v0.9.15: mship improvements, memory improvements, nextjs bump, plane and ramp integrations, outbox hardening

This commit is contained in:
Waleed
2026-10-07 23:35:16 -07:00
committed by GitHub
1188 changed files with 310145 additions and 13074 deletions
+2 -2
View File
@@ -66,8 +66,8 @@ Each grep lists candidates; review every match against the expected ones named b
- Editable pages: confirm Save/Discard go through `saveDiscardActions()` and
dirty is wired via `useSettingsUnsavedGuard` (called before early-return
gates) — flag any hand-rolled Save button, `beforeunload`, or unsaved modal.
`git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/'`
should only hit the centralized `use-settings-before-unload.ts`.
`git grep -n "beforeunload" -- 'apps/sim/**/settings/**' 'apps/sim/ee/' 'apps/sim/components/settings/' ':(exclude,glob)**/*.test.*'`
should only hit the centralized `use-settings-browser-navigation.ts`.
5. Fix each finding with the smallest structural change that satisfies the checklist;
do not touch handlers, state, queries, or gate returns. A pixel-size fix swaps
only the size class for its exact-pixel token (`text-[12px]` → `text-caption`).
+5 -2
View File
@@ -47,7 +47,7 @@ When the user runs `/ship`:
- Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version).
- `(cd packages/db && bunx drizzle-kit generate && git status --porcelain ./migrations)` must print nothing (CI's schema/migration sync step).
- `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy.
6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed.
6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `lint` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed.
**Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry), `docs-manifest:generate` (derives from docs page paths), and `skills:sync` (derives from `.agents/skills/**`). They write disjoint outputs (`apps/docs/…/agent.mdx`, `apps/sim/lib/mothership/generated/docs-manifest.ts`, and `.claude/skills` links), so they parallelize safely, and each is idempotent (a no-op when already in sync):
```bash
@@ -66,7 +66,7 @@ When the user runs `/ship`:
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/mothership/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. `scripts/generate-docs.ts` rewrites the integration docs and client-safe catalog; run it when this PR changes their block/icon/landing-content inputs or when `integration-catalog:check` reports drift, then review its broad generated diff. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** Before running the commands, compare this list with `.github/workflows/test-build.yml`; when CI adds an audit, run it and update this skill instead of trusting a stale snapshot. The env-flag audit is currently an inline workflow block rather than a package script: when `apps/sim/lib/core/config/env-flags.ts` changed, run that current workflow block verbatim instead of copying a second version into this skill. Run `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then run the base-sensitive block-registry check, then fan the independent audits out and collect exit codes:
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** Before running the commands, compare this list with `.github/workflows/checks.yml`; when CI adds an audit, run it and update this skill instead of trusting a stale snapshot. The env-flag audit is currently an inline workflow block rather than a package script: when `apps/sim/lib/core/config/env-flags.ts` changed, run that current workflow block verbatim instead of copying a second version into this skill. Run `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then run the base-sensitive block-registry check, then fan the independent audits out and collect exit codes:
```bash
# autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too —
# a non-zero lint (unfixable errors) must abort before the audits run, not be ignored.
@@ -77,6 +77,9 @@ When the user runs `/ship`:
}
# Runs every audit CI runs, concurrently, and replays the output of any that fail.
# The audit list is derived in scripts/run-audits.ts — do not hand-list audits here.
# Install CI's pinned actionlint version for the host OS/architecture and verify its
# artifact against the official release checksums in a local mktemp directory.
# Preserve CI's -shellcheck= -pyflakes= flags; lint all workflows and abort ship if it fails.
bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; }
bun run type-check || { echo "❌ type-check failed — do not ship"; exit 1; }
# CI's "Verify docs manifest is in sync" step is not a `check:*` script, so the runner above
+1
View File
@@ -17,6 +17,7 @@ paths:
- All copy is server-rendered text: no text baked into images, no content that exists only after a client effect runs.
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element). The navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
- Structured data: emit JSON-LD (`Organization`, `WebSite`, `WebApplication` with `featureList`, `FAQPage` if an FAQ exists) from a server component rendered before visible content. Keep `featureList` in sync with the features the page shows (`apps/sim/app/(landing)/components/home-structured-data/`).
- `/library` and `/blog` pagination stays server-rendered with real next/previous links. Each unfiltered page is indexable and uses its own canonical URL (`?page=2`, etc.); page 1 canonicalizes to the bare index. Tag-filtered variants remain `noindex,follow`. See [Google pagination guidance](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading).
- After adding routes or anchors, verify `app/sitemap.ts` and `app/robots.ts` still reflect reality.
## GEO (Generative Engine Optimisation)
+27 -28
View File
@@ -339,34 +339,33 @@ rendered through `<SettingsActionChips actions={…} />` from
`@/components/settings/settings-header` — that is the shared chip path, and it
is what keeps tone/icon/variant/tooltip handling from drifting between the two
shells. Reach for it before hand-rolling a `Chip`.
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack,
confirmDiscard }` for a detail view's **in-view back** chip.
- **Top-level pages** (whitelabeling, sso): call it **unassigned** —
`useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the
store-sync; the sidebar/`beforeunload` do the rest.
- **Detail sub-views** (data-retention, access-control group-detail): route the
back chip through `onClick={() => guard.guardBack(closeFn)}` and render the
shared `<UnsavedChangesModal open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} />`
(from `@/app/workspace/[workspaceId]/components/credential-detail`). The
in-view header **Discard** chip (via `saveDiscardActions({ onDiscard })`) is a
*reset to original* — distinct from the back-confirm's discard, which leaves.
- **`useSettingsBeforeUnload`** is mounted by the settings shells
(`settings/layout.tsx` and `components/settings/standalone-settings-shell.tsx`) —
never add a per-page `beforeunload`.
- **Dirty *computation* stays local** (shapes differ: field-compare vs
normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a
`const`/`useMemo`), never store it in `useState`.
- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)`
**unconditionally, before every early-return gate** (entitlement / loading /
not-entitled `return <SettingsEmptyState>`). A hook placed after a gate is
skipped on gated renders and crashes.
- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it
guards real `router.push` navigation + browser Back via a history sentinel);
it already shares `UnsavedChangesModal`, so copy stays unified.
- **`useSettingsUnsavedGuard({ isDirty, navigationBlocked, onDiscard })`** from
`@/components/settings/use-settings-unsaved-guard` registers one editor with the
shared settings store. Register every full-page draft, including inline creation
forms. Dirty computation stays local and derived; fetched defaults stay clean.
- **`onDiscard` resets the registered draft.** The root `SettingsNavigationGuard`
owns the discard dialog, internal link interception, browser Back/Forward, and
refresh protection for registered editors. Do not add a per-page dialog or
history listener for an editor using this hook. Skill create/detail pages retain
their existing `useUnsavedChangesGuard` and local dialog until they migrate to
the shared registration.
- **Native history needs entry indexes.** The Navigation API supplies them for
legacy entries. Without it, native traversal to an unindexed entry cannot be
cancelled reliably; never guess a direction or rewrite the history stack.
- **History confirmation authorizes traversal before discarding.** Drafts are
discarded only when the browser reports the confirmed traversal, so a no-op
Back or Forward retains edits. Known cross-document traversal uses native unload
protection. An unknown cross-document target on a classic History browser can
require a second native confirmation after the shared dialog.
- **Detail back controls** call `guard.guardBack(closeFn)`. Other destructive view
transitions, such as switching an editor's direction or environment, also use
the shared `requestLeave` action before resetting their draft.
- **`navigationBlocked` covers pending saves and uploads.** The shared guard blocks
navigation while requests are pending. Attempts are not queued; retry after they
settle. Disable or preserve edits made during requests;
failed saves retain drafts, and successful saves only clear committed values.
- **Call the hook unconditionally, before every early-return gate** (entitlement,
loading, or empty-state return), so gated renders preserve hook order.
## Detail sub-views
+3 -3
View File
@@ -32,11 +32,11 @@ contracts, and demonstrated regressions.
| Suffix | Needs | Run with | In CI |
|--------|-------|----------|-------|
| `*.test.ts(x)` | nothing; global mocks from `vitest.setup.ts` | `vitest run` | Lint and Test job |
| `*.integration.ts` | real PostgreSQL (`TEST_DATABASE_URL`), optionally Redis (`TEST_REDIS_URL`) | `vitest run --mode integration` | `PostgreSQL integration` job, by glob |
| `*.test.ts(x)` | nothing; global mocks from `vitest.setup.ts` | `vitest run` | `test` jobs (sharded) |
| `*.integration.ts` | real PostgreSQL (`TEST_DATABASE_URL`), optionally Redis (`TEST_REDIS_URL`) | `vitest run --mode integration` | `integration` jobs, by glob (sharded per provisioning path) |
| `*.live.test.ts` | provider APIs, hosted sandboxes, local runtimes, or sibling checkouts | `vitest run --mode live <file>` (apps/sim) | never |
| `apps/desktop/e2e/*.spec.ts` | the packaged Electron app | Playwright | desktop E2E workflow |
| `apps/sim/scripts/test-*-e2e.ts` | a running app over HTTP | its `package.json` script when one exists (`bun run test:scim:e2e`; `test:workflow-version-compare:e2e` adds `--no-env-file`), else `bun scripts/test-<suite>-e2e.ts` from apps/sim | End-to-end over real HTTP job |
| `apps/sim/scripts/test-*-e2e.ts` | a running app over HTTP | its `package.json` script when one exists (`bun run test:scim:e2e`; `test:workflow-version-compare:e2e` adds `--no-env-file`), else `bun scripts/test-<suite>-e2e.ts` from apps/sim | `e2e` jobs (`.github/scripts/http-e2e.sh`) |
- A unit test lives next to its source: `feature.ts` → `feature.test.ts`. No network, no database,
no real timers.
+1
View File
@@ -17,6 +17,7 @@ globs: ["apps/sim/app/(landing)/**/*.tsx","apps/sim/content/**/*.mdx"]
- All copy is server-rendered text: no text baked into images, no content that exists only after a client effect runs.
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element). The navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
- Structured data: emit JSON-LD (`Organization`, `WebSite`, `WebApplication` with `featureList`, `FAQPage` if an FAQ exists) from a server component rendered before visible content. Keep `featureList` in sync with the features the page shows (`apps/sim/app/(landing)/components/home-structured-data/`).
- `/library` and `/blog` pagination stays server-rendered with real next/previous links. Each unfiltered page is indexable and uses its own canonical URL (`?page=2`, etc.); page 1 canonicalizes to the bare index. Tag-filtered variants remain `noindex,follow`. See [Google pagination guidance](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading).
- After adding routes or anchors, verify `app/sitemap.ts` and `app/robots.ts` still reflect reality.
## GEO (Generative Engine Optimisation)
+27 -28
View File
@@ -336,34 +336,33 @@ rendered through `<SettingsActionChips actions={…} />` from
`@/components/settings/settings-header` — that is the shared chip path, and it
is what keeps tone/icon/variant/tooltip handling from drifting between the two
shells. Reach for it before hand-rolling a `Chip`.
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack,
confirmDiscard }` for a detail view's **in-view back** chip.
- **Top-level pages** (whitelabeling, sso): call it **unassigned** —
`useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the
store-sync; the sidebar/`beforeunload` do the rest.
- **Detail sub-views** (data-retention, access-control group-detail): route the
back chip through `onClick={() => guard.guardBack(closeFn)}` and render the
shared `<UnsavedChangesModal open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} />`
(from `@/app/workspace/[workspaceId]/components/credential-detail`). The
in-view header **Discard** chip (via `saveDiscardActions({ onDiscard })`) is a
*reset to original* — distinct from the back-confirm's discard, which leaves.
- **`useSettingsBeforeUnload`** is mounted by the settings shells
(`settings/layout.tsx` and `components/settings/standalone-settings-shell.tsx`) —
never add a per-page `beforeunload`.
- **Dirty *computation* stays local** (shapes differ: field-compare vs
normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a
`const`/`useMemo`), never store it in `useState`.
- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)`
**unconditionally, before every early-return gate** (entitlement / loading /
not-entitled `return <SettingsEmptyState>`). A hook placed after a gate is
skipped on gated renders and crashes.
- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it
guards real `router.push` navigation + browser Back via a history sentinel);
it already shares `UnsavedChangesModal`, so copy stays unified.
- **`useSettingsUnsavedGuard({ isDirty, navigationBlocked, onDiscard })`** from
`@/components/settings/use-settings-unsaved-guard` registers one editor with the
shared settings store. Register every full-page draft, including inline creation
forms. Dirty computation stays local and derived; fetched defaults stay clean.
- **`onDiscard` resets the registered draft.** The root `SettingsNavigationGuard`
owns the discard dialog, internal link interception, browser Back/Forward, and
refresh protection for registered editors. Do not add a per-page dialog or
history listener for an editor using this hook. Skill create/detail pages retain
their existing `useUnsavedChangesGuard` and local dialog until they migrate to
the shared registration.
- **Native history needs entry indexes.** The Navigation API supplies them for
legacy entries. Without it, native traversal to an unindexed entry cannot be
cancelled reliably; never guess a direction or rewrite the history stack.
- **History confirmation authorizes traversal before discarding.** Drafts are
discarded only when the browser reports the confirmed traversal, so a no-op
Back or Forward retains edits. Known cross-document traversal uses native unload
protection. An unknown cross-document target on a classic History browser can
require a second native confirmation after the shared dialog.
- **Detail back controls** call `guard.guardBack(closeFn)`. Other destructive view
transitions, such as switching an editor's direction or environment, also use
the shared `requestLeave` action before resetting their draft.
- **`navigationBlocked` covers pending saves and uploads.** The shared guard blocks
navigation while requests are pending. Attempts are not queued; retry after they
settle. Disable or preserve edits made during requests;
failed saves retain drafts, and successful saves only clear committed values.
- **Call the hook unconditionally, before every early-return gate** (entitlement,
loading, or empty-state return), so gated renders preserve hook order.
## Detail sub-views
+3 -3
View File
@@ -30,11 +30,11 @@ contracts, and demonstrated regressions.
| Suffix | Needs | Run with | In CI |
|--------|-------|----------|-------|
| `*.test.ts(x)` | nothing; global mocks from `vitest.setup.ts` | `vitest run` | Lint and Test job |
| `*.integration.ts` | real PostgreSQL (`TEST_DATABASE_URL`), optionally Redis (`TEST_REDIS_URL`) | `vitest run --mode integration` | `PostgreSQL integration` job, by glob |
| `*.test.ts(x)` | nothing; global mocks from `vitest.setup.ts` | `vitest run` | `test` jobs (sharded) |
| `*.integration.ts` | real PostgreSQL (`TEST_DATABASE_URL`), optionally Redis (`TEST_REDIS_URL`) | `vitest run --mode integration` | `integration` jobs, by glob (sharded per provisioning path) |
| `*.live.test.ts` | provider APIs, hosted sandboxes, local runtimes, or sibling checkouts | `vitest run --mode live <file>` (apps/sim) | never |
| `apps/desktop/e2e/*.spec.ts` | the packaged Electron app | Playwright | desktop E2E workflow |
| `apps/sim/scripts/test-*-e2e.ts` | a running app over HTTP | its `package.json` script when one exists (`bun run test:scim:e2e`; `test:workflow-version-compare:e2e` adds `--no-env-file`), else `bun scripts/test-<suite>-e2e.ts` from apps/sim | End-to-end over real HTTP job |
| `apps/sim/scripts/test-*-e2e.ts` | a running app over HTTP | its `package.json` script when one exists (`bun run test:scim:e2e`; `test:workflow-version-compare:e2e` adds `--no-env-file`), else `bun scripts/test-<suite>-e2e.ts` from apps/sim | `e2e` jobs (`.github/scripts/http-e2e.sh`) |
- A unit test lives next to its source: `feature.ts` → `feature.test.ts`. No network, no database,
no real timers.
@@ -1,4 +1,4 @@
name: Cache Mount
name: cache
description: Mount a build cache directory using Blacksmith sticky disks, or the GitHub Actions cache when running on GitHub-hosted runners.
inputs:
@@ -1,4 +1,4 @@
name: Docker Build and Push
name: image
description: Set up a buildx builder and build/push an image, using Blacksmith's builder or the upstream Docker actions on GitHub-hosted runners.
inputs:
@@ -35,7 +35,7 @@ inputs:
required: false
# Registry logins must precede this action. provenance/sbom stay off: attestation
# manifests break `imagetools create` retagging in promote-images.
# manifests break `imagetools create` retagging in the `promote` job of ci.yml.
runs:
using: composite
steps:
@@ -1,15 +1,23 @@
name: Setup Workspace
name: setup
description: Install the pinned Bun and Node toolchain, mount the dependency (and optionally Turbo) caches, and install workspace dependencies.
inputs:
provider:
description: The CI_PROVIDER repo variable, forwarded to cache-mount.
description: The CI_PROVIDER repo variable, forwarded to the cache action.
required: false
default: ''
turbo-cache-key:
description: Suffix for a Turbo cache mounted at ./.turbo. Empty skips the mount. Jobs that write Turbo entries need distinct suffixes, or last-writer-wins commits evict each other's entries.
required: false
default: ''
node-version:
description: Node version to install. Empty keeps the runner's preinstalled Node, for jobs that only ever ran Bun.
required: false
default: '24'
registry-url:
description: npm registry to write an .npmrc for, so `npm publish` reads NODE_AUTH_TOKEN. Empty writes none.
required: false
default: ''
# Cache keys are scoped by event name, and fork PRs get their own namespace on
# top: untrusted fork runs must never share a cache with push runs (whose caches
@@ -30,19 +38,21 @@ runs:
bun-version: 1.4.2
- name: Setup Node
if: inputs.node-version != ''
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
node-version: ${{ inputs.node-version }}
registry-url: ${{ inputs.registry-url }}
- name: Mount Bun cache
uses: ./.github/actions/cache-mount
uses: ./.github/actions/cache
with:
provider: ${{ inputs.provider }}
key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
path: ~/.bun/install/cache
- name: Mount node_modules
uses: ./.github/actions/cache-mount
uses: ./.github/actions/cache
with:
provider: ${{ inputs.provider }}
key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }}
@@ -50,7 +60,7 @@ runs:
- name: Mount Turbo cache
if: inputs.turbo-cache-key != ''
uses: ./.github/actions/cache-mount
uses: ./.github/actions/cache
with:
provider: ${{ inputs.provider }}
key: ${{ github.repository }}-${{ inputs.turbo-cache-key }}-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
+19
View File
@@ -0,0 +1,19 @@
version: 2
# GitHub Actions only: every `uses:` is pinned to a commit SHA, and without this the pins drift
# apart (checkout had three different SHAs across workflows). One grouped PR a week keeps every
# workflow and composite action on the same version of each action.
updates:
- package-ecosystem: github-actions
directories:
- /
- /.github/actions/*
schedule:
interval: weekly
target-branch: staging
groups:
actions:
patterns:
- '*'
commit-message:
prefix: ci
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Prints `changed=false` only when every file a pull request changes is clearly unrelated to the
# live desktop suite, and `changed=true` otherwise, including when the diff cannot be worked out.
#
# Usage: desktop-live-changes.sh <base-sha>
set -u
unrelated='^(apps/docs/|apps/pii/|apps/sim/content/|packages/(python-sdk|ts-sdk)/)|\.mdx?$|(^|/)LICENSE$'
base=${1:-}
run() {
echo "changed=true"
echo "Running the live desktop suite: $1" >&2
exit 0
}
[ -n "$base" ] || run 'no base commit'
git fetch --quiet --depth=1 origin "$base" || run "could not fetch $base"
names=$(git diff --no-renames --name-only "$base" HEAD) || run "could not diff against $base"
[ -n "$names" ] || run 'no changed files listed'
if printf '%s\n' "$names" | grep -qvE "$unrelated"; then
run 'a change may affect it'
fi
echo "changed=false"
echo 'Skipping the live desktop suite: every change is unrelated to it' >&2
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Runs one end-to-end suite group over real HTTP, each against its own `next dev` app.
#
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox> (run from apps/sim)
#
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
#
# The first request cold-compiles the app under Turbopack, which takes 42-150s on CI runners, so
# the readiness deadline only has to catch a hung boot: an exited server fails immediately, and
# either way the server log tail lands in the job log.
#
# Each app starts from an empty Turbopack dev cache: a cache written under other NEXT_PUBLIC_*
# values, by a server that `next dev` SIGKILLs 100ms after SIGTERM, can panic Turbopack or wedge a
# route compile on restore. It runs in its own session under an E2E_APP tag, and stop-session.sh
# returns only once every process in that session or carrying that tag has exited (Next's
# telemetry flush runs detached and still writes .next/dev).
set -euo pipefail
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox>}
report_dir="$RUNNER_TEMP/e2e"
ready_timeout_seconds=300
mkdir -p "$report_dir"
server_pid=''
app_tag=''
server_log=''
status_log=''
finish() {
local status=$?
if [ -n "$server_pid" ]; then
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag" || status=1
wait "$server_pid" 2>/dev/null || true
if [ -n "$status_log" ]; then
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$status_log"
fi
if [ "$status" -ne 0 ]; then
tail -n 200 "$server_log"
fi
fi
exit "$status"
}
trap finish EXIT
# start_app <name> <port> <label> [record-http-status]
start_app() {
local name=$1 port=$2 label=$3
server_log="$report_dir/$name-next.log"
if [ "${4:-}" = record-http-status ]; then
status_log="$report_dir/$name-http-status.log"
fi
app_tag="$name-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
export NEXT_PUBLIC_APP_URL="http://127.0.0.1:$port"
export BETTER_AUTH_URL="$NEXT_PUBLIC_APP_URL"
export DISABLE_TELEMETRY=true NEXT_TELEMETRY_DISABLED=1
rm -rf .next/dev
E2E_APP="$app_tag" setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
--port "$port" > "$server_log" 2>&1 &
server_pid=$!
local started=$SECONDS
until curl --fail --silent --max-time 10 "$NEXT_PUBLIC_APP_URL/api/health" > /dev/null; do
if ! kill -0 "$server_pid" 2>/dev/null; then
echo "::error::Local $label app exited during startup."
exit 1
fi
if [ $((SECONDS - started)) -ge "$ready_timeout_seconds" ]; then
echo "::error::Local $label app did not become ready within $ready_timeout_seconds seconds."
exit 1
fi
sleep 2
done
echo "Local $label app ready after $((SECONDS - started))s"
}
case "$group" in
scim)
export NEXT_PUBLIC_FORCE_HOSTED=true
export BILLING_ENABLED=true NEXT_PUBLIC_BILLING_ENABLED=true
export ENTERPRISE_ENABLED=true NEXT_PUBLIC_ENTERPRISE_ENABLED=true
export SCIM_ENABLED=true NEXT_PUBLIC_SCIM_ENABLED=true
export SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true
export ORGANIZATIONS_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
export INTERNAL_API_SECRET=scim-http-ci-local-secret-at-least-32-characters
export DB_TX_TRIPWIRE=throw
export NEXT_PUBLIC_CHAT_DISABLED=true
start_app scim 3017 SCIM record-http-status
SCIM_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
SCIM_E2E_REPORT_PATH="$report_dir/scim-e2e-report.json" \
bun run test:scim:e2e
VERSION_COMPARE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
VERSION_COMPARE_E2E_DATABASE_URL="$DATABASE_URL" \
VERSION_COMPARE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
VERSION_COMPARE_E2E_REPORT_PATH="$report_dir/version-compare-http-report.json" \
bun run test:workflow-version-compare:e2e
;;
# The search suites serve their own fixtures in-process and need no app. They share this group
# because they finish in seconds. Self-hosted without billing: hosted billing admits runs through
# Redis, which the CLI app is not given.
cli)
for search in google-content lucid zoom google-meet; do
report_var="SEARCH_$(echo "$search" | tr 'a-z-' 'A-Z_')_REPORT_PATH"
env NEXT_PUBLIC_APP_URL=http://127.0.0.1:3040 NEXT_PUBLIC_FORCE_HOSTED=false \
"$report_var=$report_dir/search-$search.json" \
bun "scripts/test-search-$search-e2e.ts"
done
export NEXT_PUBLIC_FORCE_HOSTED=false
export INTERNAL_API_SECRET=cli-http-ci-local-secret-at-least-32-characters
start_app cli 3018 CLI
CLI_LATENCY_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
CLI_LATENCY_E2E_DATABASE_URL="$DATABASE_URL" \
CLI_LATENCY_E2E_RUNS=3 \
CLI_LATENCY_E2E_WARMUP=1 \
CLI_LATENCY_E2E_REPORT_PATH="$report_dir/cli-run-latency-report.json" \
bun run test:cli-run-latency:e2e
;;
# Self-hosted: hosted billing admits a run only through a Redis usage reservation.
stop-after)
export NEXT_PUBLIC_FORCE_HOSTED=false
export CRON_SECRET=stop-after-e2e-local-cron-secret
export INTERNAL_API_SECRET=stop-after-http-ci-local-secret-at-least-32-characters
export DB_TX_TRIPWIRE=throw
export NEXT_PUBLIC_CHAT_DISABLED=true
start_app stop-after 3018 workflow record-http-status
STOP_AFTER_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
STOP_AFTER_E2E_DATABASE_URL="$DATABASE_URL" \
STOP_AFTER_E2E_REPORT_PATH="$report_dir/stop-after-http-report.json" \
STOP_AFTER_E2E_CRON_SECRET="$CRON_SECRET" \
bun run test:workflow-stop-after:e2e
;;
# The desktop background executor's protocol: device registration, the SSE doorbell over Redis
# pub/sub, presence, leased claims, Stop and isolation. The only group whose app gets Redis.
desktop-inbox)
export REDIS_URL=redis://127.0.0.1:6379
export NEXT_PUBLIC_FORCE_HOSTED=false
export COPILOT_TOOL_PERMISSIONS_ENABLED=true
export INTERNAL_API_SECRET=desktop-inbox-http-ci-local-secret-at-least-32-characters
export DB_TX_TRIPWIRE=throw
start_app desktop-inbox 3019 'desktop executor' record-http-status
DESKTOP_INBOX_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
DESKTOP_INBOX_E2E_DATABASE_URL="$DATABASE_URL" \
DESKTOP_INBOX_E2E_REDIS_URL="$REDIS_URL" \
DESKTOP_INBOX_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
DESKTOP_INBOX_E2E_REPORT_PATH="$report_dir/desktop-inbox-http-report.json" \
bun run test:desktop-inbox:e2e
;;
*)
echo "::error::Unknown end-to-end group: $group" >&2
exit 2
;;
esac
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Stops every process an app started, and returns only once none is left.
#
# Usage: stop-session.sh <session-leader-pid> <app-tag>
#
# Start the app as `E2E_APP=<app-tag> setsid <command> &`, with a tag no other process of this
# user carries (CI uses `<app>-<run id>-<attempt>-<shell pid>`). Its processes are the members of
# the leader's session, plus any process whose environment holds exactly `E2E_APP=<app-tag>` but
# left the session. This script, its ancestors and its own children are never signalled, even
# when they carry the tag.
# `next dev` needs both: its server and workers stay in the session, while its telemetry flush is
# spawned detached, in a session of its own, and writes .next/dev/trace after `next dev` exits.
# The next app in the job wipes that directory, so it must not start while any of them runs.
#
# Every member is signalled, including one that moved to its own process group, and one that
# started after the first signal.
#
# Zombies don't count as running: they have exited and only await reaping. The leader is one
# until the shell that started it waits on it, which that shell does after this returns.
set -u
leader=${1:-}
tag=${2:-}
grace_seconds=10
if ! [[ "$leader" =~ ^[0-9]+$ ]] || [ -z "$tag" ]; then
echo "::error::Usage: stop-session.sh <session-leader-pid> <app-tag>, with a non-empty tag." >&2
exit 2
fi
# Sets `state` and `parent` from /proc/<pid>/stat without forking, so a scan stays cheap, and
# fails once the process is gone. The command name can hold spaces and parentheses, so the
# fields are read after its last ")".
read_stat() {
local stat rest
read -r stat 2>/dev/null < "/proc/$1/stat" || return 1
rest=${stat##*") "}
state=${rest%% *}
rest=${rest#* }
parent=${rest%% *}
}
ancestors=" "
pid=$$
while read_stat "$pid" && [ "$parent" -gt 0 ]; do
ancestors+="$parent "
pid=$parent
[ "$pid" -eq 1 ] && break
done
# This script, an ancestor of it, or one of its own subshells and commands, which inherit the tag
# when the caller exported it. When a process on the walk exits mid-walk, the candidate has been
# reparented, so the walk restarts from it; ancestry that still won't resolve counts as not ours,
# so an app process is never spared by accident.
is_own() {
local candidate=$1 pid attempt
[[ "$ancestors" == *" $candidate "* ]] && return 0
for attempt in 1 2 3; do
pid=$candidate
while [ "$pid" -gt 1 ]; do
[ "$pid" -eq $$ ] && return 0
read_stat "$pid" || continue 2
pid=$parent
done
return 1
done
return 1
}
app_pids() {
local pid
{
ps -s "$leader" -o pid= 2>/dev/null | tr -d ' '
grep -Flzx "E2E_APP=$tag" /proc/[0-9]*/environ 2>/dev/null | cut -d/ -f3
} | sort -nu | while read -r pid; do
read_stat "$pid" || continue
[ "$state" = Z ] && continue
is_own "$pid" || echo "$pid"
done
}
signal_app() {
local pids
pids=$(app_pids)
[ -z "$pids" ] || kill "-$1" $pids 2>/dev/null || true
}
running() {
[ -n "$(app_pids)" ]
}
leader_running() {
ps -p "$leader" -o stat= 2>/dev/null | grep -qv '^Z'
}
# Prints one scan of the app's processes as a table, the expected telemetry flush labelled in a
# leading column, and fails when that scan finds none, so no caller prints an empty heading.
describe_app() {
local pids table
pids=$(app_pids | paste -sd, -)
[ -n "$pids" ] || return 1
table=$(ps -p "$pids" -o pid,sid,stat,etimes,args 2>/dev/null)
[ "$(wc -l <<< "$table")" -gt 1 ] || return 1
awk '{
note = ""
if (NR > 1 && $0 ~ /next\/dist\/telemetry\/detached-flush\.js/) note = "(expected telemetry flush)"
printf "%-26s %s\n", note, $0
}' <<< "$table" | cut -c1-240
}
# Centiseconds since boot: monotonic, and independent of the locale's decimal separator.
now_cs() {
local uptime
read -r uptime _ < /proc/uptime
echo "${uptime/./}"
}
# Re-sends the signal every 0.1s while the check holds, until the shared deadline. Succeeds
# once the check has stopped holding on two scans 0.1s apart, so a process missed by one scan
# (spawned, or mid-reparenting, while it ran) is still caught.
signal_while() {
local signal=$1 clear=0
shift
while ((10#$(now_cs) < deadline)); do
if "$@"; then
clear=0
signal_app "$signal"
else
clear=$((clear + 1))
((clear >= 2)) && return 0
fi
sleep 0.1
done
"$@" && return 1
sleep 0.1
! "$@"
}
deadline=$((10#$(now_cs) + grace_seconds * 100))
signal_while TERM leader_running
if ! leader_running && table=$(describe_app); then
echo "Processes from app $tag outlived its leader:"
echo "$table"
fi
signal_while TERM running && exit 0
echo "::warning::Processes from app $tag were still running ${grace_seconds}s after SIGTERM:"
describe_app || true
deadline=$((10#$(now_cs) + grace_seconds * 100))
signal_while KILL running && exit 0
echo "::error::Processes from app $tag survived SIGKILL for ${grace_seconds}s."
exit 1
+737
View File
@@ -0,0 +1,737 @@
name: checks
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
integration:
# Runs the real-infrastructure test layer: every `*.integration.ts` in packages/db and
# apps/sim, discovered by glob (`vitest run --mode integration`), against the database each
# provisioning path produces. A new integration suite needs no workflow change.
#
# The two paths build different schemas (migrations add triggers, checks and NOT VALID
# constraints that `db:push` does not), so each runs the whole suite. Vitest splits each
# suite's files across four shards by measured duration (DurationBalancedSequencer in vitest.shared.ts); a
# shard runs its files one at a time against its own database. Files run serially, so a shard
# barely uses more than one core: 4 vCPU is enough.
name: integration (${{ matrix.provision }}, ${{ matrix.shard }}/4)
runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
provision: [push, migrate]
shard: [1, 2, 3, 4]
services:
redis:
image: redis:8.2-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
postgres-legacy:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
TEST_REDIS_URL: redis://127.0.0.1:6379
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
BETTER_AUTH_SECRET: oauth-postgres-ci-secret-at-least-32-characters
NEXT_PUBLIC_APP_URL: https://test.sim.ai
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
steps:
# A same-repository pull request checks out through a git mirror on a sticky disk, so a slow
# clone from GitHub (1 in 10 took a minute or more, up to 4) no longer sets the run's pace.
# The mirror is one disk per repository that job steps can write to, so fork pull requests
# and pushes, whose checks gate a deploy, keep the plain checkout: same trust split as the
# setup action's caches.
- &checkout-mirror
name: Checkout code (mirror)
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1
- &checkout-plain
name: Checkout code
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
- name: Provision a fresh database through the supported command
working-directory: packages/db
run: |
bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); for (const extension of ["vector", "btree_gin", "pg_trgm"]) await sql`CREATE EXTENSION IF NOT EXISTS ${sql(extension)}`; await sql.end()'
bun run db:${{ matrix.provision }}
- name: Verify migration replay is a no-op
if: matrix.provision == 'migrate' && matrix.shard == 1
working-directory: packages/db
run: bun run db:migrate
- name: Run packages/db integration tests
working-directory: packages/db
run: bun run test --mode integration --shard=${{ matrix.shard }}/4
- name: Run apps/sim integration tests
working-directory: apps/sim
# A non-UTC process zone keeps timestamp-without-time-zone handling honest.
env:
TZ: America/Los_Angeles
run: bun run test --mode integration --shard=${{ matrix.shard }}/4
- name: Verify cumulative billing timeout recovery on PostgreSQL 16
if: matrix.provision == 'push' && matrix.shard == 1
working-directory: apps/sim
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_test
run: >-
bun run test --mode integration lib/billing/core/usage-log.integration.ts
--outputFile.json=test-results/integration-pg16.json
- name: Upload integration test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-reports-${{ matrix.provision }}-${{ matrix.shard }}
path: |
packages/db/test-results/*.json
apps/sim/test-results/*.json
if-no-files-found: warn
retention-days: 14
# Acceptance suites that cross a real HTTP boundary, one job per app, each on its own database.
# Off the integration jobs' path: the SCIM app boots hosted, which starts background usage replay
# against DATABASE_URL, so it must never share a database with suites asserting on billing rows.
# The suites exercise HTTP behavior rather than a provisioning path, so they run once, against
# the production (migrate) path. Each group's app environment lives in http-e2e.sh.
#
# SCIM runs two suites against a hosted app and is the longest group, so it keeps the 8 vCPU
# runner; the others boot a smaller self-hosted app and fit on 4.
e2e:
name: e2e (${{ matrix.group }})
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- group: scim
runner: blacksmith-8vcpu-ubuntu-2404
- group: cli
runner: blacksmith-4vcpu-ubuntu-2404
- group: stop-after
runner: blacksmith-4vcpu-ubuntu-2404
- group: desktop-inbox
runner: blacksmith-4vcpu-ubuntu-2404
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
# Only the desktop executor's app is given REDIS_URL: its doorbell and presence live there.
redis:
image: redis:8.2-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
BETTER_AUTH_SECRET: http-e2e-ci-secret-at-least-32-characters
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
steps:
- *checkout-mirror
- *checkout-plain
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
# Migrations create their own extensions, as on a fresh self-hosted install.
- name: Provision the database through migrations
working-directory: packages/db
run: bun run db:migrate
# No step timeout: the job's bound covers a hang without cutting a slow but healthy suite
# short of writing its report.
- name: Run end-to-end suites
working-directory: apps/sim
run: bash ../../.github/scripts/http-e2e.sh "${{ matrix.group }}"
- name: Upload end-to-end reports and server logs
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: http-e2e-${{ matrix.group }}
path: ${{ runner.temp }}/e2e/
if-no-files-found: ignore
retention-days: 7
# Pull requests skip the live desktop suite only when every change is clearly unrelated to the
# app it drives (docs, other apps, published content). Anything else, and any failure to work
# out the diff, runs it: a pull request that skipped it wrongly would first fail on staging.
desktop-changes:
name: desktop-changes
runs-on: &runner-2vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
changed: ${{ github.event_name != 'pull_request' || steps.diff.outputs.changed != 'false' }}
steps:
- name: Checkout code (mirror)
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1
with:
fetch-depth: 2
- name: Checkout code
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 2
- name: Diff against the pull request's base
id: diff
if: github.event_name == 'pull_request'
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: bash .github/scripts/desktop-live-changes.sh "$BASE" >> "$GITHUB_OUTPUT"
# Desktop tools in the real Electron app against a local app, on its own runner: the
# Electron app, the dev app and its realtime server together outgrow the e2e runner.
desktop-live:
name: desktop-live
needs: desktop-changes
if: needs.desktop-changes.outputs.changed == 'true'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:8.2-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
BETTER_AUTH_SECRET: desktop-live-e2e-ci-secret-at-least-32-characters
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
steps:
- *checkout-mirror
- *checkout-plain
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
- name: Provision the database through migrations
working-directory: packages/db
run: bun run db:migrate
# Turbopack's dev cache turns the spec's route warm-up from a cold compile (~4 min) into a
# restore. It is content-addressed, so a pull request's changed modules still recompile; the
# key carries the installed Next version so an upgrade starts from an empty cache, and the
# event and fork segments keep untrusted runs off the cache trusted runs read.
- name: Resolve Turbopack dev cache key
id: next-cache
run: echo "key=${GITHUB_REPOSITORY}-next-dev-desktop-live-${GITHUB_EVENT_NAME}${FORK_SUFFIX}-$(jq -r .version node_modules/next/package.json)" >> "$GITHUB_OUTPUT"
env:
FORK_SUFFIX: ${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
- name: Mount Turbopack dev cache
uses: ./.github/actions/cache
with:
provider: ${{ vars.CI_PROVIDER }}
key: ${{ steps.next-cache.outputs.key }}
path: ./apps/sim/.next/dev
# Chat switches, Stop, sign-out, approval, the dormant-executor round trip, and background
# runs across a chat switch and a network cut. The spec runs the recording proxy (the app's
# public origin) and the stand-in worker.
- name: Verify desktop tools in the Electron app against a local app
env:
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3020
BETTER_AUTH_URL: http://127.0.0.1:3020
REDIS_URL: redis://127.0.0.1:6379
SIM_AGENT_API_URL: http://127.0.0.1:3022
NEXT_PUBLIC_SOCKET_URL: http://127.0.0.1:3023
SOCKET_SERVER_URL: http://127.0.0.1:3023
NEXT_PUBLIC_FORCE_HOSTED: 'false'
COPILOT_API_KEY: desktop-tools-e2e-ci-local-copilot-key
COPILOT_TOOL_PERMISSIONS_ENABLED: 'true'
MOTHERSHIP_SIM_TRANSPORT: direct
INTERNAL_API_SECRET: desktop-tools-e2e-ci-local-secret-at-least-32-characters
DISABLE_TELEMETRY: 'true'
NEXT_TELEMETRY_DISABLED: '1'
READY_TIMEOUT_SECONDS: 300
run: |
report_dir="$RUNNER_TEMP/e2e"
next_log="$report_dir/desktop-tools-next.log"
mkdir -p "$report_dir"
# Each app runs in its own session under an E2E_APP tag, and stop-session.sh returns once
# every process it started has exited.
realtime_tag="desktop-realtime-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
server_tag="desktop-tools-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
# Keeps the restored cache under the cap the dev scripts apply locally.
(cd apps/sim && bun run dev:cache:cap)
start_sim() {
(cd apps/sim && E2E_APP="$server_tag" exec setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
--port 3021 >> "$next_log" 2>&1) &
server_pid=$!
}
# A corrupted cache aborts Turbopack instead of falling back. Only then is the shared cache
# dropped: a failing test must not cost every later run its warm cache.
cache_broken() {
grep -qiE 'cache corruption|turbopack.*panic|panicked' "$next_log" 2>/dev/null
}
# The cache directory is a mount point: empty it rather than remove it. Absolute, because
# the EXIT trap runs after the step has moved into apps/desktop.
clear_cache() {
find "$GITHUB_WORKSPACE/apps/sim/.next/dev" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
# SIGINT first: `next dev` SIGKILLs its server 100ms after SIGTERM, which discards a cache
# write in flight. stop-session.sh then removes anything still running.
# SIGINT is best-effort; the cleanup always runs, since workers and the detached telemetry
# flush can outlive a server that has already exited.
stop_sim() {
if kill -INT "$server_pid" 2>/dev/null; then
for _ in $(seq 1 30); do kill -0 "$server_pid" 2>/dev/null || break; sleep 1; done
fi
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$server_tag"
}
(cd apps/realtime && PORT=3023 SIM_DB_ROLE=realtime ALLOWED_ORIGINS="$NEXT_PUBLIC_APP_URL" \
E2E_APP="$realtime_tag" exec setsid bun src/index.ts > "$report_dir/desktop-tools-realtime.log" 2>&1) &
realtime_pid=$!
start_sim
finish() {
status=$?
stop_sim || status=1
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$realtime_pid" "$realtime_tag" || status=1
wait "$server_pid" "$realtime_pid" 2>/dev/null || true
if cache_broken; then
echo "::warning::Turbopack reported a broken dev cache; clearing it for the next run."
clear_cache
fi
exit "$status"
}
trap finish EXIT
# The apps boot while the runner installs Electron's libraries and bundles the shell.
sudo apt-get update -q
sudo apt-get install -yq xvfb libgtk-3-0t64 libnss3 libasound2t64 libgbm1 libxss1 \
libxtst6 libatk-bridge2.0-0t64 libxkbcommon0 > /dev/null
# Bundle only: `bun run build` also fetches the macOS node-pty prebuilds for packaging,
# which a Linux run does not use.
(cd apps/desktop && bun run scripts/build.ts)
started=$SECONDS
retried=0
until curl --fail --silent --max-time 10 http://127.0.0.1:3021/api/health > /dev/null &&
curl --fail --silent --max-time 10 http://127.0.0.1:3023/health > /dev/null; do
if ! kill -0 "$server_pid" 2>/dev/null; then
tail -n 200 "$next_log"
if [ "$retried" = 0 ] && cache_broken; then
echo "::warning::Turbopack rejected the restored dev cache; restarting from an empty cache."
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$server_tag"
clear_cache
: > "$next_log"
retried=1
start_sim
continue
fi
exit 1
fi
kill -0 "$realtime_pid" 2>/dev/null || { tail -n 200 "$report_dir/desktop-tools-realtime.log"; exit 1; }
[ $((SECONDS - started)) -lt "$READY_TIMEOUT_SECONDS" ] || { echo '::error::Local app did not become ready'; exit 1; }
sleep 2
done
echo "Local apps ready $((SECONDS - started))s after the Electron bundle"
cd apps/desktop
SIM_DESKTOP_E2E_SIM_URL=http://127.0.0.1:3021 \
SIM_DESKTOP_E2E_PROXY_PORT=3020 \
SIM_DESKTOP_E2E_AGENT_PORT=3022 \
SIM_DESKTOP_E2E_DATABASE_URL="$DATABASE_URL" \
SIM_DESKTOP_E2E_REDIS_URL="$REDIS_URL" \
SIM_DESKTOP_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
xvfb-run -a -s '-screen 0 1920x1200x24' bunx playwright test e2e/desktop-tools-live-sim.spec.ts \
--output "$report_dir/desktop-tools-results" --retries=0
- name: Upload Electron E2E results and server logs
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-live-e2e-results
path: ${{ runner.temp }}/e2e/
if-no-files-found: ignore
retention-days: 7
# Lint, audits, type-check and the schema sync check: about two minutes of mostly cached work,
# kept off the test shards so neither waits on the other.
lint:
name: lint
runs-on: *runner-4vcpu
timeout-minutes: 15
steps:
# The diff-based audits below need a base commit to read, and the default
# depth of 1 clones a single commit with no parent. They normally fetch
# their base by SHA (see "Resolve base ref"), so this depth only covers the
# `HEAD~1` fallback — but without it that fallback resolves to nothing.
#
# Worth stating because the failure was invisible for so long: the migration
# audit read the resulting `git diff` failure as "no migrations changed" and
# exited 0, so it had never actually run on a push build.
# Same mirror/plain split as the integration job's checkout.
- &checkout-mirror-depth2
name: Checkout code (mirror)
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1
with:
fetch-depth: 2
- &checkout-plain-depth2
name: Checkout code
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 2
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
turbo-cache-key: turbo-cache
# Surfaces known CVEs in the dependency tree. Non-blocking until the
# existing advisory backlog is triaged, then flip to a required gate by
# removing continue-on-error.
- name: Security audit
run: bun audit
continue-on-error: true
- name: Validate env flags
run: |
FILE="apps/sim/lib/core/config/env-flags.ts"
ERRORS=""
echo "Checking for hardcoded boolean env flags..."
# Use perl for multiline matching to catch both:
# export const isHosted = true
# export const isHosted =
# true
HARDCODED=$(perl -0777 -ne 'while (/export const (is[A-Za-z]+)\s*=\s*\n?\s*(true|false)\b/g) { print " $1 = $2\n" }' "$FILE")
if [ -n "$HARDCODED" ]; then
ERRORS="${ERRORS}\n❌ Env flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nEnv flags should derive their values from environment variables.\n"
fi
echo "Checking env flag naming conventions..."
# Check that all export const (except functions) start with 'is'
# This finds exports like "export const someFlag" that don't start with "is" or "get"
BAD_NAMES=$(grep -E "^export const [a-z]" "$FILE" | grep -vE "^export const (is|get)" | sed 's/export const \([a-zA-Z]*\).*/ \1/')
if [ -n "$BAD_NAMES" ]; then
ERRORS="${ERRORS}\n❌ Env flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n"
fi
if [ -n "$ERRORS" ]; then
echo ""
echo -e "$ERRORS"
exit 1
fi
echo "✅ All env flags are properly configured"
# One fetch for both base-ref audits, and no `|| true`: a swallowed fetch leaves
# the base ref absent, which neither audit can tell apart from a branch that
# changed nothing. The block-registry check at least degrades to a visible
# `⚠ … skipping` line; the migration audit printed `✓ No new migrations to
# check` and exited 0, clearing the only guard on production DDL.
#
# Depth stays at 1 — without a merge-base the migration audit diffs the two
# tips, which under `--diff-filter=AM` is exactly the migrations new here.
#
# On push the base is `github.event.before`, the tip the branch had before
# this push — not `HEAD~1`, which names only the last commit and would let a
# multi-commit push slip every earlier commit's migrations past the audit.
# It is fetched by SHA at depth 1; the audits diff two tips and need no
# common ancestry. An all-zero `before` means the branch is new and has no
# predecessor to diff, so `HEAD~1` remains the fallback there.
- name: Resolve base ref for diff-based audits
id: audit_base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch --depth=1 origin "${{ github.base_ref }}"
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
elif [ -n "${{ github.event.before }}" ] &&
[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
git fetch --depth=1 origin "${{ github.event.before }}"
echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
else
echo "ref=HEAD~1" >> "$GITHUB_OUTPUT"
fi
- name: Check block registry invariants
run: bun run apps/sim/scripts/check-block-registry.ts "${{ steps.audit_base.outputs.ref }}"
- name: Lint code
run: bun run lint:check
# Workflow syntax, expressions, `needs` references and runner labels. ShellCheck stays off
# here: the existing run blocks carry info-level findings that are their own cleanup.
- name: Lint workflows
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
run: |
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum -c -
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" -color -shellcheck= -pyflakes=
# Every zero-argument `check:*` script, run concurrently. The list is derived in
# scripts/run-audits.ts, which also writes the per-audit timing table to the job
# summary and annotates failures. Audits needing a base ref stay separate below.
- name: Repo audits
run: bun run check:audits
- name: Verify docs manifest is in sync
run: bun run docs-manifest:check
- name: Migration safety (zero-downtime) audit
run: bun run check:migrations "${{ steps.audit_base.outputs.ref }}"
# Every workspace, not just realtime. packages/emcn, packages/utils,
# apps/desktop and apps/docs had no type check in CI at all; apps/sim's
# source was covered only as a side effect of `next build` in the separate
# `build` job. Note this does NOT cover apps/sim's tests — its tsconfig
# excludes *.test.ts(x), and including them today surfaces ~2.2k errors,
# so that is its own cleanup rather than a gate to switch on here.
- name: Type-check all workspaces
run: bunx turbo run type-check
- name: Check schema and migrations are in sync
working-directory: packages/db
run: |
bunx drizzle-kit generate --config=./drizzle.config.ts
if [ -n "$(git status --porcelain ./migrations)" ]; then
echo "❌ Schema and migrations are out of sync!"
echo "Run 'cd packages/db && bunx drizzle-kit generate' and commit the new migrations."
git status --porcelain ./migrations
git diff ./migrations
exit 1
fi
echo "✅ Schema and migrations are in sync"
# The root scripts and every workspace's Vitest suite (`bun run test`), split in two. apps/sim is
# about 98% of the time, so only its files are sharded; shard 1 also runs the root scripts and
# the other workspaces. Each shard keeps its own Turbo cache: pass-through args are part of the
# task hash, so a shard only ever replays its own result.
test:
name: test (${{ matrix.shard }}/2)
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- *checkout-mirror-depth2
- *checkout-plain-depth2
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
turbo-cache-key: turbo-cache-test-${{ matrix.shard }}
# cloud-review-tools.test.ts runs the real helper on the runner, which shells
# out to rg. Blacksmith's image ships it, GitHub's doesn't.
- name: Install ripgrep
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)
- name: Verify shell placeholder compilation in Bash
if: matrix.shard == 1
working-directory: apps/sim
env:
SHELL_PLACEHOLDERS_REPORT_PATH: ${{ runner.temp }}/shell-placeholders.json
run: bun scripts/test-shell-placeholders-e2e.ts
- name: Upload shell placeholder execution report
if: failure() && matrix.shard == 1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: shell-placeholders
path: ${{ runner.temp }}/shell-placeholders.json
if-no-files-found: warn
retention-days: 7
- name: Run tests
env:
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only
TURBO_CACHE_DIR: .turbo
SHARD: ${{ matrix.shard }}
run: |
if [ "$SHARD" = 1 ]; then
bun run test:scripts
bunx turbo run test --filter='!@sim/app'
fi
bunx turbo run test --filter=@sim/app -- --shard="$SHARD/2"
# Next.js production build, in parallel with lint + tests. Sticky disks are
# cloned from the last committed snapshot per job and committed last-writer-
# wins, so concurrent mounts are safe. The bun/node_modules disks are shared
# with the test jobs (the lockfile-hashed key means they only ever share when the
# dependency tree really is identical, so LWW loss is harmless), but the Turbo
# cache gets its own key: with a shared key, only the last committer's new
# entries survive each run, so the test and build Turbo entries would evict
# each other nondeterministically.
#
# Runner is sized for the COLD-cache build, which is what OOM-killed the 8vcpu
# tier (23 kills / 1074 runs at 98% of its 30.4 GB): warm peaks ~12 GB, cold
# peaked 51 GB. NODE_OPTIONS' --max-old-space-size caps only Node's JS heap,
# not the native Turbopack workers that dominate, so it cannot prevent this.
build:
name: build
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
# Build durations crossed 15 minutes as the app grew (10m02 on Jul 29 AM,
# 14m44 after the folders/desktop/library merges, then two straight
# timeouts) — GitHub reports a job timeout as "cancelled". 25 keeps
# headroom without masking a genuine hang.
timeout-minutes: 25
steps:
- *checkout-mirror
- *checkout-plain
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
turbo-cache-key: turbo-cache-build
# No `.next/cache` mount: the Turbopack persistent build cache is off. A
# controlled A/B on one branch (PR #6078) with a byte-identical module graph
# measured compile at 113s with the cache off, 162s cold with it on, and
# 360s warm — the cache made the same build 3.2x slower, and it grew
# 5.1 GB -> 12 GB across two runs of an unchanged tree, so a disk degrades
# the more it is used. Mounting a disk nothing reads would only cost storage.
# Running out of RAM kills the whole VM and surfaces only as "the runner
# has received a shutdown signal" — no mention of memory, ~12 min in. Warn
# with the real numbers so that failure is a one-line diagnosis instead of
# a mystery. Warn, never fail: a warm build peaks ~12 GB and a partial one
# ~28 GB, so a 32 GB runner still completes plenty of builds, and the
# GitHub fallback is the break-glass path — degrading it to a guaranteed
# failure would be worse than the risk this flags.
- name: Check runner memory headroom
run: |
TOTAL_GB=$(awk '/MemTotal/ {printf "%d", $2/1048576}' /proc/meminfo)
echo "Runner memory: ${TOTAL_GB} GB"
if [ "$TOTAL_GB" -lt 40 ]; then
echo "::warning::Runner has ${TOTAL_GB} GB. A cold-cache build peaks ~51 GB, so this run may be OOM-killed (reported only as 'the runner has received a shutdown signal'). Warm/partial builds should still fit."
fi
- name: Build application
env:
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
STRIPE_SECRET_KEY: 'dummy_key_for_ci_only'
STRIPE_WEBHOOK_SECRET: 'dummy_secret_for_ci_only'
RESEND_API_KEY: 'dummy_key_for_ci_only'
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only
TURBO_CACHE_DIR: .turbo
run: bunx turbo run build --filter=@sim/app
# One status for every check above: the single check to require on a branch ruleset, so adding,
# sharding or renaming a job never means editing the ruleset. Skipped is a pass (the desktop live
# suite skips on pull requests that cannot affect it); a failure or a cancellation is not.
# `always()`, not `!cancelled()`: a skipped job satisfies a required check, so a gate that skips
# on a cancelled run would report a cancelled head commit as passing.
ci:
name: ci
needs: [integration, e2e, desktop-changes, desktop-live, lint, test, build]
if: ${{ always() }}
runs-on: *runner-2vcpu
timeout-minutes: 5
steps:
- name: Require every check to pass
env:
RESULTS: ${{ toJSON(needs) }}
run: |
failed="$(jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "skipped") | "\(.key): \(.value.result)"' <<< "$RESULTS")"
if [ -n "$failed" ]; then
echo "::error::Checks did not pass:"
echo "$failed"
exit 1
fi
echo "All checks passed."
+226 -217
View File
@@ -1,4 +1,4 @@
name: CI
name: ci
# Runner provider toggle, read from the CI_PROVIDER repo variable:
#
@@ -18,13 +18,11 @@ name: CI
on:
push:
branches: [main, staging, dev]
# No path filter: `checks / ci` is the required status check, and a pull request whose run
# never starts never reports it, so it could not merge. Docs-only pull requests are rare enough
# that running CI on them costs nothing measurable.
pull_request:
branches: [main, staging, dev]
# Docs content and markdown don't affect the app build or images; push
# runs stay unfiltered because they feed the deploy pipeline.
paths-ignore:
- 'apps/docs/content/**'
- '**/*.md'
concurrency:
group: ci-${{ github.ref }}
@@ -34,18 +32,17 @@ permissions:
contents: read
jobs:
test-build:
name: Test and Build
checks:
name: checks
if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request'
uses: ./.github/workflows/test-build.yml
secrets: inherit
uses: ./.github/workflows/checks.yml
# Detect if this is a version release commit (e.g., "v0.5.24: ...")
# Smallest runner on purpose: a few seconds of pure shell over the commit
# message, no checkout and no install.
detect-version:
name: Detect Version
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
version:
name: version
runs-on: &runner-2vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
outputs:
@@ -74,9 +71,9 @@ jobs:
# the Electron app or the bridge packages trigger a per-env prerelease build
# (dev → dev stream, staging → staging) that the env's update feed
# (/api/desktop/update) starts offering automatically.
detect-desktop-changes:
name: Detect Desktop Changes
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
desktop-changes:
name: desktop-changes
runs-on: *runner-2vcpu
timeout-minutes: 5
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging')
outputs:
@@ -116,36 +113,41 @@ jobs:
# in place before the new app version deploys (replaces the removed ECS
# migration sidecar)
migrate:
name: Migrate DB
needs: [test-build]
name: migrate
needs: [checks]
# Explicit need results instead of the implicit success(): a skipped job
# anywhere in the transitive needs chain silently fails implicit success()
# and cascade-skips the deploy chain (migrate -> promote-images ->
# and cascade-skips the deploy chain (migrate -> promote ->
# CodeDeploy) — this bit us on 2026-07-23. State requirements explicitly.
if: >-
!cancelled() &&
needs.test-build.result == 'success' &&
needs.checks.result == 'success' &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
uses: ./.github/workflows/migrations.yml
uses: ./.github/workflows/migrate.yml
with:
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
secrets: inherit
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}
STAGING_MIGRATION_DATABASE_URL: ${{ secrets.STAGING_MIGRATION_DATABASE_URL }}
# Same ordering for dev (schema push before the dev image lands in ECR)
migrate-dev:
name: Migrate Dev DB
name: migrate-dev
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
uses: ./.github/workflows/migrations.yml
uses: ./.github/workflows/migrate.yml
with:
environment: dev
secrets: inherit
secrets:
DEV_DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }}
# Dev: build immutable images alongside the schema push and Trigger upload.
build-dev:
name: Build Dev ECR
image-dev:
name: image-dev (${{ matrix.id }})
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }}
runs-on: &runner-matrix ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }}
timeout-minutes: 30
permissions:
contents: read
@@ -162,29 +164,33 @@ jobs:
# deploy — nothing ships until the image is pushed — and its two
# dominant steps both scale with cores (`bun install` ~300-400s, `next
# build` ~260s). The same `next build` runs on 16 vCPU in the separate
# Build App verification job, which does not gate anything; this one
# checks `build` job, which does not gate anything; this one
# was doing comparable work on half the cores.
#
# cache_mb is the layer cache the post-job prune retains, and it is the
# only reason the sticky disks stay bounded — see docker-build's
# only reason the sticky disks stay bounded — see the image action's
# action.yml. Rows that omit it take the small-image default there. The
# app image overrides because it carries ~34 layers plus apt and bun
# cache mounts for the whole monorepo; 100 GB is several builds' worth
# of headroom over that working set.
- dockerfile: ./docker/app.Dockerfile
- id: app
dockerfile: ./docker/app.Dockerfile
cache_mb: '102400'
ecr_repo_secret: ECR_APP
gh_runner: linux-x64-8-core
bs_runner: blacksmith-16vcpu-ubuntu-2404
- dockerfile: ./docker/db.Dockerfile
- id: db
dockerfile: ./docker/db.Dockerfile
ecr_repo_secret: ECR_MIGRATIONS
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
- dockerfile: ./docker/realtime.Dockerfile
- id: realtime
dockerfile: ./docker/realtime.Dockerfile
ecr_repo_secret: ECR_REALTIME
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
- dockerfile: ./docker/pii.Dockerfile
- id: pii
dockerfile: ./docker/pii.Dockerfile
ecr_repo_secret: ECR_PII
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
@@ -215,7 +221,7 @@ jobs:
ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }}
- name: Build and push
uses: ./.github/actions/docker-build
uses: ./.github/actions/image
with:
provider: ${{ vars.CI_PROVIDER }}
file: ${{ matrix.dockerfile }}
@@ -226,13 +232,13 @@ jobs:
max-cache-size-mb: ${{ matrix.cache_mb }}
# Staging/production coordinate task releases with app traffic cutover.
# Dev tasks deploy independently in deploy-trigger-dev.yml.
prepare-trigger:
name: Prepare Trigger.dev
# Dev tasks deploy independently in trigger-dev.yml.
trigger-upload:
name: trigger-upload
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
outputs:
version: ${{ steps.deploy.outputs.deploymentVersion }}
@@ -242,24 +248,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Setup workspace
uses: ./.github/actions/setup
with:
bun-version: 1.4.2
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
provider: ${{ vars.CI_PROVIDER }}
node-version: ''
- name: Select Trigger environment
id: target
@@ -305,13 +298,13 @@ jobs:
# Runs in parallel with tests — only immutable sha tags are pushed here, and
# the CodePipeline EventBridge triggers filter on exactly the
# latest/staging/dev ECR tags, so nothing deploys and no mutable tag moves
# until promote-images / create-ghcr-manifests retag after the gate.
build-amd64:
name: Build AMD64
# until promote / ghcr retag after the gate.
image-amd64:
name: image-amd64 (${{ matrix.id }})
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }}
runs-on: *runner-matrix
timeout-minutes: 30
permissions:
contents: read
@@ -321,30 +314,35 @@ jobs:
fail-fast: false
matrix:
include:
- dockerfile: ./docker/app.Dockerfile
- id: app
dockerfile: ./docker/app.Dockerfile
cache_mb: '102400'
ghcr_image: ghcr.io/simstudioai/simstudio
ecr_repo_secret: ECR_APP
gh_runner: linux-x64-8-core
bs_runner: blacksmith-16vcpu-ubuntu-2404
- dockerfile: ./docker/db.Dockerfile
- id: db
dockerfile: ./docker/db.Dockerfile
ghcr_image: ghcr.io/simstudioai/migrations
ecr_repo_secret: ECR_MIGRATIONS
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
- dockerfile: ./docker/realtime.Dockerfile
- id: realtime
dockerfile: ./docker/realtime.Dockerfile
ghcr_image: ghcr.io/simstudioai/realtime
ecr_repo_secret: ECR_REALTIME
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
- dockerfile: ./docker/pii.Dockerfile
- id: pii
dockerfile: ./docker/pii.Dockerfile
ghcr_image: ghcr.io/simstudioai/pii
ecr_repo_secret: ECR_PII
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
# No ECR repo is provisioned for cron, so it publishes to GHCR only.
# The tag step below omits the ECR tag when the repo name is empty.
- dockerfile: ./docker/cron.Dockerfile
- id: cron
dockerfile: ./docker/cron.Dockerfile
ghcr_image: ghcr.io/simstudioai/cron
gh_runner: ubuntu-latest
bs_runner: blacksmith-2vcpu-ubuntu-2404
@@ -383,8 +381,8 @@ jobs:
ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }}
# Only sha tags here — the ECR deploy tags (latest/staging) are applied
# by promote-images and the GHCR latest-amd64/version tags by
# create-ghcr-manifests, both after tests and migrations pass.
# by promote and the GHCR latest-amd64/version tags by
# ghcr, both after tests and migrations pass.
- name: Generate tags
id: meta
run: |
@@ -419,7 +417,7 @@ jobs:
- name: Build and push images
if: steps.meta.outputs.skip != 'true'
uses: ./.github/actions/docker-build
uses: ./.github/actions/image
with:
provider: ${{ vars.CI_PROVIDER }}
file: ${{ matrix.dockerfile }}
@@ -434,33 +432,33 @@ jobs:
# the image builds themselves run in parallel with the tests. A single job
# (not a matrix) so all four sha manifests are verified before any tag
# moves; a missing image can't produce a partial mixed-version deploy.
promote-images:
name: Promote Images
needs: [migrate, build-amd64, prepare-trigger, migrate-dev, build-dev]
promote:
name: promote
needs: [migrate, image-amd64, trigger-upload, migrate-dev, image-dev]
# Explicit results: see migrate's comment.
if: >-
!cancelled() && github.event_name == 'push' &&
(
((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') &&
needs.prepare-trigger.result == 'success' &&
needs.trigger-upload.result == 'success' &&
needs.migrate.result == 'success' &&
needs.build-amd64.result == 'success') ||
needs.image-amd64.result == 'success') ||
(github.ref == 'refs/heads/dev' &&
needs.migrate-dev.result == 'success' &&
needs.build-dev.result == 'success')
needs.image-dev.result == 'success')
)
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: *runner-2vcpu
timeout-minutes: 10
permissions:
contents: read
id-token: write
outputs:
# Whether the deploy tag was actually moved (false on a stale-run guard
# skip). promote-trigger keys off this so tasks are never promoted when
# skip). trigger-promote keys off this so tasks are never promoted when
# the app itself wasn't.
promoted: ${{ steps.guard.outputs.fresh }}
# Epoch when the deploy tag was retagged (this push's ECS pipeline trigger).
# promote-trigger passes it to the poll script so a stale pipeline execution
# trigger-promote passes it to the poll script so a stale pipeline execution
# reusing the same image digest can't satisfy the cutover gate.
retag_epoch: ${{ steps.promote.outputs.retag_epoch }}
# Unchanged tags verify the latest execution's cutover without an epoch bound.
@@ -552,21 +550,21 @@ jobs:
# traffic cutover (CodeDeploy AllowTraffic on every target). The image retag
# triggers the ECS pipeline; this job correlates it via the digest + retag epoch
# (rejecting a stale execution reusing the digest) and promotes at cutover.
# Skipped when promote-images skipped the tag move (stale run) — tasks then
# Skipped when promote skipped the tag move (stale run) — tasks then
# correctly stay on the old version. If the app deploy fails or never cuts over,
# promote never fires and this job fails visibly.
promote-trigger:
name: Promote Trigger.dev
needs: [promote-images, prepare-trigger]
trigger-promote:
name: trigger-promote
needs: [promote, trigger-upload]
# Explicit results also suppress skip propagation from optional ancestors.
if: >-
!cancelled() &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') &&
needs.promote-images.result == 'success' &&
needs.prepare-trigger.result == 'success' &&
needs.promote-images.outputs.promoted == 'true'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
needs.promote.result == 'success' &&
needs.trigger-upload.result == 'success' &&
needs.promote.outputs.promoted == 'true'
runs-on: *runner-4vcpu
# Leave setup/promotion headroom above the 70-minute cutover poll.
timeout-minutes: 90
permissions:
@@ -576,24 +574,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Setup workspace
uses: ./.github/actions/setup
with:
bun-version: 1.4.2
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
provider: ${{ vars.CI_PROVIDER }}
node-version: ''
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
@@ -607,10 +592,10 @@ jobs:
- name: Wait for ECS traffic cutover
env:
OVERALL_TIMEOUT: 4200
APP_IMAGE_CHANGED: ${{ needs.promote-images.outputs.app_image_changed }}
DIGEST: ${{ needs.promote-images.outputs.app_image_digest }}
APP_IMAGE_CHANGED: ${{ needs.promote.outputs.app_image_changed }}
DIGEST: ${{ needs.promote.outputs.app_image_digest }}
PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || github.ref == 'refs/heads/dev' && 'dev' || 'staging' }}-us-east-1-app-deployment
RETAG_EPOCH: ${{ needs.promote-images.outputs.retag_epoch }}
RETAG_EPOCH: ${{ needs.promote.outputs.retag_epoch }}
run: |
set -eo pipefail
case "$APP_IMAGE_CHANGED" in
@@ -625,9 +610,9 @@ jobs:
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
TRIGGER_ENV: ${{ needs.prepare-trigger.outputs.environment }}
TRIGGER_BRANCH: ${{ needs.prepare-trigger.outputs.preview_branch }}
VERSION: ${{ needs.prepare-trigger.outputs.version }}
TRIGGER_ENV: ${{ needs.trigger-upload.outputs.environment }}
TRIGGER_BRANCH: ${{ needs.trigger-upload.outputs.preview_branch }}
VERSION: ${{ needs.trigger-upload.outputs.version }}
run: |
set -eo pipefail
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then
@@ -635,7 +620,7 @@ jobs:
exit 1
fi
if [ -z "$VERSION" ]; then
echo "ERROR: no deployed version passed from prepare-trigger" >&2
echo "ERROR: no deployed version passed from trigger-upload" >&2
exit 1
fi
echo "Promoting Trigger.dev version $VERSION ($TRIGGER_ENV)"
@@ -647,11 +632,11 @@ jobs:
# Build ARM64 images for GHCR (main branch only, runs in parallel with
# tests). Pushes only the immutable sha tag — latest-arm64/version-arm64
# are applied by create-ghcr-manifests after the gate, so a failing run
# are applied by ghcr after the gate, so a failing run
# never moves a documented tag.
build-ghcr-arm64:
name: Build ARM64 (GHCR Only)
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }}
image-arm64:
name: image-arm64 (${{ matrix.id }})
runs-on: *runner-matrix
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
@@ -665,24 +650,29 @@ jobs:
# and this job only runs on push to main — an unprovisioned label would
# hang a release in `queued` rather than fail a PR.
include:
- dockerfile: ./docker/app.Dockerfile
- id: app
dockerfile: ./docker/app.Dockerfile
cache_mb: '102400'
image: ghcr.io/simstudioai/simstudio
gh_runner: linux-arm64-8-core
bs_runner: blacksmith-16vcpu-ubuntu-2404-arm
- dockerfile: ./docker/db.Dockerfile
- id: db
dockerfile: ./docker/db.Dockerfile
image: ghcr.io/simstudioai/migrations
gh_runner: ubuntu-24.04-arm
bs_runner: blacksmith-4vcpu-ubuntu-2404-arm
- dockerfile: ./docker/realtime.Dockerfile
- id: realtime
dockerfile: ./docker/realtime.Dockerfile
image: ghcr.io/simstudioai/realtime
gh_runner: ubuntu-24.04-arm
bs_runner: blacksmith-4vcpu-ubuntu-2404-arm
- dockerfile: ./docker/pii.Dockerfile
- id: pii
dockerfile: ./docker/pii.Dockerfile
image: ghcr.io/simstudioai/pii
gh_runner: ubuntu-24.04-arm
bs_runner: blacksmith-4vcpu-ubuntu-2404-arm
- dockerfile: ./docker/cron.Dockerfile
- id: cron
dockerfile: ./docker/cron.Dockerfile
image: ghcr.io/simstudioai/cron
gh_runner: ubuntu-24.04-arm
bs_runner: blacksmith-4vcpu-ubuntu-2404-arm
@@ -699,7 +689,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ARM64 to GHCR
uses: ./.github/actions/docker-build
uses: ./.github/actions/image
with:
provider: ${{ vars.CI_PROVIDER }}
file: ${{ matrix.dockerfile }}
@@ -709,18 +699,18 @@ jobs:
# Publish all mutable GHCR tags (latest, latest-amd64/arm64, version tags)
# and the multi-arch manifests from the immutable sha tags — only on main,
# after the deploy gate (promote-images) and the ARM64 build both pass.
create-ghcr-manifests:
name: Create GHCR Manifests
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
# after the deploy gate (promote) and the ARM64 build both pass.
ghcr:
name: ghcr (${{ matrix.id }})
runs-on: *runner-2vcpu
timeout-minutes: 10
needs: [promote-images, build-ghcr-arm64, detect-version]
needs: [promote, image-arm64, version]
# Explicit results: see migrate's comment.
if: >-
!cancelled() &&
needs.promote-images.result == 'success' &&
needs.build-ghcr-arm64.result == 'success' &&
needs.detect-version.result == 'success' &&
needs.promote.result == 'success' &&
needs.image-arm64.result == 'success' &&
needs.version.result == 'success' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -733,11 +723,16 @@ jobs:
strategy:
matrix:
include:
- image: ghcr.io/simstudioai/simstudio
- image: ghcr.io/simstudioai/migrations
- image: ghcr.io/simstudioai/realtime
- image: ghcr.io/simstudioai/pii
- image: ghcr.io/simstudioai/cron
- id: simstudio
image: ghcr.io/simstudioai/simstudio
- id: migrations
image: ghcr.io/simstudioai/migrations
- id: realtime
image: ghcr.io/simstudioai/realtime
- id: pii
image: ghcr.io/simstudioai/pii
- id: cron
image: ghcr.io/simstudioai/cron
steps:
- name: Login to GHCR
@@ -747,7 +742,7 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Same monotonic guard as promote-images, applied to the public latest
# Same monotonic guard as promote, applied to the public latest
# tags only — immutable sha and version tags are always published.
- name: Guard against stale latest tags
id: guard
@@ -771,8 +766,8 @@ jobs:
docker buildx imagetools create -t "${IMAGE}:${SHA}" \
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then
VERSION="${{ needs.detect-version.outputs.version }}"
if [ "${{ needs.version.outputs.is_release }}" = "true" ]; then
VERSION="${{ needs.version.outputs.version }}"
echo "📦 Publishing version tags: ${VERSION}"
docker buildx imagetools create -t "${IMAGE}:${VERSION}-amd64" "${IMAGE}:${SHA}-amd64"
docker buildx imagetools create -t "${IMAGE}:${VERSION}-arm64" "${IMAGE}:${SHA}-arm64"
@@ -789,10 +784,10 @@ jobs:
# Sign the published images and attach SLSA provenance and an SBOM to each.
#
# This runs after create-ghcr-manifests rather than inside the build because
# This runs after ghcr rather than inside the build because
# buildx's own provenance/sbom attestations stay off (see the note in
# .github/actions/docker-build): the extra manifests they add to an index
# break the `imagetools create` retagging that promote-images depends on.
# .github/actions/image): the extra manifests they add to an index
# break the `imagetools create` retagging that promote depends on.
# Attaching attestations here instead leaves the index itself untouched — they
# are stored as separate referrer manifests that point at it.
#
@@ -804,18 +799,18 @@ jobs:
# `:<sha>-amd64` manifest it wraps — attesting the manifest leaves the tag
# people actually pin unverifiable. Which tags exist also varies per run:
# version tags only on a release, and the latest tags only when the monotonic
# guard in create-ghcr-manifests passed. Resolving tag -> digest here and
# guard in ghcr passed. Resolving tag -> digest here and
# de-duplicating is what keeps the two in step without hardcoding that logic
# twice.
attest-subjects:
name: Resolve Attestation Subjects
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
name: attest-subjects
runs-on: *runner-2vcpu
timeout-minutes: 10
needs: [create-ghcr-manifests, detect-version]
needs: [ghcr, version]
if: >-
!cancelled() &&
needs.create-ghcr-manifests.result == 'success' &&
needs.detect-version.result == 'success' &&
needs.ghcr.result == 'success' &&
needs.version.result == 'success' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -834,10 +829,10 @@ jobs:
- name: Resolve published tags to digests
id: resolve
env:
IS_RELEASE: ${{ needs.detect-version.outputs.is_release }}
VERSION: ${{ needs.detect-version.outputs.version }}
IS_RELEASE: ${{ needs.version.outputs.is_release }}
VERSION: ${{ needs.version.outputs.version }}
SHA: ${{ github.sha }}
LATEST_FRESH: ${{ needs.create-ghcr-manifests.outputs.latest_fresh }}
LATEST_FRESH: ${{ needs.ghcr.outputs.latest_fresh }}
run: |
set -euo pipefail
@@ -911,7 +906,7 @@ jobs:
# A moving alias is only taken when it resolves to the same index
# digest this run published — content identity, which is what a
# digest can prove. create-ghcr-manifests holds the latest tags back when
# digest can prove. ghcr holds the latest tags back when
# its monotonic guard sees a newer commit, and they then still point
# at an older build — attesting those would put this run's signature
# and provenance on an image it did not produce. The per-arch
@@ -983,9 +978,9 @@ jobs:
# One leg per distinct published digest. Attesting each subject separately is
# also what makes the SBOMs truthful: the amd64 and arm64 images contain
# different packages, and one SBOM attached to the index cannot describe both.
attest-images:
name: Attest Images
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
attest:
name: attest
runs-on: *runner-2vcpu
timeout-minutes: 15
needs: [attest-subjects]
if: >-
@@ -1064,9 +1059,9 @@ jobs:
# Check if docs changed
# Smallest runner on purpose: a depth-2 checkout plus a path filter, no
# install and no build.
check-docs-changes:
name: Check Docs Changes
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
docs-changes:
name: docs-changes
runs-on: *runner-2vcpu
timeout-minutes: 5
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
@@ -1085,41 +1080,43 @@ jobs:
- 'apps/sim/lib/chunkers/**'
# Process docs embeddings (only when docs change, after images are promoted)
process-docs:
name: Process Docs
needs: [promote-images, check-docs-changes]
docs:
name: docs
needs: [promote, docs-changes]
# Explicit results: see migrate's comment.
if: >-
!cancelled() &&
needs.promote-images.result == 'success' &&
needs.check-docs-changes.result == 'success' &&
needs.check-docs-changes.outputs.docs_changed == 'true'
uses: ./.github/workflows/docs-embeddings.yml
secrets: inherit
needs.promote.result == 'success' &&
needs.docs-changes.result == 'success' &&
needs.docs-changes.outputs.docs_changed == 'true'
uses: ./.github/workflows/docs.yml
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Create GitHub Release (only for version commits on main, after all builds complete)
create-release:
name: Create GitHub Release
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
release:
name: release
runs-on: *runner-4vcpu
timeout-minutes: 10
needs: [create-ghcr-manifests, attest-subjects, attest-images, detect-version]
# Explicit results: see migrate's comment. attest-images is a gate, not just
needs: [ghcr, attest-subjects, attest, version]
# Explicit results: see migrate's comment. attest is a gate, not just
# an ordering edge — a release must not advertise images whose signature or
# attestation failed to publish. The count check is belt and braces: today
# attest-subjects already fails on an empty subject set, so this only bites
# if that guard is ever removed.
#
# Note this gates the GitHub release, not the production deploy: CodePipeline
# fires from the ECR tags moved by promote-images, upstream of this job, and
# fires from the ECR tags moved by promote, upstream of this job, and
# only the GHCR mirrors are attested.
if: >-
!cancelled() &&
needs.create-ghcr-manifests.result == 'success' &&
needs.ghcr.result == 'success' &&
needs.attest-subjects.result == 'success' &&
needs.attest-subjects.outputs.count != '0' &&
needs.attest-images.result == 'success' &&
needs.detect-version.result == 'success' &&
needs.detect-version.outputs.is_release == 'true'
needs.attest.result == 'success' &&
needs.version.result == 'success' &&
needs.version.outputs.is_release == 'true'
permissions:
contents: write
steps:
@@ -1128,18 +1125,16 @@ jobs:
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Setup workspace
uses: ./.github/actions/setup
with:
bun-version: 1.4.2
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
provider: ${{ vars.CI_PROVIDER }}
node-version: ''
- name: Create release
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }}
run: bun run scripts/create-single-release.ts ${{ needs.version.outputs.version }}
# Desktop release: builds, signs, notarizes, and attaches the macOS app to
# the GitHub release created above. Gated on the Apple signing secrets so a
@@ -1147,14 +1142,14 @@ jobs:
# Developer account is provisioned — the moment the six secrets exist, the
# next vX.Y.Z release ships desktop artifacts with no further changes.
# Job-level `if:` cannot read the secrets context, hence the probe job.
check-desktop-signing:
name: Check Desktop Signing Secrets
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
desktop-signing:
name: desktop-signing
runs-on: *runner-2vcpu
timeout-minutes: 2
needs: [detect-version, detect-desktop-changes]
# !cancelled(): detect-desktop-changes is skipped on main (and
# detect-version tags only on main); either path may need the probe.
if: ${{ !cancelled() && (needs.detect-version.outputs.is_release == 'true' || needs.detect-desktop-changes.outputs.changed == 'true') }}
needs: [version, desktop-changes]
# !cancelled(): desktop-changes is skipped on main (and
# version tags only on main); either path may need the probe.
if: ${{ !cancelled() && (needs.version.outputs.is_release == 'true' || needs.desktop-changes.outputs.changed == 'true') }}
outputs:
configured: ${{ steps.check.outputs.configured }}
steps:
@@ -1169,24 +1164,31 @@ jobs:
fi
desktop-release:
name: Desktop Release
needs: [create-release, check-desktop-signing, detect-version]
# Suppress the implicit success() check: check-desktop-signing has an
name: desktop-release
needs: [release, desktop-signing, version]
# Suppress the implicit success() check: desktop-signing has an
# intentionally skipped transitive dependency on main, which would
# otherwise cascade-skip this job even when every direct need succeeded.
if: >-
!cancelled() &&
needs.create-release.result == 'success' &&
needs.check-desktop-signing.result == 'success' &&
needs.detect-version.result == 'success' &&
needs.check-desktop-signing.outputs.configured == 'true'
needs.release.result == 'success' &&
needs.desktop-signing.result == 'success' &&
needs.version.result == 'success' &&
needs.desktop-signing.outputs.configured == 'true'
permissions:
contents: write
uses: ./.github/workflows/desktop-release.yml
with:
version: ${{ needs.detect-version.outputs.version }}
version: ${{ needs.version.outputs.version }}
publish: true
secrets: inherit
secrets:
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Per-env desktop prereleases: a dev/staging push that touches shell code
# publishes an environment-tagged GitHub prerelease to the public,
@@ -1194,19 +1196,19 @@ jobs:
# builds out of this source repository prevents its followers from receiving
# every internal shell release. Each environment's /api/desktop/update feed
# still offers only its own signed stream.
create-desktop-prerelease:
name: Create Desktop Prerelease
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
prerelease-create:
name: prerelease-create
runs-on: *runner-2vcpu
timeout-minutes: 5
needs: [detect-desktop-changes, check-desktop-signing]
needs: [desktop-changes, desktop-signing]
# Requires the signing probe to have actually succeeded (not just "not
# cancelled") so a probe failure can't produce a release with no build.
if: >-
${{
!cancelled() &&
needs.detect-desktop-changes.outputs.changed == 'true' &&
needs.check-desktop-signing.result == 'success' &&
needs.check-desktop-signing.outputs.configured == 'true'
needs.desktop-changes.outputs.changed == 'true' &&
needs.desktop-signing.result == 'success' &&
needs.desktop-signing.outputs.configured == 'true'
}}
permissions:
contents: read
@@ -1265,9 +1267,9 @@ jobs:
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "✅ Created draft prerelease $TAG"
desktop-prerelease:
name: Desktop Prerelease Build
needs: [create-desktop-prerelease, check-desktop-signing]
prerelease-build:
name: prerelease-build
needs: [prerelease-create, desktop-signing]
# The reusable workflow declares contents: write for its stable-release
# path. GitHub cannot elevate a caller's token, even though this prerelease
# path uses the dedicated cross-repository token for its actual upload.
@@ -1275,25 +1277,32 @@ jobs:
contents: write
uses: ./.github/workflows/desktop-release.yml
with:
version: ${{ needs.create-desktop-prerelease.outputs.version }}
version: ${{ needs.prerelease-create.outputs.version }}
publish: true
sign: true
secrets: inherit
secrets:
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The draft only becomes visible to the update feed once its artifacts are
# attached — this is what makes a dev/staging push atomic from the shell's
# point of view.
publish-desktop-prerelease:
name: Publish Desktop Prerelease
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
prerelease-publish:
name: prerelease-publish
runs-on: *runner-2vcpu
timeout-minutes: 5
needs: [create-desktop-prerelease, desktop-prerelease]
needs: [prerelease-create, prerelease-build]
permissions:
contents: read
env:
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
GH_REPO: simstudioai/sim-desktop-releases
TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
TAG: ${{ needs.prerelease-create.outputs.version }}
steps:
- name: Publish the draft release
run: |
@@ -1307,17 +1316,17 @@ jobs:
# and delete the rest (with their tags, so dev force-resets don't strand
# commits behind stale tags). Leftover drafts (failed or superseded builds)
# are always garbage by this point — the current run's release is published.
prune-desktop-prereleases:
name: Prune Desktop Prereleases
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
prerelease-prune:
name: prerelease-prune
runs-on: *runner-2vcpu
timeout-minutes: 5
needs: [create-desktop-prerelease, publish-desktop-prerelease]
needs: [prerelease-create, prerelease-publish]
permissions:
contents: read
env:
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
GH_REPO: simstudioai/sim-desktop-releases
CURRENT_TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
CURRENT_TAG: ${{ needs.prerelease-create.outputs.version }}
steps:
- name: Delete stale prereleases
run: |
+3 -3
View File
@@ -1,4 +1,4 @@
name: CodeQL
name: codeql
# Advanced setup, replacing the repo-settings "default setup".
#
@@ -77,7 +77,7 @@ permissions:
jobs:
analyze:
name: Analyze ${{ matrix.language }}
name: analyze (${{ matrix.language }})
# Sized per language, not per workflow. The two analyses are nothing alike:
# javascript-typescript peaks at 19.5 GB (p95 over 3090 runs), so it needs
# the 8 vCPU tier's 30.4 GB and would OOM on the 4 vCPU tier's 15.2 GB; the
@@ -108,7 +108,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -1,4 +1,4 @@
name: companion-pr-check
name: companion
# Soft, NON-BLOCKING warning: when a PR targeting staging/main declares a
# cross-repo "Companion:" PR, surface whether that companion is merged yet, so
@@ -40,6 +40,7 @@ permissions:
jobs:
companion:
name: companion
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
steps:
+66 -7
View File
@@ -1,4 +1,4 @@
name: Desktop E2E
name: desktop-e2e
# Smoke coverage of the real Electron shell on desktop changes, plus a weekly
# advisory canary against electron@latest so Chromium-cadence breakage surfaces
@@ -57,19 +57,34 @@ concurrency:
cancel-in-progress: true
jobs:
e2e-pinned:
name: E2E (pinned)
e2e:
name: e2e
if: github.event_name != 'schedule'
runs-on: macos-26
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
# Electron, electron-builder and Playwright download their binaries on every cold install. All
# three caches are keyed by version inside, so an older entry restored by prefix is harmless.
- name: Cache Electron and Playwright downloads
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/Library/Caches/ms-playwright
key: desktop-downloads-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: desktop-downloads-${{ runner.os }}-
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -81,9 +96,16 @@ jobs:
working-directory: apps/desktop
run: bunx playwright install chromium
# The terminal-cancel suite drives a real tmux server; without tmux its tmux scenarios skip.
- name: Install tmux
run: brew install tmux
- name: Run Playwright _electron smoke suite
working-directory: apps/desktop
run: bunx playwright test
env:
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
- name: Upload test results
if: failure()
@@ -93,20 +115,35 @@ jobs:
path: apps/desktop/test-results
retention-days: 7
e2e-latest-canary:
name: E2E (electron@latest canary)
e2e-canary:
name: e2e-canary
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: macos-26
timeout-minutes: 30
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
# Electron, electron-builder and Playwright download their binaries on every cold install. All
# three caches are keyed by version inside, so an older entry restored by prefix is harmless.
- name: Cache Electron and Playwright downloads
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/Library/Caches/ms-playwright
key: desktop-downloads-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: desktop-downloads-${{ runner.os }}-
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -122,9 +159,16 @@ jobs:
working-directory: apps/desktop
run: bunx playwright install chromium
# The terminal-cancel suite drives a real tmux server; without tmux its tmux scenarios skip.
- name: Install tmux
run: brew install tmux
- name: Run Playwright _electron smoke suite
working-directory: apps/desktop
run: bunx playwright test
env:
BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json
TERMINAL_CANCEL_REPORT_PATH: test-results/terminal-cancel-report.json
- name: Upload test results
if: failure()
@@ -135,12 +179,15 @@ jobs:
retention-days: 7
package-smoke:
name: Unsigned package smoke
name: package-smoke
if: github.event_name != 'schedule'
runs-on: macos-26
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -148,10 +195,22 @@ jobs:
bun-version: 1.4.2
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
# Electron, electron-builder and Playwright download their binaries on every cold install. All
# three caches are keyed by version inside, so an older entry restored by prefix is harmless.
- name: Cache Electron and Playwright downloads
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/Library/Caches/ms-playwright
key: desktop-downloads-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: desktop-downloads-${{ runner.os }}-
- name: Install dependencies
run: bun install --frozen-lockfile
+20 -5
View File
@@ -1,4 +1,4 @@
name: Desktop Release (macOS)
name: desktop-release
# Builds, signs, notarizes, and uploads the desktop app to an existing GitHub
# release. Stable releases live in this source repository; dev and staging
@@ -24,6 +24,21 @@ on:
required: false
type: boolean
default: true
secrets:
DESKTOP_RELEASE_TOKEN:
required: false
CSC_LINK:
required: false
CSC_KEY_PASSWORD:
required: false
APPLE_API_KEY_P8:
required: false
APPLE_API_KEY_ID:
required: false
APPLE_API_ISSUER:
required: false
APPLE_TEAM_ID:
required: false
workflow_dispatch:
inputs:
version:
@@ -45,8 +60,8 @@ permissions:
contents: write
jobs:
build-sign-notarize:
name: Build, Sign, Notarize
build:
name: build
runs-on: macos-26
timeout-minutes: 60
steps:
@@ -131,12 +146,12 @@ jobs:
bun-version: 1.4.2
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Cache Electron binaries
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/Library/Caches/electron
@@ -1,7 +1,7 @@
name: Sticky Disk GC
name: disk-gc
# node_modules sticky disks are keyed on hashFiles('bun.lock') by design — see the
# "Mount node_modules" comment in test-build.yml. A sticky disk is a mutable volume
# cache-key comment in .github/actions/setup/action.yml. A sticky disk is a mutable volume
# and `bun install --frozen-lockfile` adds what the lockfile needs without pruning
# what it dropped, so branches on different lockfiles must not share one. That
# design is correct and is preserved here.
@@ -42,7 +42,7 @@ concurrency:
jobs:
gc:
name: Reclaim idle node_modules sticky disks
name: gc
# GitHub-hosted on purpose, not a Blacksmith runner: the CLI is a pure API
# client, and collection has to keep working during a Blacksmith outage or a
# CI_PROVIDER break-glass switch — exactly when disks sit idle and still bill.
@@ -120,7 +120,7 @@ jobs:
# because the key interpolates ${{ github.event_name }} and a
# workflow_dispatch disk already exists that an enumerated list would have
# skipped forever. `-fork` is a separate optional segment rather than part
# of that class: test-build.yml appends it after the event name, so a fork
# of that class: the setup action appends it after the event name, so a fork
# key reads `pull_request-fork` and a character class cannot span the
# hyphen. Without it, fork disks would never be collected.
#
-51
View File
@@ -1,51 +0,0 @@
name: Process Docs Embeddings
on:
workflow_call:
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs:
process-docs-embeddings:
name: Process Documentation Embeddings
# Network-bound on the embeddings API: ~9% CPU and ~3% peak memory on 8 vCPU.
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Process docs embeddings
working-directory: ./apps/sim
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: bun run scripts/process-docs.ts --clear
+40
View File
@@ -0,0 +1,40 @@
name: docs
on:
workflow_call:
secrets:
DATABASE_URL:
required: false
OPENAI_API_KEY:
required: false
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs:
embeddings:
name: embeddings
# Network-bound on the embeddings API: ~9% CPU and ~3% peak memory on 8 vCPU.
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
node-version: '22'
- name: Process docs embeddings
working-directory: ./apps/sim
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: bun run scripts/process-docs.ts --clear
+15 -15
View File
@@ -1,4 +1,4 @@
name: Helm Chart
name: helm
on:
push:
@@ -33,11 +33,11 @@ permissions:
jobs:
chart:
name: Lint, test, and validate chart
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
name: chart
runs-on: &runner-2vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# ct diffs the chart against the PR base to decide whether the version
# was bumped, so a shallow clone would leave it nothing to compare.
@@ -154,12 +154,12 @@ jobs:
done
install:
name: Install on kind and run helm test
name: install
needs: chart
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -198,9 +198,9 @@ jobs:
# and a release becoming public between two independent API calls would be
# enough to make the OCI artifact and the HTTP repo install different Sims.
release-version:
name: Resolve the app release
name: release-version
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'simstudioai/sim'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: *runner-2vcpu
timeout-minutes: 5
permissions:
contents: read # Read the release list.
@@ -211,7 +211,7 @@ jobs:
# it -- so the subject is the only source available, and the latest release
# is the source of truth for every other push.
#
# The subject pattern MUST stay identical to detect-version in ci.yml, which
# The subject pattern MUST stay identical to the `version` job in ci.yml, which
# is what actually creates the tag and builds the images. If this one
# matched a subject that one rejects, the chart would publish naming a
# release that was never cut.
@@ -246,10 +246,10 @@ jobs:
# rendered clean under kubeconform, and actually installed on a kind cluster.
# A separate workflow would race those instead of waiting for them.
publish:
name: Publish chart to GHCR
name: publish
needs: [chart, install, release-version]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'simstudioai/sim'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: *runner-2vcpu
timeout-minutes: 15
permissions:
contents: read # Read the chart source.
@@ -257,7 +257,7 @@ jobs:
id-token: write # Sigstore signs against the runner's OIDC identity; no key material is stored.
attestations: write # Let actions/attest-build-provenance record the SLSA provenance.
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -439,15 +439,15 @@ jobs:
# to cut a release and push the index, and there is no reason to hand that to
# the job holding the signing identity.
publish-http:
name: Publish chart to the Helm repo
name: publish-http
needs: [chart, install, release-version]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'simstudioai/sim'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: *runner-2vcpu
timeout-minutes: 15
permissions:
contents: write # Cut the chart release and push index.yaml to the pages branch.
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# chart-releaser diffs against the previous tag to decide which charts
# changed, so it needs the full history rather than a shallow clone.
@@ -1,4 +1,4 @@
name: Database Migrations
name: migrate
on:
workflow_call:
@@ -7,6 +7,17 @@ on:
description: Target environment (production, staging, or dev)
required: true
type: string
secrets:
DATABASE_URL:
required: false
STAGING_DATABASE_URL:
required: false
DEV_DATABASE_URL:
required: false
MIGRATION_DATABASE_URL:
required: false
STAGING_MIGRATION_DATABASE_URL:
required: false
workflow_dispatch:
inputs:
environment:
@@ -23,7 +34,7 @@ permissions:
jobs:
migrate:
name: Apply Database Migrations
name: migrate
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
# Bulk projection loads and concurrent index builds can outlast ordinary schema changes.
timeout-minutes: 300
@@ -31,25 +42,14 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
persist-credentials: false
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
- name: Setup workspace
uses: ./.github/actions/setup
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
provider: ${{ vars.CI_PROVIDER }}
node-version: ''
# The expression maps the explicit environment input to exactly one repo
# secret, so the job never holds another environment's database URL. An
+19 -26
View File
@@ -1,4 +1,4 @@
name: Publish CLI Package
name: publish-cli
on:
push:
@@ -9,39 +9,28 @@ on:
permissions:
contents: read
# A newer push waits for the running publish rather than racing its version check.
concurrency:
group: publish-cli-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-npm:
publish:
name: publish
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
persist-credentials: false
- name: Setup Node.js for npm publishing
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
node-version: '22'
registry-url: 'https://registry.npmjs.org/'
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
working-directory: packages/cli
run: bun install --frozen-lockfile --ignore-scripts
registry-url: https://registry.npmjs.org/
- name: Build package
working-directory: packages/cli
@@ -54,8 +43,10 @@ jobs:
- name: Check if version already exists
id: version_check
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: |
if npm view simstudio@${{ steps.package_version.outputs.version }} version &> /dev/null; then
if npm view "simstudio@$VERSION" version &> /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
@@ -70,4 +61,6 @@ jobs:
- name: Log skipped publish
if: steps.version_check.outputs.exists == 'true'
run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on npm"
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: echo "Skipped publishing because version $VERSION already exists on npm"
+24 -9
View File
@@ -1,4 +1,4 @@
name: Publish Python SDK
name: publish-python-sdk
on:
push:
@@ -9,13 +9,21 @@ on:
permissions:
contents: write
# A newer push waits for the running publish rather than racing its version check.
concurrency:
group: publish-python-sdk-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-pypi:
publish:
name: publish
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -40,12 +48,17 @@ jobs:
- name: Check if version already exists
id: version_check
# Exact lookup: PyPI answers 404 only for a version it has never published. Anything other
# than 200 or 404 stops the job rather than guessing.
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: |
if pip index versions simstudio-sdk | grep -q "${{ steps.package_version.outputs.version }}"; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
status="$(curl -sS -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/simstudio-sdk/$VERSION/json")"
case "$status" in
200) echo "exists=true" >> "$GITHUB_OUTPUT" ;;
404) echo "exists=false" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::PyPI returned HTTP $status for simstudio-sdk $VERSION" >&2; exit 1 ;;
esac
- name: Build package
if: steps.version_check.outputs.exists == 'false'
@@ -67,11 +80,13 @@ jobs:
- name: Log skipped publish
if: steps.version_check.outputs.exists == 'true'
run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on PyPI"
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: echo "Skipped publishing because version $VERSION already exists on PyPI"
- name: Create GitHub Release
if: steps.version_check.outputs.exists == 'false'
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
+7 -22
View File
@@ -1,4 +1,4 @@
name: Publish Sim API CLI Package
name: publish-sim-cli
on:
push:
@@ -19,39 +19,24 @@ concurrency:
cancel-in-progress: false
jobs:
publish-npm:
publish:
name: publish
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
persist-credentials: false
# Vitest 5 requires Node 22.12+; the published bundle is still
# smoke-tested on Node 20 below, its minimum supported runtime.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
node-version: '22'
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Verify npm authentication
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+7 -22
View File
@@ -1,4 +1,4 @@
name: Publish Sim Setup Package
name: publish-sim-setup
on:
push:
@@ -19,39 +19,24 @@ concurrency:
cancel-in-progress: false
jobs:
publish-npm:
publish:
name: publish
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
persist-credentials: false
# Vitest 5 requires Node 22.12+; the published bundle is still
# smoke-tested on Node 20 below, its minimum supported runtime.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
node-version: '22'
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Verify npm authentication
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+20 -26
View File
@@ -1,4 +1,4 @@
name: Publish TypeScript SDK
name: publish-ts-sdk
on:
push:
@@ -9,38 +9,28 @@ on:
permissions:
contents: write
# A newer push waits for the running publish rather than racing its version check.
concurrency:
group: publish-ts-sdk-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-npm:
publish:
name: publish
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
persist-credentials: false
- name: Setup Node.js for npm publishing
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Setup workspace
uses: ./.github/actions/setup
with:
provider: ${{ vars.CI_PROVIDER }}
node-version: '22'
registry-url: 'https://registry.npmjs.org/'
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
registry-url: https://registry.npmjs.org/
- name: Run tests
working-directory: packages/ts-sdk
@@ -57,8 +47,10 @@ jobs:
- name: Check if version already exists
id: version_check
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: |
if npm view simstudio-ts-sdk@${{ steps.package_version.outputs.version }} version &> /dev/null; then
if npm view "simstudio-ts-sdk@$VERSION" version &> /dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
@@ -73,11 +65,13 @@ jobs:
- name: Log skipped publish
if: steps.version_check.outputs.exists == 'true'
run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on npm"
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: echo "Skipped publishing because version $VERSION already exists on npm"
- name: Create GitHub Release
if: steps.version_check.outputs.exists == 'false'
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
-482
View File
@@ -1,482 +0,0 @@
name: Test and Build
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
postgres-integration:
# Runs the real-infrastructure test layer: every `*.integration.ts` in packages/db and
# apps/sim, discovered by glob (`vitest run --mode integration`), against the database each
# provisioning path produces. A new integration suite needs no workflow change.
name: PostgreSQL integration (${{ matrix.provision }})
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
provision: [push, migrate]
services:
redis:
image: redis:8.2-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
postgres-legacy:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
TEST_REDIS_URL: redis://127.0.0.1:6379
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
BETTER_AUTH_SECRET: oauth-postgres-ci-secret-at-least-32-characters
NEXT_PUBLIC_APP_URL: https://test.sim.ai
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup workspace
uses: ./.github/actions/setup-workspace
with:
provider: ${{ vars.CI_PROVIDER }}
- name: Provision a fresh database through the supported command
working-directory: packages/db
run: |
bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); for (const extension of ["vector", "btree_gin", "pg_trgm"]) await sql`CREATE EXTENSION IF NOT EXISTS ${sql(extension)}`; await sql.end()'
bun run db:${{ matrix.provision }}
- name: Verify migration replay is a no-op
if: matrix.provision == 'migrate'
working-directory: packages/db
run: bun run db:migrate
- name: Run packages/db integration tests
working-directory: packages/db
run: bun run test --mode integration
- name: Run apps/sim integration tests
working-directory: apps/sim
# A non-UTC process zone keeps timestamp-without-time-zone handling honest.
env:
TZ: America/Los_Angeles
run: bun run test --mode integration
- name: Verify cumulative billing timeout recovery on PostgreSQL 16
if: matrix.provision == 'push'
working-directory: apps/sim
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_test
run: >-
bun run test --mode integration lib/billing/core/usage-log.integration.ts
--outputFile.json=test-results/integration-pg16.json
- name: Upload integration test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-reports-${{ matrix.provision }}
path: |
packages/db/test-results/*.json
apps/sim/test-results/*.json
if-no-files-found: warn
retention-days: 14
# Acceptance suites that cross a real HTTP boundary. Its own job, off the
# integration legs' critical path and on its own database: the SCIM app boots
# hosted, which starts background usage replay against DATABASE_URL, so it must
# never share a database with suites asserting on billing rows. The suites
# exercise HTTP behavior rather than a provisioning path, so they run once,
# against the production (migrate) path.
http-e2e:
name: End-to-end over real HTTP
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 20
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sim_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d sim_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
BETTER_AUTH_SECRET: http-e2e-ci-secret-at-least-32-characters
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup workspace
uses: ./.github/actions/setup-workspace
with:
provider: ${{ vars.CI_PROVIDER }}
# Migrations create their own extensions, as on a fresh self-hosted install.
- name: Provision the database through migrations
working-directory: packages/db
run: bun run db:migrate
- name: Verify Google document reads over real HTTP
working-directory: apps/sim
env:
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
NEXT_PUBLIC_FORCE_HOSTED: 'false'
SEARCH_GOOGLE_CONTENT_REPORT_PATH: ${{ runner.temp }}/e2e/search-google-content.json
run: bun scripts/test-search-google-content-e2e.ts
- name: Verify Lucid MCP search and complete diagram reads over real HTTP
working-directory: apps/sim
env:
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
NEXT_PUBLIC_FORCE_HOSTED: 'false'
SEARCH_LUCID_REPORT_PATH: ${{ runner.temp }}/e2e/search-lucid.json
run: bun scripts/test-search-lucid-e2e.ts
- name: Verify Zoom search over real HTTP
working-directory: apps/sim
env:
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
NEXT_PUBLIC_FORCE_HOSTED: 'false'
SEARCH_ZOOM_REPORT_PATH: ${{ runner.temp }}/e2e/search-zoom.json
run: bun scripts/test-search-zoom-e2e.ts
- name: Verify Google Meet search over real HTTP
working-directory: apps/sim
env:
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3040
NEXT_PUBLIC_FORCE_HOSTED: 'false'
SEARCH_GOOGLE_MEET_REPORT_PATH: ${{ runner.temp }}/e2e/search-google-meet.json
run: bun scripts/test-search-google-meet-e2e.ts
# The first request cold-compiles the app under Turbopack, which took 42-150s
# on this runner class: a fixed 120s readiness deadline failed on the slow
# tail. The deadline only has to catch a hung boot; an exited server fails
# immediately, and either way the server log tail lands in the job log. No
# step timeout: the job's bound covers a hang without cutting a slow but
# healthy suite short of writing its report.
- name: Verify SCIM, administration and workflow comparisons over real HTTP
working-directory: apps/sim
env:
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3017
BETTER_AUTH_URL: http://127.0.0.1:3017
NEXT_PUBLIC_FORCE_HOSTED: 'true'
BILLING_ENABLED: 'true'
NEXT_PUBLIC_BILLING_ENABLED: 'true'
ENTERPRISE_ENABLED: 'true'
NEXT_PUBLIC_ENTERPRISE_ENABLED: 'true'
SCIM_ENABLED: 'true'
NEXT_PUBLIC_SCIM_ENABLED: 'true'
SSO_ENABLED: 'true'
NEXT_PUBLIC_SSO_ENABLED: 'true'
ORGANIZATIONS_ENABLED: 'true'
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: 'true'
INTERNAL_API_SECRET: scim-http-ci-local-secret-at-least-32-characters
DB_TX_TRIPWIRE: throw
DISABLE_TELEMETRY: 'true'
NEXT_TELEMETRY_DISABLED: '1'
NEXT_PUBLIC_CHAT_DISABLED: 'true'
READY_TIMEOUT_SECONDS: 300
run: |
report_dir="$RUNNER_TEMP/e2e"
server_log="$report_dir/scim-next.log"
mkdir -p "$report_dir"
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3017 > "$server_log" 2>&1 &
server_pid=$!
finish() {
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$report_dir/scim-http-status.log"
}
trap finish EXIT
fail_startup() {
echo "::error::$1"
tail -n 200 "$server_log"
exit 1
}
started=$SECONDS
until curl --fail --silent --max-time 10 http://127.0.0.1:3017/api/health > /dev/null; do
kill -0 "$server_pid" 2>/dev/null || fail_startup 'Local SCIM app exited during startup.'
[ $((SECONDS - started)) -lt "$READY_TIMEOUT_SECONDS" ] ||
fail_startup "Local SCIM app did not become ready within $READY_TIMEOUT_SECONDS seconds."
sleep 2
done
echo "Local SCIM app ready after $((SECONDS - started))s"
SCIM_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
SCIM_E2E_REPORT_PATH="$report_dir/scim-e2e-report.json" \
bun run test:scim:e2e
VERSION_COMPARE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
VERSION_COMPARE_E2E_DATABASE_URL="$DATABASE_URL" \
VERSION_COMPARE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
VERSION_COMPARE_E2E_REPORT_PATH="$report_dir/version-compare-http-report.json" \
bun run test:workflow-version-compare:e2e
- name: Upload end-to-end reports and server logs
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: http-e2e-reports
path: ${{ runner.temp }}/e2e/
if-no-files-found: ignore
retention-days: 7
test-build:
name: Lint and Test
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
# The diff-based audits below need a base commit to read, and the default
# depth of 1 clones a single commit with no parent. They normally fetch
# their base by SHA (see "Resolve base ref"), so this depth only covers the
# `HEAD~1` fallback — but without it that fallback resolves to nothing.
#
# Worth stating because the failure was invisible for so long: the migration
# audit read the resulting `git diff` failure as "no migrations changed" and
# exited 0, so it had never actually run on a push build.
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
with:
provider: ${{ vars.CI_PROVIDER }}
turbo-cache-key: turbo-cache
# Surfaces known CVEs in the dependency tree. Non-blocking until the
# existing advisory backlog is triaged, then flip to a required gate by
# removing continue-on-error.
- name: Security audit
run: bun audit
continue-on-error: true
- name: Validate env flags
run: |
FILE="apps/sim/lib/core/config/env-flags.ts"
ERRORS=""
echo "Checking for hardcoded boolean env flags..."
# Use perl for multiline matching to catch both:
# export const isHosted = true
# export const isHosted =
# true
HARDCODED=$(perl -0777 -ne 'while (/export const (is[A-Za-z]+)\s*=\s*\n?\s*(true|false)\b/g) { print " $1 = $2\n" }' "$FILE")
if [ -n "$HARDCODED" ]; then
ERRORS="${ERRORS}\n❌ Env flags must not be hardcoded to boolean literals!\n\nFound hardcoded flags:\n${HARDCODED}\n\nEnv flags should derive their values from environment variables.\n"
fi
echo "Checking env flag naming conventions..."
# Check that all export const (except functions) start with 'is'
# This finds exports like "export const someFlag" that don't start with "is" or "get"
BAD_NAMES=$(grep -E "^export const [a-z]" "$FILE" | grep -vE "^export const (is|get)" | sed 's/export const \([a-zA-Z]*\).*/ \1/')
if [ -n "$BAD_NAMES" ]; then
ERRORS="${ERRORS}\n❌ Env flags must use 'is' prefix for boolean flags!\n\nFound incorrectly named flags:\n${BAD_NAMES}\n\nExample: 'hostedMode' should be 'isHostedMode'\n"
fi
if [ -n "$ERRORS" ]; then
echo ""
echo -e "$ERRORS"
exit 1
fi
echo "✅ All env flags are properly configured"
# One fetch for both base-ref audits, and no `|| true`: a swallowed fetch leaves
# the base ref absent, which neither audit can tell apart from a branch that
# changed nothing. The block-registry check at least degrades to a visible
# `⚠ … skipping` line; the migration audit printed `✓ No new migrations to
# check` and exited 0, clearing the only guard on production DDL.
#
# Depth stays at 1 — without a merge-base the migration audit diffs the two
# tips, which under `--diff-filter=AM` is exactly the migrations new here.
#
# On push the base is `github.event.before`, the tip the branch had before
# this push — not `HEAD~1`, which names only the last commit and would let a
# multi-commit push slip every earlier commit's migrations past the audit.
# It is fetched by SHA at depth 1; the audits diff two tips and need no
# common ancestry. An all-zero `before` means the branch is new and has no
# predecessor to diff, so `HEAD~1` remains the fallback there.
- name: Resolve base ref for diff-based audits
id: audit_base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch --depth=1 origin "${{ github.base_ref }}"
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
elif [ -n "${{ github.event.before }}" ] &&
[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
git fetch --depth=1 origin "${{ github.event.before }}"
echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
else
echo "ref=HEAD~1" >> "$GITHUB_OUTPUT"
fi
- name: Check block registry invariants
run: bun run apps/sim/scripts/check-block-registry.ts "${{ steps.audit_base.outputs.ref }}"
- name: Lint code
run: bun run lint:check
# Every zero-argument `check:*` script, run concurrently. The list is derived in
# scripts/run-audits.ts, which also writes the per-audit timing table to the job
# summary and annotates failures. Audits needing a base ref stay separate below.
- name: Repo audits
run: bun run check:audits
- name: Verify docs manifest is in sync
run: bun run docs-manifest:check
- name: Migration safety (zero-downtime) audit
run: bun run check:migrations "${{ steps.audit_base.outputs.ref }}"
# Every workspace, not just realtime. packages/emcn, packages/utils,
# apps/desktop and apps/docs had no type check in CI at all; apps/sim's
# source was covered only as a side effect of `next build` in the separate
# Build App job. Note this does NOT cover apps/sim's tests — its tsconfig
# excludes *.test.ts(x), and including them today surfaces ~2.2k errors,
# so that is its own cleanup rather than a gate to switch on here.
- name: Type-check all workspaces
run: bunx turbo run type-check
# cloud-review-tools.test.ts runs the real helper on the runner, which shells
# out to rg. Blacksmith's image ships it, GitHub's doesn't.
- name: Install ripgrep
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)
# Runs the root scripts and each workspace's Vitest suite,
# without `--coverage`.
- name: Run tests
env:
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only
TURBO_CACHE_DIR: .turbo
run: bun run test
- name: Check schema and migrations are in sync
working-directory: packages/db
run: |
bunx drizzle-kit generate --config=./drizzle.config.ts
if [ -n "$(git status --porcelain ./migrations)" ]; then
echo "❌ Schema and migrations are out of sync!"
echo "Run 'cd packages/db && bunx drizzle-kit generate' and commit the new migrations."
git status --porcelain ./migrations
git diff ./migrations
exit 1
fi
echo "✅ Schema and migrations are in sync"
# Next.js production build, in parallel with lint + tests. Sticky disks are
# cloned from the last committed snapshot per job and committed last-writer-
# wins, so concurrent mounts are safe. The bun/node_modules disks are shared
# with test-build (the lockfile-hashed key means they only ever share when the
# dependency tree really is identical, so LWW loss is harmless), but the Turbo
# cache gets its own key: with a shared key, only the last committer's new
# entries survive each run, so the test and build Turbo entries would evict
# each other nondeterministically.
#
# Runner is sized for the COLD-cache build, which is what OOM-killed the 8vcpu
# tier (23 kills / 1074 runs at 98% of its 30.4 GB): warm peaks ~12 GB, cold
# peaked 51 GB. NODE_OPTIONS' --max-old-space-size caps only Node's JS heap,
# not the native Turbopack workers that dominate, so it cannot prevent this.
build:
name: Build App
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
# Build durations crossed 15 minutes as the app grew (10m02 on Jul 29 AM,
# 14m44 after the folders/desktop/library merges, then two straight
# timeouts) — GitHub reports a job timeout as "cancelled". 25 keeps
# headroom without masking a genuine hang.
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup workspace
uses: ./.github/actions/setup-workspace
with:
provider: ${{ vars.CI_PROVIDER }}
turbo-cache-key: turbo-cache-build
# No `.next/cache` mount: the Turbopack persistent build cache is off. A
# controlled A/B on one branch (PR #6078) with a byte-identical module graph
# measured compile at 113s with the cache off, 162s cold with it on, and
# 360s warm — the cache made the same build 3.2x slower, and it grew
# 5.1 GB -> 12 GB across two runs of an unchanged tree, so a disk degrades
# the more it is used. Mounting a disk nothing reads would only cost storage.
# Running out of RAM kills the whole VM and surfaces only as "the runner
# has received a shutdown signal" — no mention of memory, ~12 min in. Warn
# with the real numbers so that failure is a one-line diagnosis instead of
# a mystery. Warn, never fail: a warm build peaks ~12 GB and a partial one
# ~28 GB, so a 32 GB runner still completes plenty of builds, and the
# GitHub fallback is the break-glass path — degrading it to a guaranteed
# failure would be worse than the risk this flags.
- name: Check runner memory headroom
run: |
TOTAL_GB=$(awk '/MemTotal/ {printf "%d", $2/1048576}' /proc/meminfo)
echo "Runner memory: ${TOTAL_GB} GB"
if [ "$TOTAL_GB" -lt 40 ]; then
echo "::warning::Runner has ${TOTAL_GB} GB. A cold-cache build peaks ~51 GB, so this run may be OOM-killed (reported only as 'the runner has received a shutdown signal'). Warm/partial builds should still fit."
fi
- name: Build application
env:
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
STRIPE_SECRET_KEY: 'dummy_key_for_ci_only'
STRIPE_WEBHOOK_SECRET: 'dummy_secret_for_ci_only'
RESEND_API_KEY: 'dummy_key_for_ci_only'
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only
TURBO_CACHE_DIR: .turbo
run: bunx turbo run build --filter=@sim/app
@@ -1,4 +1,4 @@
name: Deploy Dev Tasks
name: trigger-dev
# Independent of app CI: task packaging and deployment must never delay dev images.
on:
@@ -16,31 +16,20 @@ concurrency:
jobs:
deploy:
name: Deploy Trigger.dev preview
name: deploy
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
persist-credentials: false
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
- name: Setup workspace
uses: ./.github/actions/setup
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
provider: ${{ vars.CI_PROVIDER }}
node-version: ''
- name: Upload preview version
id: deploy
+2 -2
View File
@@ -107,8 +107,8 @@ Local unsigned pre-release share: `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.si
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
- Stable builds run only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never`; reruns verify the size and SHA-256 digest of existing release assets instead of overwriting them.
- **Secrets gate**: `check-desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release).
- Stable builds run only after `release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never`; reruns verify the size and SHA-256 digest of existing release assets instead of overwriting them.
- **Secrets gate**: `desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "desktop-release" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release).
- The product semver is **injected** from the release tag into `apps/desktop/package.json` at build time (repo package versions are placeholders). A mismatch guard fails the build.
- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, and cookie encryption on. The packaged smoke test asserts every fuse byte so Electron upgrades fail until new fuses receive an explicit policy.
- **Cookie-encryption go/no-go**: on every Electron bump, verify a packaged build keeps its session across relaunch (there are historical cookie-persistence bugs with the `EnableCookieEncryption` fuse). If it reproduces, set `enableCookieEncryption: false` and record it here.
@@ -0,0 +1,575 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { type ElectronApplication, expect, test } from '@playwright/test'
import type { SimDesktopApi } from '@sim/desktop-bridge'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { recordCheck } from './check-report'
import {
type FixtureCall,
FixtureSim,
launch,
processRunning,
RECONCILE_MS,
readFileSafe,
refFor,
registeredDevice,
settled,
sha256,
WORKSPACE,
} from './executor-sim'
/**
* The Sim desktop app's background executor against a fixture Sim that speaks the executor's
* device protocol (register, inbox, doorbell, claim, lease, complete, import) the way Sim's
* routes do.
* The window navigates, reloads and leaves the chats while their calls run: nothing in this
* suite depends on a chat view, which is the point. Each check lands in a JSON report at
* BACKGROUND_EXECUTOR_REPORT_PATH as it finishes.
*/
const CHAT_A = 'chat-browser-a'
const CHAT_B = 'chat-terminal-b'
const CHAT_C = 'chat-idle-c'
const sim = new FixtureSim()
/** Runs one check and adds its outcome to the report as soon as it is known. */
async function check(name: string, body: () => Promise<void>): Promise<void> {
const startedAt = Date.now()
const record = (status: 'passed' | 'failed', error?: unknown) =>
recordCheck(process.env.BACKGROUND_EXECUTOR_REPORT_PATH, 'background-executor', {
name,
status,
durationMs: Date.now() - startedAt,
retry: test.info().retry,
...(error === undefined ? {} : { error: getErrorMessage(error) }),
})
try {
await body()
record('passed')
} catch (error) {
record('failed', error)
throw error
}
}
test.describe('background executor', () => {
let app: ElectronApplication | null = null
test.beforeAll(async () => {
await sim.start()
})
test.afterEach(async () => {
await app?.close().catch(() => {})
app = null
sim.reset()
})
test.afterAll(async () => {
await sim.stop()
})
test('A: two chats run browser and terminal work while the user is elsewhere and reloads', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-a-'))
const launched = await launch(sim, userData)
app = launched.app
const window = launched.window
const deviceId = await registeredDevice(sim)
await window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
const marker = join(userData, 'terminal-marker.txt')
const readable = join(userData, 'notes.txt')
writeFileSync(readable, 'background read fixture')
await check('A: chat A opens its page in the background', async () => {
const opened = sim.issue(deviceId, CHAT_A, 'browser_open_url', {
url: `${sim.origin}/counter?chat=A`,
})
const completion = await settled(sim, opened)
expect(completion.status, completion.message).toBe('success')
})
const opened = [...sim.calls.values()][0] as FixtureCall
const outline = (opened.completions[0]?.data?.snapshot as { outline: string }).outline
const button = refFor(outline, 'Count visit')
const clicks = Array.from({ length: 10 }, () =>
sim.issue(deviceId, CHAT_A, 'browser_click', { elementId: button })
)
const terminalRuns = [1, 2, 3].map((n) =>
sim.issue(deviceId, CHAT_B, 'terminal', {
operation: 'run',
args: { command: `sleep 1; echo B-${n} >> '${marker}'`, waitSeconds: 30 },
})
)
const localRead = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
await window.goto(`${sim.origin}/workspace/ws-other/home`)
await window.reload()
await window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
await check('A: every call completes exactly once with its own token', async () => {
for (const id of [...clicks, ...terminalRuns, localRead]) {
const completion = await settled(sim, id, 90_000)
const call = sim.requireCall(id)
expect(completion.status, `${call.toolName}: ${completion.message}`).toBe('success')
expect(completion.outcome).toBe('recorded')
expect(call.claims).toBe(1)
expect(call.completions).toHaveLength(1)
}
})
await check('A: the page saw each click exactly once', async () => {
await expect.poll(() => sim.hits.get('A')).toBe(10)
})
await check('A: chat B ran each command once, in order, in its own terminal', async () => {
expect(readFileSync(marker, 'utf8').trim().split('\n')).toEqual(['B-1', 'B-2', 'B-3'])
const read = sim.requireCall(localRead).completions[0]
expect(JSON.stringify(read?.data)).toContain('background read fixture')
})
await check("A: chat B cannot see chat A's tabs", async () => {
const listed = sim.issue(deviceId, CHAT_B, 'browser_list_tabs', {})
const completion = await settled(sim, listed)
expect(JSON.stringify(completion.data)).not.toContain('/counter')
})
await check('A: no chat view executed or reported a call', async () => {
expect(sim.requests.filter((request) => request.includes('/api/copilot/confirm'))).toEqual([])
expect(
sim.requests.filter((request) => request.includes('/api/desktop/tool/authorize'))
).toEqual([])
})
await check('A: calls are picked up within 1.5 s at p95', async () => {
const latencies = [...sim.calls.values()]
.map((call) => (call.claimedAt ?? Number.POSITIVE_INFINITY) - call.issuedAt)
.sort((a, b) => a - b)
const p95 = latencies[Math.ceil(latencies.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY
expect(p95).toBeLessThan(1_500)
})
await check('A: the lease is renewed while calls wait and run', async () => {
expect(Math.max(...terminalRuns.map((id) => sim.requireCall(id).renewals))).toBeGreaterThan(0)
})
})
test('B: a result produced while the network is cut is delivered once after reconnecting', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-b-'))
app = (await launch(sim, userData)).app
const deviceId = await registeredDevice(sim)
const run = sim.issue(deviceId, CHAT_B, 'terminal', {
operation: 'run',
args: { command: 'sleep 2; echo offline-done', waitSeconds: 30 },
})
await expect.poll(() => sim.requireCall(run).claims).toBe(1)
sim.disconnect()
await check(
'B: the cut drops the doorbell and nothing reaches Sim while it lasts',
async () => {
await expect.poll(() => sim.streams.get(deviceId)?.size ?? 0).toBe(0)
await expect.poll(() => sim.droppedWhileOffline, { timeout: 15_000 }).toBeGreaterThan(0)
await sleep(4_000)
expect(sim.requireCall(run).completions).toHaveLength(0)
}
)
sim.reconnect()
await check('B: the result arrives once after reconnecting', async () => {
const completion = await settled(sim, run, 60_000)
expect(completion.status, completion.message).toBe('success')
expect(JSON.stringify(completion.data)).toContain('offline-done')
await sleep(3_000)
expect(sim.requireCall(run).completions).toHaveLength(1)
expect(sim.requireCall(run).claims).toBe(1)
})
await check('B: the device reopens its doorbell and picks up new work', async () => {
await expect.poll(() => sim.streams.get(deviceId)?.size ?? 0, { timeout: 45_000 }).toBe(1)
const after = sim.issue(deviceId, CHAT_B, 'browser_list_tabs', {})
const completion = await settled(sim, after)
expect(completion.status, completion.message).toBe('success')
})
})
test('C: a crash mid-command reports the outcome as unknown and never reruns it', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-c-'))
const marker = join(userData, 'crash-marker.txt')
const first = await launch(sim, userData)
const deviceId = await registeredDevice(sim)
const run = sim.issue(deviceId, CHAT_B, 'terminal', {
operation: 'run',
args: { command: `echo started >> '${marker}'; sleep 40`, waitSeconds: 60 },
})
await expect.poll(() => readFileSafe(marker), { timeout: 30_000 }).toContain('started')
first.app.process().kill('SIGKILL')
sim.streams.get(deviceId)?.clear()
app = (await launch(sim, userData)).app
await check('C: the restarted app reports the lost result as outcome unknown', async () => {
const completion = await settled(sim, run, 60_000)
expect(completion.data).toMatchObject({ outcomeUnknown: true, doNotRetry: true })
})
await check('C: the command ran exactly once', async () => {
expect(sim.requireCall(run).claims).toBe(1)
expect(readFileSafe(marker).trim().split('\n')).toEqual(['started'])
})
})
test('D: a folder import lands in Sim while the user is in another chat', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-d-'))
const launched = await launch(sim, userData)
app = launched.app
const deviceId = await registeredDevice(sim)
const source = join(userData, 'Reports')
mkdirSync(join(source, 'q3'), { recursive: true })
writeFileSync(join(source, 'notes.txt'), 'remember the numbers')
// Larger than one 8 MB read, so the file crosses Electron in several chunks.
const large = Buffer.alloc(9 * 1024 * 1024, 7)
writeFileSync(join(source, 'q3', 'export.bin'), large)
await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
const call = sim.issue(deviceId, CHAT_B, 'import_local_files', {
path: source,
targetWorkspaceId: WORKSPACE,
folderId: 'folder-e2e',
})
await check('D: the import completes with every entry it stored', async () => {
const completion = await settled(sim, call, 60_000)
expect(completion.status).toBe('success')
expect(completion.data).toMatchObject({
success: true,
workspaceId: WORKSPACE,
folders: [
{ id: 'entry-1', relativePath: '' },
{ id: 'entry-3', relativePath: 'q3' },
],
files: [
{ id: 'entry-2', relativePath: 'notes.txt' },
{ id: 'entry-4', relativePath: 'q3/export.bin' },
],
})
})
await check('D: Sim received the tree with the bytes on disk, once', async () => {
expect(sim.imported).toEqual([
{ toolCallId: call, kind: 'directory', sourceName: 'Reports', relativePath: '' },
{
toolCallId: call,
kind: 'file',
sourceName: 'Reports',
relativePath: 'notes.txt',
sha256: sha256(Buffer.from('remember the numbers')),
bytes: 20,
},
{ toolCallId: call, kind: 'directory', sourceName: 'Reports', relativePath: 'q3' },
{
toolCallId: call,
kind: 'file',
sourceName: 'Reports',
relativePath: 'q3/export.bin',
sha256: sha256(large),
bytes: large.length,
},
])
expect(sim.requireCall(call).claims).toBe(1)
})
})
test('E: Stop from another chat stops a running browser wait and terminal command', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-e-'))
app = (await launch(sim, userData)).app
const deviceId = await registeredDevice(sim)
const opened = sim.issue(deviceId, CHAT_A, 'browser_open_url', {
url: `${sim.origin}/counter?chat=E`,
})
await settled(sim, opened)
const wait = sim.issue(deviceId, CHAT_A, 'browser_wait_for', {
text: 'never appears',
timeoutMs: 30_000,
})
const command = 'sleep 47'
const run = sim.issue(deviceId, CHAT_B, 'terminal', {
operation: 'run',
args: { command, waitSeconds: 60 },
})
await expect.poll(() => sim.requireCall(wait).claims).toBe(1)
await expect.poll(() => processRunning(command), { timeout: 20_000 }).toBe(true)
const stoppedAt = Date.now()
sim.stopCall(wait)
sim.stopCall(run)
await check('E: both stopped calls are acknowledged within seconds', async () => {
await settled(sim, wait, 10_000)
await settled(sim, run, 15_000)
expect(sim.requireCall(wait).completions[0]?.outcome).toBe('superseded')
expect(sim.requireCall(run).completions[0]?.outcome).toBe('superseded')
expect((sim.requireCall(wait).completions[0]?.at ?? 0) - stoppedAt).toBeLessThan(5_000)
})
await check('E: the stopped command no longer runs on the machine', async () => {
await expect.poll(() => processRunning(command), { timeout: 10_000 }).toBe(false)
})
})
test('F: a call waiting for approval in a background chat notifies, then runs once approved', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-f-'))
app = (await launch(sim, userData)).app
await app.evaluate(({ Notification }) => {
const shown: Array<{ title: string; body: string }> = []
const target = globalThis as typeof globalThis & { __shownNotifications?: typeof shown }
target.__shownNotifications = shown
Notification.prototype.show = function show(this: { title: string; body: string }) {
shown.push({ title: this.title, body: this.body })
}
})
const deviceId = await registeredDevice(sim)
const gated = sim.issue(
deviceId,
CHAT_B,
'terminal',
{ operation: 'run', args: { command: 'echo approved-run', waitSeconds: 30 } },
'awaiting_approval'
)
await check('F: the user is notified, without the command in the notification', async () => {
await expect
.poll(
() =>
app?.evaluate(
() =>
(globalThis as { __shownNotifications?: Array<{ body: string }> })
.__shownNotifications ?? []
),
{ timeout: 15_000 }
)
.toHaveLength(1)
const shown = await app?.evaluate(
() =>
(globalThis as { __shownNotifications?: Array<{ body: string }> }).__shownNotifications
)
expect(JSON.stringify(shown)).not.toContain('approved-run')
})
await check('F: nothing runs before approval', async () => {
await sleep(RECONCILE_MS * 2)
expect(sim.requireCall(gated).claims).toBe(0)
})
sim.approve(gated)
const approvedAt = Date.now()
await check('F: the approved call is claimed promptly and runs once', async () => {
const completion = await settled(sim, gated)
expect((sim.requireCall(gated).claimedAt ?? 0) - approvedAt).toBeLessThan(1_500)
expect(JSON.stringify(completion.data)).toContain('approved-run')
})
})
test('F: a call the user declines in a background chat never runs', async () => {
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-f-declined-'))
const marker = join(userData, 'declined-marker.txt')
app = (await launch(sim, userData)).app
const deviceId = await registeredDevice(sim)
const pulls = () =>
sim.requests.filter((request) => request.startsWith('GET /api/desktop/inbox')).length
const claimAttempts = () =>
sim.requests.filter((request) => request === 'POST /api/desktop/tool/claim').length
const pullsBefore = pulls()
const claimAttemptsBefore = claimAttempts()
const gated = sim.issue(
deviceId,
CHAT_B,
'terminal',
{ operation: 'run', args: { command: `echo ran >> '${marker}'`, waitSeconds: 30 } },
'awaiting_approval'
)
// The device has pulled its inbox and seen the call waiting for approval.
await expect.poll(pulls).toBeGreaterThan(pullsBefore)
sim.decline(gated)
await check('F: the declined call is never claimed, and its command never runs', async () => {
await sleep(RECONCILE_MS * 2)
// Attempts, not successful claims: the fixture refuses a declined call's claim itself.
expect(claimAttempts() - claimAttemptsBefore).toBe(0)
expect(sim.requireCall(gated).completions).toEqual([])
expect(readFileSafe(marker)).toBe('')
})
})
test('G: only the device a turn is bound to claims its calls', async () => {
const first = await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-g1-')))
const firstDevice = await registeredDevice(sim)
const second = await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-g2-')))
const secondDevice = await registeredDevice(sim, new Set([firstDevice]))
app = first.app
const call = sim.issue(firstDevice, CHAT_A, 'browser_list_tabs', {})
await check('G: the bound device runs it and the other never claims it', async () => {
await settled(sim, call)
expect(sim.requireCall(call).claims).toBe(1)
expect(
sim.requests.filter((request) => request === 'POST /api/desktop/tool/claim').length
).toBe(1)
expect(secondDevice).not.toBe(firstDevice)
})
await second.app.close()
})
test('H: a device Sim has not enabled offers no binding', async () => {
sim.enabled = false
const launched = await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-h-')))
app = launched.app
await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
await expect.poll(() => sim.requests.includes('POST /api/desktop/devices')).toBe(true)
await check(
'H: the composer gets no device, so its turns stay with the chat view',
async () => {
const device = await launched.window.evaluate(() =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.desktopExecutor?.getDevice()
)
expect(device).toBeNull()
}
)
sim.enabled = true
await launched.app.close()
const enabled = await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-h2-')))
app = enabled.app
const deviceId = await registeredDevice(sim)
await enabled.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
await check('H: an enabled device offers itself for binding', async () => {
const device = await enabled.window.evaluate(() =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.desktopExecutor?.getDevice()
)
expect(device).toEqual({ deviceId, protocolVersion: 1 })
})
})
test('I: the agent yields its page while the user works in it, then takes it back', async () => {
app = (await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-i-')))).app
const deviceId = await registeredDevice(sim)
const opened = sim.issue(deviceId, CHAT_A, 'browser_open_url', {
url: `${sim.origin}/counter?chat=I`,
})
const outline = ((await settled(sim, opened)).data?.snapshot as { outline: string }).outline
const button = refFor(outline, 'Count visit')
const typeInAgentPage = () =>
app?.evaluate(({ webContents }) => {
const page = webContents
.getAllWebContents()
.find((contents) => contents.getURL().includes('/counter?chat=I'))
page?.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
page?.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
})
await typeInAgentPage()
const click = sim.issue(deviceId, CHAT_A, 'browser_click', { elementId: button })
for (let i = 0; i < 4; i++) {
await sleep(500)
await typeInAgentPage()
}
const lastUserInputAt = Date.now()
await check('I: the click waits until the user stops, then runs once', async () => {
const completion = await settled(sim, click, 30_000)
expect(completion.status, completion.message).toBe('success')
expect(completion.at - lastUserInputAt).toBeGreaterThanOrEqual(3_000)
await expect.poll(() => sim.hits.get('I')).toBe(1)
})
})
test('I: an action the user never stops working long enough for does not run', async () => {
app = (await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-i2-')))).app
const deviceId = await registeredDevice(sim)
const opened = sim.issue(deviceId, CHAT_A, 'browser_open_url', {
url: `${sim.origin}/counter?chat=I2`,
})
const outline = ((await settled(sim, opened)).data?.snapshot as { outline: string }).outline
const button = refFor(outline, 'Count visit')
const typeInAgentPage = () =>
app?.evaluate(({ webContents }) => {
const page = webContents
.getAllWebContents()
.find((contents) => contents.getURL().includes('/counter?chat=I2'))
page?.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
page?.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
})
await typeInAgentPage()
const click = sim.issue(deviceId, CHAT_A, 'browser_click', { elementId: button })
let typing = true
const keepTyping = (async () => {
while (typing) {
await typeInAgentPage()
await sleep(1_000)
}
})()
await check('I: the click reports it never ran, instead of timing out', async () => {
const completion = await settled(sim, click, 60_000)
typing = false
await keepTyping
expect(completion.status).toBe('error')
expect(completion.message).toContain('Not run: the user kept working in this page')
expect(completion.data).not.toMatchObject({ outcomeUnknown: true })
expect(sim.hits.get('I2') ?? 0).toBe(0)
})
})
test('J: the machine stays awake only while a chat has work running', async () => {
app = (await launch(sim, mkdtempSync(join(tmpdir(), 'sim-executor-j-')))).app
await app.evaluate(({ powerSaveBlocker }) => {
const log: string[] = []
const active = new Set<number>()
let next = 1
const target = globalThis as typeof globalThis & { __sleepBlocks?: string[] }
target.__sleepBlocks = log
powerSaveBlocker.start = (type) => {
log.push(`start:${type}`)
active.add(next)
return next++
}
powerSaveBlocker.stop = (id) => {
log.push('stop')
active.delete(id)
return true
}
powerSaveBlocker.isStarted = (id) => active.has(id)
})
const deviceId = await registeredDevice(sim)
const sleepLog = () =>
app?.evaluate(() => (globalThis as { __sleepBlocks?: string[] }).__sleepBlocks ?? [])
const run = sim.issue(deviceId, CHAT_B, 'terminal', {
operation: 'run',
args: { command: 'sleep 3; echo awake', waitSeconds: 30 },
})
await check('J: a blocker is held while the command runs', async () => {
await expect.poll(sleepLog, { timeout: 15_000 }).toEqual(['start:prevent-app-suspension'])
})
await check('J: it is released once the result is delivered', async () => {
await settled(sim, run)
await expect
.poll(sleepLog, { timeout: 10_000 })
.toEqual(['start:prevent-app-suspension', 'stop'])
})
})
})
+49
View File
@@ -0,0 +1,49 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test } from '@playwright/test'
import { omit } from '@sim/utils/object'
import type { ReportCheck } from './check-report'
/**
* The suites' JSON reports against a real worker restart: a nested Playwright run of a fixture
* whose first test fails, so the second runs in a fresh worker with fresh module state. The report
* must keep the failure, and must not carry checks from an earlier run.
*/
const CLI = createRequire(import.meta.url).resolve('@playwright/test/cli')
const CONFIG = fileURLToPath(new URL('./fixtures/worker-restart.config.ts', import.meta.url))
test('a check that failed before the worker restarted stays in the report', () => {
const reportPath = join(mkdtempSync(join(tmpdir(), 'sim-check-report-')), 'report.json')
writeFileSync(
reportPath,
JSON.stringify({
suite: 'worker-restart',
run: -1,
checks: [{ name: 'left by an earlier run', status: 'passed', durationMs: 0 }],
})
)
// The outer run's worker variables would make the nested runner think it is a worker.
const env = omit(
process.env,
Object.keys(process.env).filter((key) => /^(TEST_|PW_)/.test(key))
)
const run = spawnSync(process.execPath, [CLI, 'test', '--config', CONFIG], {
env: { ...env, WORKER_RESTART_REPORT_PATH: reportPath },
encoding: 'utf8',
timeout: 60_000,
})
expect(run.status, run.stdout + run.stderr).toBe(1)
const checks = (JSON.parse(readFileSync(reportPath, 'utf8')) as { checks: ReportCheck[] }).checks
expect(checks.map(({ name, status }) => ({ name, status }))).toEqual([
{ name: 'fails first', status: 'failed' },
{ name: 'passes after the worker restarts', status: 'passed' },
])
// Proof the second check ran in a replacement worker, not the one that failed.
expect(checks[0]?.error).not.toBe(checks[1]?.error)
})
+44
View File
@@ -0,0 +1,44 @@
import { readFileSync, writeFileSync } from 'node:fs'
/** One check's outcome in a suite's JSON report. */
export interface ReportCheck {
name: string
status: string
durationMs: number
error?: string
retry?: number
}
interface Report {
suite: string
/** The Playwright run that wrote it: its workers' parent process. */
run: number
checks: ReportCheck[]
}
/**
* Adds a check to the suite's report at `reportPath` as soon as its outcome is known, keeping the
* checks already there from the same run.
*
* Playwright replaces a worker after a failure, and the new one starts with empty module state, so
* a report kept in memory and written at the end would drop everything before the failure, the
* failure included, and could read green. The file holds it instead. Every worker of one run is a
* child of the same runner, so a report left by an earlier run is replaced rather than added to.
*/
export function recordCheck(
reportPath: string | undefined,
suite: string,
check: ReportCheck
): void {
if (!reportPath) return
const run = process.ppid
let checks: ReportCheck[] = []
try {
const existing = JSON.parse(readFileSync(reportPath, 'utf8')) as Report
if (existing.suite === suite && existing.run === run) checks = existing.checks
} catch {
// No report yet from this run.
}
const report: Report = { suite, run, checks: [...checks, check] }
writeFileSync(reportPath, JSON.stringify(report, null, 2))
}
@@ -0,0 +1,871 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
type ElectronApplication,
_electron as electron,
expect,
type Locator,
type Page,
test,
} from '@playwright/test'
import type { SimDesktopApi } from '@sim/desktop-bridge'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import {
type LiveSimConfig,
liveSimConfig,
RedisMonitor,
ScriptedAgent,
type SeededUser,
SimDatabase,
SimProxy,
} from './fixtures/live-sim'
/**
* Desktop tools in the real Electron app against a real local Sim: the renderer is Sim's own chat
* view, every claim and result crosses Sim's routes into PostgreSQL and Redis, and only the
* model's decisions are scripted (a stand-in worker at `SIM_AGENT_API_URL`). Each test checks
* what the user or the model would observe: the result the model is resumed with, what landed in
* the workspace, what Sim persisted, and which requests reached Sim.
*
* Sim runs the background executor wherever it has Redis, as it does here. Most tests cover the
* chat view running desktop tools itself, as on an install without Redis: the proxy answers the
* app's registration as such an install does, so the app stays dormant and no turn binds to it.
* The tests marked as running in the background let Sim's own answer through.
*/
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
const config = liveSimConfig()
const PICKUP_GRACE_MS = 15_000
/** Longer than the default tool budget (60 s) plus the resume grace (30 s). */
const LONG_IMPORT_MS = 120_000
/** The execution lease a running import holds and renews (`SIM_TOOL_EXECUTION_LEASE_SECONDS`). */
const LEASE_MS = 60_000
/** How long a held request may take to arrive once the step that sends it ran. */
const ARRIVAL_MS = 60_000
/** First requests to a route compile it, which takes minutes on a cold dev app. */
const COMPILE_MS = 300_000
const REGISTRATION_PATH = '/api/desktop/devices'
const DESKTOP_COMPLETION_PATH = '/api/desktop/tool/complete'
/** Sim's answer to registration on an install that cannot run the background executor. */
const executorUnavailable = (answer: Record<string, unknown>) => {
answer.enabled = false
}
type DesktopWindow = typeof globalThis & { simDesktop: SimDesktopApi }
test.describe('desktop tools against a live Sim', () => {
test.skip(typeof config === 'string', typeof config === 'string' ? config : '')
test.describe.configure({ timeout: 360_000 })
let sim: LiveSimConfig
let proxy: SimProxy
let agent: ScriptedAgent
let db: SimDatabase
let app: ElectronApplication | undefined
let scratch: string
/** Errors the current window reported, shown when a click is blocked. */
let pageErrors: string[] = []
let testStartedAt = 0
test.beforeAll(async () => {
test.setTimeout(1_500_000)
if (typeof config === 'string') throw new Error(config)
sim = config
proxy = new SimProxy(sim)
agent = new ScriptedAgent(sim.agentPort)
db = new SimDatabase(sim)
await Promise.all([proxy.start(), agent.start()])
proxy.rewriteAnswer(REGISTRATION_PATH, executorUnavailable)
await test.step('warm up the routes the tests use', warmUp)
})
test.afterAll(async () => {
await Promise.all([proxy?.stop(), agent?.stop(), db?.close()])
})
test.beforeEach(() => {
testStartedAt = Date.now()
scratch = mkdtempSync(join(tmpdir(), 'sim-desktop-tools-live-'))
})
test.afterEach(async () => {
const testInfo = test.info()
if (testInfo.status !== testInfo.expectedStatus) {
const page = app?.windows()[0]
await page?.screenshot({ path: testInfo.outputPath('failure.png') }).catch(() => {})
writeFileSync(
testInfo.outputPath('diagnostics.json'),
JSON.stringify(
{
url: page?.url(),
composer: await page
?.getByRole('textbox')
.last()
.inputValue({ timeout: 2_000 })
.catch(() => null),
pageErrors,
requests: proxy
.seen(testStartedAt)
.filter((entry) => !entry.path.startsWith('/_next/')),
},
null,
2
)
)
}
proxy.clearHolds()
proxy.restoreNetwork()
await app?.close().catch(() => {})
app = undefined
proxy.rewriteAnswer(REGISTRATION_PATH, executorUnavailable)
proxy.rewriteAnswer(DESKTOP_COMPLETION_PATH, undefined)
rmSync(scratch, { recursive: true, force: true })
})
/**
* A dev app compiles each route on its first request, one at a time, so a route first reached
* mid-test stalls every other request (Electron gives a tool authorization 8 s). The warm-up runs
* the tests' own flows once: a read and an import, a chat switch during a live turn and back,
* Stop, and the login page. It then waits until every request the app made has been answered,
* so no route the tests reach is left to compile. It ends with a marker request
* (`/api/health?e2e=warm-up-done`) so the dev server's log shows anything compiled after it.
*/
async function warmUp(): Promise<void> {
scratch = mkdtempSync(join(tmpdir(), 'sim-desktop-tools-warm-'))
try {
const user = await db.seedUser(['Warm chat', 'Warm other chat'])
const headers = {
'Content-Type': 'application/json',
Cookie: `better-auth.session_token=${user.cookie}`,
Origin: proxy.origin,
}
const compile = (path: string, method: 'GET' | 'POST' | 'PUT' = 'GET') =>
fetch(new URL(path, sim.upstream), {
method,
headers,
body: method === 'GET' ? undefined : '{}',
redirect: 'manual',
signal: AbortSignal.timeout(COMPILE_MS),
}).then((response) => response.arrayBuffer())
// Routes the tests reach that a warm-up turn alone would not: Stop's, registration, and the
// ones a running app loads in the background.
for (const path of [
chatPath(user, 'Warm chat'),
'/login',
`/api/mothership/chats/${user.chats['Warm other chat']}`,
`/api/mothership/chat/stream?chatId=${user.chats['Warm chat']}`,
`/api/workspaces/${user.workspaceId}/files/folders`,
'/api/copilot/chats',
'/api/users/me/settings',
'/api/auth/oauth/connections',
'/api/desktop/inbox',
])
await compile(path)
for (const path of [
'/api/mothership/chat/stop',
'/api/mothership/chat/abort',
'/api/desktop/devices',
'/api/desktop/tool/authorize',
'/api/desktop/tool/claim',
'/api/desktop/tool/complete',
'/api/copilot/confirm',
'/api/files/uploads',
'/api/files/uploads/warm-up/parts',
'/api/files/uploads/warm-up/complete',
`/api/workspaces/${user.workspaceId}/files/folders`,
])
await compile(path, 'POST')
await compile('/api/v2/uploads/warm-up', 'PUT')
const file = writeFile(join(scratch, 'warm.txt'), 'warm')
const folder = join(scratch, 'Warm folder')
writeFile(join(folder, 'warm.txt'), 'warm')
let proceed!: () => void
const proceeding = new Promise<void>((resolve) => {
proceed = resolve
})
agent.script(
'[warm-up]',
async (turn) => {
turn.text('Warming up.')
await proceeding
turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
turn.toolCall({
toolName: 'import_local_files',
args: { path: folder, targetWorkspaceId: user.workspaceId },
})
turn.pause()
},
(_resume, turn) => turn.complete('Warmed up.')
)
agent.script('[warm-stop]', async (turn) => {
turn.text('Stopping soon.')
// The leg stays open until Stop ends it.
await turn.closed
})
const page = await openApp(user, 'Warm chat', COMPILE_MS)
await send(page, '[warm-up] read and import', COMPILE_MS)
await expect(page.getByText('Warming up.')).toBeVisible({ timeout: COMPILE_MS })
// Leaving and reopening a chat while its turn runs re-attaches to the turn's stream.
await openChat(page, user, 'Warm other chat', COMPILE_MS)
await openChat(page, user, 'Warm chat', COMPILE_MS)
proceed()
await expect(page.getByText('Warmed up.')).toBeVisible({ timeout: 2 * COMPILE_MS })
await send(page, '[warm-stop] wait for Stop', COMPILE_MS)
await expect(page.getByText('Stopping soon.')).toBeVisible({ timeout: COMPILE_MS })
await click(page, page.getByRole('button', { name: 'Stop generation' }), COMPILE_MS)
await expect(page.getByRole('button', { name: 'Stop generation' })).toBeHidden({
timeout: COMPILE_MS,
})
await proxy.settled(COMPILE_MS)
await compile('/api/health?e2e=warm-up-done')
} finally {
await app?.close().catch(() => {})
app = undefined
rmSync(scratch, { recursive: true, force: true })
}
}
const chatPath = (user: SeededUser, title: string) =>
`/workspace/${user.workspaceId}/chat/${user.chats[title]}`
/** Launches the app signed in as `user`, showing the chat titled `title`. */
async function openApp(user: SeededUser, title: string, timeout = 120_000): Promise<Page> {
app = await electron.launch({
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
cwd: DESKTOP_DIR,
env: {
...process.env,
SIM_DESKTOP_ORIGIN: proxy.origin,
SIM_DESKTOP_USER_DATA: join(scratch, 'profile'),
},
})
const page = await app.firstWindow({ timeout })
pageErrors = []
page.on('pageerror', (error) => pageErrors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') pageErrors.push(message.text())
})
const signIn = new URL('/__e2e/sign-in', proxy.origin)
signIn.searchParams.set('cookie', user.cookie)
signIn.searchParams.set('to', chatPath(user, title))
await page.goto(signIn.toString(), { waitUntil: 'commit', timeout })
await expect(composer(page)).toBeVisible({ timeout })
return page
}
const composer = (page: Page) => page.getByRole('textbox').last()
/** The dev app's error overlay, if it is up, with the errors the window reported. */
async function devOverlay(page: Page): Promise<{ text: string; consoleOnly: boolean } | null> {
const overlay = page.locator('nextjs-portal [data-nextjs-dialog]')
if ((await overlay.count()) === 0) return null
const text = await overlay
.first()
.innerText()
.catch(() => '')
return {
text: `${text}\n${pageErrors.join('\n')}`,
consoleOnly: /^\s*Console Error/.test(text),
}
}
/**
* Clicks `target`. When the dev app's error overlay intercepts the click, a console-error notice
* (dev-only chrome over a logged error) is dismissed and the click retried; a runtime error fails
* at once with its message instead of waiting out a blocked click.
*/
async function click(page: Page, target: Locator, timeout = 15_000): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
await target.click({ timeout: attempt === 0 ? Math.min(timeout, 5_000) : timeout })
return
} catch (error) {
const overlay = await devOverlay(page)
if (overlay && !overlay.consoleOnly)
throw new Error(`Next.js error overlay: ${overlay.text}`)
if (overlay) await page.keyboard.press('Escape')
else if (attempt > 0) throw error
}
}
}
/**
* Sends `message` and waits for its turn to reach Sim. Before the page hydrates, typing or the
* click can be lost: the Send button is missing or does nothing and the composer is not emptied,
* and only then is the message typed and sent again. Once the UI takes the message (the
* composer empties after the click), a turn that never reaches Sim is a lost send and fails.
*/
async function send(page: Page, message: string, timeout = 60_000): Promise<void> {
const since = Date.now()
const deadline = since + timeout
const sent = () =>
proxy
.seen(since)
.some((entry) => entry.method === 'POST' && entry.path === '/api/mothership/chat')
while (!sent()) {
if (Date.now() > deadline)
throw new Error(
`The UI never took the message: ${message} (errors: ${pageErrors.join(' | ')})`
)
if ((await composer(page).inputValue()) !== message) await composer(page).fill(message)
const clicked = await click(page, page.getByRole('button', { name: 'Send message' }), 5_000)
.then(() => true)
.catch((error: unknown) => {
if (String(error).includes('Next.js error overlay')) throw error
return false
})
if (!clicked) continue
const taken = await expect
.poll(() => composer(page).inputValue(), { timeout: 5_000 })
.toBe('')
.then(
() => true,
() => false
)
if (!taken) continue
await expect
.poll(sent, {
timeout: Math.max(deadline - Date.now(), 30_000),
message: `The UI took the message but its turn never reached Sim: ${message} (errors: ${pageErrors.join(' | ')})`,
})
.toBe(true)
}
}
/** Switches chats in-app, the way the sidebar does, without reloading the page. */
async function openChat(
page: Page,
user: SeededUser,
title: string,
timeout = 30_000
): Promise<void> {
await click(page, page.getByRole('link', { name: title }).first(), timeout)
await expect(page).toHaveURL(new RegExp(`${user.chats[title]}$`), { timeout })
}
/** The chat's first call as `status: error`, so a wrong terminal state says why. */
async function callState(chatId: string): Promise<string> {
const [call] = await db.toolCalls(chatId)
return call ? `${call.status}: ${call.error ?? ''}` : 'no call'
}
function writeFile(path: string, contents: string): string {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, contents)
return path
}
/** A folder to import: a file, then a subfolder holding another file. */
function importSource(): string {
const root = join(scratch, 'Reports')
writeFile(join(root, 'a.txt'), 'first file')
writeFile(join(root, 'later', 'b.txt'), 'second file')
return root
}
/** The first request of a file's upload session. */
const isUploadStart = (method: string, path: string) =>
method === 'POST' && path === '/api/files/uploads'
const isFolderCreate = (method: string, path: string) =>
method === 'POST' && /^\/api\/workspaces\/[^/]+\/files\/folders$/.test(path)
const isDesktopClaim = (method: string, path: string) =>
method === 'POST' && path === '/api/desktop/tool/authorize'
/** The chat turn's response stream, as the chat view reads it. */
const isChatStream = (entry: { method: string; path: string }) =>
entry.method === 'POST' && entry.path === '/api/mothership/chat'
/** The background executor's report of a call's result. */
const isDesktopCompletion = (method: string, path: string) =>
method === 'POST' && path === DESKTOP_COMPLETION_PATH
/** A client tool's report of its own result. */
const isToolReport = (method: string, path: string) =>
method === 'POST' && path === '/api/copilot/confirm'
test('a browser call issued after the user switched chats fails as not started after the pickup grace', async () => {
const user = await db.seedUser(['Browser chat', 'Other chat'])
let issue!: () => void
const issued = new Promise<void>((resolve) => {
issue = resolve
})
let callId = ''
let issuedAt = 0
agent.script('[pickup-grace]', async (turn) => {
turn.text('Checking your browser tabs.')
await issued
callId = turn.toolCall({ toolName: 'browser_list_tabs', args: {} })
issuedAt = Date.now()
turn.pause()
})
const page = await openApp(user, 'Browser chat')
await send(page, '[pickup-grace] which tabs are open?')
await expect(page.getByText('Checking your browser tabs.')).toBeVisible({ timeout: 60_000 })
await openChat(page, user, 'Other chat')
issue()
await agent.waitForResume(() => Boolean(callId && agent.resultFor(callId)), 60_000)
const result = agent.resultFor(callId)
expect(result?.success).toBe(false)
expect(result?.data).toMatchObject({ notStarted: true })
const waited = (result?.at ?? 0) - issuedAt
expect(waited).toBeGreaterThanOrEqual(PICKUP_GRACE_MS - 1_000)
expect(waited).toBeLessThan(PICKUP_GRACE_MS + 20_000)
const [call] = await db.toolCalls(user.chats['Browser chat'])
expect(call).toMatchObject({ toolName: 'browser_list_tabs', status: 'failed', claimedBy: null })
})
test('leaving the chat does not end a local file read already under way', async () => {
const user = await db.seedUser(['Read chat', 'Other chat'])
const marker = generateId()
const file = writeFile(join(scratch, 'notes.txt'), `notes from disk ${marker}`)
let callId = ''
agent.script('[leave-read]', (turn) => {
callId = turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
turn.pause()
})
const page = await openApp(user, 'Read chat')
// Visit the other chat once, so leaving for it later is a quick client-side switch.
await openChat(page, user, 'Other chat')
await openChat(page, user, 'Read chat')
const claim = proxy.hold(isDesktopClaim)
const since = Date.now()
await send(page, '[leave-read] read my notes')
await claim.arrival(ARRIVAL_MS, 'The read’s claim')
const turnStream = proxy.seen(since).find(isChatStream)
if (!turnStream) throw new Error('The turn’s stream never reached Sim')
await click(page, page.getByRole('link', { name: 'Other chat' }).first())
// The view let go of the turn's stream, the moment a read tied to that view would end.
// Electron gives the held claim 8 s, so this waits only a few.
await expect.poll(() => turnStream.clientClosedAt, { timeout: 6_000 }).toBeDefined()
claim.release()
await expect(page).toHaveURL(new RegExp(`${user.chats['Other chat']}$`), { timeout: 30_000 })
await agent.waitForResume(() => Boolean(callId && agent.resultFor(callId)), 60_000)
const result = agent.resultFor(callId)
expect(result?.success).toBe(true)
expect(JSON.stringify(result?.data)).toContain(marker)
const [call] = await db.toolCalls(user.chats['Read chat'])
expect(call).toMatchObject({ toolName: 'read_local_file', status: 'completed' })
})
test('an import keeps running across chat switches, and Stop from the reopened chat ends it', async () => {
const user = await db.seedUser(['Import chat', 'Other chat'])
const chatId = user.chats['Import chat']
const source = importSource()
agent.script('[stop-import]', (turn) => {
turn.toolCall({
toolName: 'import_local_files',
args: { path: source, targetWorkspaceId: user.workspaceId },
})
turn.pause()
})
const page = await openApp(user, 'Import chat')
const firstUpload = proxy.hold(isUploadStart)
await send(page, '[stop-import] import my reports')
await firstUpload.arrival(ARRIVAL_MS, 'The first upload')
await openChat(page, user, 'Other chat')
await openChat(page, user, 'Import chat')
const laterFolder = proxy.hold(isFolderCreate)
firstUpload.release()
// The import outlived both view changes: its first file landed.
await expect
.poll(
async () =>
`${(await db.workspaceFileNames(user.workspaceId)).join(',')} | ${await callState(chatId)}`,
{ timeout: 60_000 }
)
.toMatch(/^a\.txt /)
await laterFolder.arrival(ARRIVAL_MS, 'The next folder')
const stoppedAt = Date.now()
await click(page, page.getByRole('button', { name: 'Stop generation' }))
// Stop cancels the import's request in flight, which ends the import, and records the call as
// cancelled; the stopped import reports nothing that could contest that record.
await expect.poll(() => laterFolder.isAbandoned, { timeout: 15_000 }).toBe(true)
await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^cancelled/)
laterFolder.release()
expect(proxy.seen(stoppedAt).filter((entry) => isToolReport(entry.method, entry.path))).toEqual(
[]
)
const [call] = await db.toolCalls(chatId)
expect(call).toMatchObject({ toolName: 'import_local_files', status: 'cancelled' })
expect(await db.workspaceFolderNames(user.workspaceId)).not.toContain('later')
expect(await db.workspaceFileNames(user.workspaceId)).toEqual(['a.txt'])
})
/** An import whose first upload the proxy holds until released, as a large file's would take. */
async function slowImport(user: SeededUser, title: string, marker: string) {
const source = importSource()
let callId = ''
agent.script(marker, (turn) => {
callId = turn.toolCall({
toolName: 'import_local_files',
args: { path: source, targetWorkspaceId: user.workspaceId },
})
turn.pause()
})
const page = await openApp(user, title)
const firstUpload = proxy.hold(isUploadStart)
await send(page, `${marker} import my reports`)
await firstUpload.arrival(ARRIVAL_MS, 'The first upload')
return { page, firstUpload, callId: () => callId }
}
test('an import that runs longer than the default tool budget still completes', async () => {
test.setTimeout(420_000)
const user = await db.seedUser(['Long import chat', 'Other chat'])
const chatId = user.chats['Long import chat']
const { page, firstUpload, callId } = await slowImport(
user,
'Long import chat',
'[long-import]'
)
// The import is alive and working past the 60 s default budget and its 30 s grace, while the
// user is in another chat: its lease is renewed by the import, not by the chat view.
await openChat(page, user, 'Other chat')
await page.waitForTimeout(LONG_IMPORT_MS)
expect(agent.resultFor(callId())).toBeUndefined()
firstUpload.release()
await agent.waitForResume(() => Boolean(agent.resultFor(callId())), 120_000)
const result = agent.resultFor(callId())
expect(JSON.stringify(result?.data)).not.toContain('outcomeUnknown')
expect(result?.success).toBe(true)
await expect
.poll(() => db.workspaceFileNames(user.workspaceId), { timeout: 30_000 })
.toEqual(['a.txt', 'b.txt'])
expect(await callState(chatId)).toMatch(/^completed/)
})
test('a long import whose window crashed settles as outcome unknown about one lease later', async () => {
test.setTimeout(420_000)
const user = await db.seedUser(['Crashing import chat'])
const { callId } = await slowImport(user, 'Crashing import chat', '[crash-import]')
await sleep(LONG_IMPORT_MS)
expect(agent.resultFor(callId())).toBeUndefined()
// A crash reports nothing on its way out, unlike a closed window: only the lapse of the
// lease the page was renewing tells Sim the import is gone.
const crashedAt = Date.now()
await app?.evaluate(({ webContents }) => {
for (const contents of webContents.getAllWebContents())
if (contents.getURL().includes('/workspace/')) contents.forcefullyCrashRenderer()
})
await agent.waitForResume(() => Boolean(agent.resultFor(callId())), 150_000)
const result = agent.resultFor(callId())
expect(result?.data).toMatchObject({ outcomeUnknown: true })
expect((result?.at ?? 0) - crashedAt).toBeLessThan(LEASE_MS + 20_000)
})
test('signing out ends a desktop tool still running', async () => {
const user = await db.seedUser(['Import chat'])
const chatId = user.chats['Import chat']
const source = importSource()
agent.script('[sign-out-import]', (turn) => {
turn.toolCall({
toolName: 'import_local_files',
args: { path: source, targetWorkspaceId: user.workspaceId },
})
turn.pause()
})
const page = await openApp(user, 'Import chat')
const firstUpload = proxy.hold(isUploadStart)
await send(page, '[sign-out-import] import my reports')
await firstUpload.arrival(ARRIVAL_MS, 'The first upload')
// The desktop reloads into the login page once signing out completes, which would end any
// tool; holding the sign-out shows the tool ends at sign-out itself, not at that reload.
const signOut = proxy.hold((method, path) => method === 'POST' && path === '/api/auth/sign-out')
await click(page, page.getByRole('button', { name: user.name }))
await click(page, page.getByRole('menuitem', { name: 'Sign out' }))
await signOut.arrival(ARRIVAL_MS, 'The sign-out')
await expect.poll(() => firstUpload.isAbandoned, { timeout: 15_000 }).toBe(true)
const abandonedAt = Date.now()
signOut.release()
// The reload into the login page replaces the document, so nothing of the import can run after.
await expect(page).toHaveURL(/\/login/, { timeout: 30_000 })
firstUpload.release()
// Between sign-out and that reload the cancelled import sent nothing more, not even a report.
const toolRequests = proxy
.seen(abandonedAt)
.filter(
(entry) =>
isUploadStart(entry.method, entry.path) ||
isFolderCreate(entry.method, entry.path) ||
isToolReport(entry.method, entry.path)
)
expect(toolRequests).toEqual([])
expect(await callState(chatId)).toMatch(/^running/)
expect(await db.workspaceFileNames(user.workspaceId)).toEqual([])
expect(await db.workspaceFolderNames(user.workspaceId)).not.toContain('later')
})
/** Sends `message` and returns the `toolName` call Sim persisted for it, held for approval. */
async function gatedCall(page: Page, chatId: string, message: string, toolName: string) {
await send(page, message)
await expect
.poll(async () => (await db.toolCalls(chatId)).some((call) => call.toolName === toolName), {
timeout: 60_000,
})
.toBe(true)
const call = (await db.toolCalls(chatId)).find((entry) => entry.toolName === toolName)
if (!call) throw new Error(`Missing ${toolName} call`)
expect(call.status).toBe('pending')
expect(call.permissionRequestedAt).not.toBeNull()
return call
}
test("a local read awaiting the user's approval cannot be claimed", async () => {
const user = await db.seedUser(['Approval chat'])
const secret = generateId()
const file = writeFile(join(scratch, 'secret.txt'), `private ${secret}`)
agent.script('[unapproved-read]', (turn) => {
turn.toolCall({
toolName: 'read_local_file',
args: { path: file },
status: 'awaiting_approval',
})
turn.pause()
})
const page = await openApp(user, 'Approval chat')
const chatId = user.chats['Approval chat']
const read = await gatedCall(
page,
chatId,
'[unapproved-read] read my secret',
'read_local_file'
)
// A renderer acting on a call the user has not allowed (a replayed event) is refused.
const response = await page.evaluate(
(toolCallId) =>
(globalThis as DesktopWindow).simDesktop.localFiles?.({ operation: 'read', toolCallId }),
read.toolCallId
)
expect(response?.ok).toBe(false)
expect(JSON.stringify(response)).not.toContain(secret)
const [after] = await db.toolCalls(chatId)
expect(after).toMatchObject({ status: 'pending', claimedBy: null })
})
test("a terminal command awaiting the user's approval cannot be claimed", async () => {
const user = await db.seedUser(['Approval chat'])
const command = `touch '${join(scratch, 'terminal-ran')}'`
agent.script('[unapproved-run]', (turn) => {
turn.toolCall({ toolName: 'terminal', args: { operation: 'run', command } })
turn.pause()
})
const page = await openApp(user, 'Approval chat')
const chatId = user.chats['Approval chat']
const run = await gatedCall(page, chatId, '[unapproved-run] run a command', 'terminal')
// The guard is the claim: Sim must not hand an unapproved command to the desktop, so the call
// stays pending and unclaimed. Whether a handed-over command then runs depends on a terminal
// being open for the chat, which this test does not set up, so it checks the claim only.
const response = await page.evaluate(
({ toolCallId, command }) =>
(globalThis as DesktopWindow).simDesktop.terminal
.executeTool(toolCallId, 'run', { command }, 'unapproved-e2e')
.catch((error: unknown) => ({ ok: false, error: String(error) })),
{ toolCallId: run.toolCallId, command }
)
expect(response).toMatchObject({ ok: false })
const [after] = await db.toolCalls(chatId)
expect(after).toMatchObject({ status: 'pending', claimedBy: null })
})
test('a claim that reaches Sim after the user pressed Stop is refused', async () => {
const user = await db.seedUser(['Stop chat'])
const file = writeFile(join(scratch, 'notes.txt'), 'notes')
agent.script('[stopped-claim]', (turn) => {
turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
turn.pause()
})
const page = await openApp(user, 'Stop chat')
const chatId = user.chats['Stop chat']
// Delivered to Sim on release even should Electron have given up on it meanwhile.
const lateClaim = proxy.hold(isDesktopClaim, { deliverIfAbandoned: true })
await send(page, '[stopped-claim] read my secret')
const claim = await lateClaim.arrival(ARRIVAL_MS, 'The read’s claim')
await click(page, page.getByRole('button', { name: 'Stop generation' }))
// Stop settles the call nobody has claimed yet as never started.
await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^cancelled/)
lateClaim.release()
// The claim held across Stop reaches Sim after it and is refused, and so is a replay of it.
await expect.poll(() => claim.status, { timeout: 15_000 }).toBe(410)
const [call] = await db.toolCalls(chatId)
const replay = await page.evaluate(
(toolCallId) =>
(globalThis as DesktopWindow).simDesktop.localFiles?.({ operation: 'read', toolCallId }),
call.toolCallId
)
expect(replay?.ok).toBe(false)
const answered = () =>
proxy.seen(claim.at, '/api/desktop/tool/authorize').filter((entry) => entry.status)
await expect.poll(() => answered().length, { timeout: 15_000 }).toBeGreaterThan(0)
for (const entry of answered()) expect(entry.status).toBe(410)
const [after] = await db.toolCalls(chatId)
expect(after).toMatchObject({ status: 'cancelled', claimedBy: null })
})
test('where Sim cannot run the background executor, the app stays dormant and a desktop round trip runs in the chat view', async () => {
const user = await db.seedUser(['Round trip'])
const marker = generateId()
const file = writeFile(join(scratch, 'plan.txt'), `plan ${marker}`)
const monitor = new RedisMonitor(sim.redisUrl)
await monitor.start()
try {
let callId = ''
agent.script(
'[round-trip]',
(turn) => {
turn.text('Reading the plan.')
callId = turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
turn.pause()
},
(resume, turn) => {
const result = resume.results.find((entry) => entry.callId === callId)
turn.complete(
JSON.stringify(result?.data).includes(marker) ? 'The plan says go.' : 'Could not read.'
)
}
)
const since = Date.now()
const page = await openApp(user, 'Round trip')
await send(page, '[round-trip] what does my plan say?')
// Each layer is checked on its own (soft), so a regression shows every layer it reaches:
// the round trip, the turn's binding, the routes the app calls, and the doorbell.
await expect
.soft(page.getByText('The plan says go.'), 'foreground round trip')
.toBeVisible({ timeout: 60_000 })
expect.soft(agent.resultFor(callId)?.success, 'read result').toBe(true)
// The app registers once signed out (refused) and again on sign-in.
const registeredSignedIn = () =>
proxy
.seen(since, REGISTRATION_PATH)
.some((entry) => entry.method === 'POST' && entry.status === 200)
await expect.poll(registeredSignedIn, { timeout: 30_000 }).toBe(true)
const runs = await db.runs(user.chats['Round trip'])
expect(runs.length).toBeGreaterThan(0)
expect
.soft(
runs.map((run) => run.desktopDeviceId),
'turn binding'
)
.toEqual(runs.map(() => null))
const [call] = await db.toolCalls(user.chats['Round trip'])
expect
.soft(call, 'foreground call')
.toMatchObject({ toolName: 'read_local_file', status: 'completed' })
expect.soft(call?.persistSeq, 'persist order').not.toBeNull()
// Only registration and the foreground claim: no inbox, doorbell stream, executor claim,
// lease or completion. The workspace's activity poll is Sim's page, not the app: this Sim
// has Redis, so its page asks.
const desktopRoutes = new Set(proxy.seen(since, '/api/desktop/').map((entry) => entry.path))
desktopRoutes.delete(REGISTRATION_PATH)
desktopRoutes.delete('/api/desktop/activity')
expect
.soft(desktopRoutes, 'desktop routes the app called')
.toEqual(new Set(['/api/desktop/tool/authorize']))
expect(monitor.lines.length).toBeGreaterThan(0)
expect.soft(monitor.publishesTo('desktop:inbox'), 'doorbell').toEqual([])
} finally {
monitor.stop()
}
})
test('in the background, a call issued after the user switched chats runs on the desktop', async () => {
proxy.rewriteAnswer(REGISTRATION_PATH, undefined)
const user = await db.seedUser(['Background chat', 'Other chat'])
const marker = generateId()
const file = writeFile(join(scratch, 'notes.txt'), `notes from disk ${marker}`)
let issue!: () => void
const issued = new Promise<void>((resolve) => {
issue = resolve
})
let callId = ''
let issuedAt = 0
agent.script('[background-read]', async (turn) => {
turn.text('Reading your notes.')
await issued
callId = turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
issuedAt = Date.now()
turn.pause()
})
const page = await openApp(user, 'Background chat')
await send(page, '[background-read] read my notes')
await expect(page.getByText('Reading your notes.')).toBeVisible({ timeout: 60_000 })
await openChat(page, user, 'Other chat')
issue()
await agent.waitForResume(() => Boolean(callId && agent.resultFor(callId)), 60_000)
const result = agent.resultFor(callId)
expect(result?.success).toBe(true)
expect(JSON.stringify(result?.data)).toContain(marker)
// No pickup grace: the desktop, not the chat view the user left, ran it.
expect((result?.at ?? 0) - issuedAt).toBeLessThan(PICKUP_GRACE_MS)
const chatId = user.chats['Background chat']
const runs = await db.runs(chatId)
expect(runs.some((run) => run.desktopDeviceId !== null)).toBe(true)
const [call] = await db.toolCalls(chatId)
expect(call).toMatchObject({ toolName: 'read_local_file', status: 'completed' })
expect(proxy.seen(issuedAt, '/api/desktop/tool/authorize')).toEqual([])
})
test('in the background, a result reported across a network cut reaches the agent exactly once', async () => {
proxy.rewriteAnswer(REGISTRATION_PATH, undefined)
const user = await db.seedUser(['Cut chat'])
const chatId = user.chats['Cut chat']
const marker = generateId()
const file = writeFile(join(scratch, 'notes.txt'), `notes from disk ${marker}`)
let callId = ''
agent.script('[network-cut]', (turn) => {
callId = turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
turn.pause()
})
const page = await openApp(user, 'Cut chat')
// Reaches Sim on release although the cut made the app give up on it, as a report already on
// the wire would: the app cannot know it landed, so it reports again once back online.
const completion = proxy.hold(isDesktopCompletion, { deliverIfAbandoned: true })
const answers = proxy.recordAnswers(DESKTOP_COMPLETION_PATH)
await send(page, '[network-cut] read my notes')
await completion.arrival(ARRIVAL_MS, 'The result report')
proxy.cutNetwork()
await expect.poll(() => completion.isAbandoned, { timeout: 15_000 }).toBe(true)
completion.release()
await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^completed/)
// The late report alone resumed the agent while the app was still offline.
await agent.waitForResume(() => Boolean(agent.resultFor(callId)), 60_000)
const restoredAt = Date.now()
proxy.restoreNetwork()
// Back online, the app reopens its doorbell and reports the result again.
await expect
.poll(() => proxy.seen(restoredAt, '/api/desktop/inbox/stream').length, { timeout: 60_000 })
.toBeGreaterThan(0)
const retried = () =>
proxy
.seen(restoredAt)
.filter((entry) => isDesktopCompletion(entry.method, entry.path) && entry.status)
await expect.poll(() => retried().length, { timeout: 60_000 }).toBeGreaterThan(0)
for (const entry of retried()) expect(entry.status).toBeLessThan(300)
await proxy.settled(30_000)
// Sim had already recorded the late report, so every retry the app heard back from is a no-op.
expect(answers.length).toBeGreaterThan(0)
expect(answers).toEqual(answers.map(() => expect.objectContaining({ outcome: 'duplicate' })))
const delivered = agent.resumes.filter((resume) =>
resume.results.some((entry) => entry.callId === callId)
)
expect(delivered).toHaveLength(1)
expect(JSON.stringify(agent.resultFor(callId)?.data)).toContain(marker)
const [call] = await db.toolCalls(chatId)
expect(call).toMatchObject({ toolName: 'read_local_file', status: 'completed' })
})
})
+523
View File
@@ -0,0 +1,523 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtempSync, readFileSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import type { Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
type ElectronApplication,
_electron as electron,
expect,
type Page,
} from '@playwright/test'
/**
* A fixture Sim that speaks the background executor's device protocol (register, inbox,
* doorbell, claim, lease, complete, import) the way Sim's routes do, and the helpers the
* executor's Electron suites share.
*/
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
export const WORKSPACE = 'ws-e2e'
const LEASE_RENEW_MS = 1_000
export const RECONCILE_MS = 2_000
type CallStatus = 'pending' | 'awaiting_approval' | 'running' | 'completed' | 'failed' | 'cancelled'
export interface Completion {
status: string
message: string
data?: Record<string, unknown>
outcome: 'recorded' | 'duplicate' | 'superseded'
at: number
}
export interface FixtureCall {
toolCallId: string
toolName: string
args: Record<string, unknown>
chatId: string
deviceId: string
status: CallStatus
issuedAt: number
claimedAt?: number
token?: string
claims: number
renewals: number
completions: Completion[]
acknowledged: boolean
}
export interface ImportedEntry {
toolCallId: string
kind: string
sourceName: string
relativePath: string
sha256?: string
bytes?: number
}
export function sha256(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex')
}
export class FixtureSim {
readonly calls = new Map<string, FixtureCall>()
readonly devices = new Map<string, { name: string; platform: string }>()
readonly requests: string[] = []
readonly hits = new Map<string, number>()
readonly streams = new Map<string, Set<ServerResponse>>()
readonly imported: ImportedEntry[] = []
enabled = true
/** Connections reset while the network was cut. */
droppedWhileOffline = 0
private offline = false
private readonly sockets = new Set<Socket>()
private server: Server | null = null
origin = ''
private nextToken = 0
async start(): Promise<void> {
this.server = createServer((request, response) => {
void this.handle(request, response)
})
this.server.on('connection', (socket) => {
if (this.offline) {
this.droppedWhileOffline += 1
socket.destroy()
return
}
this.sockets.add(socket)
socket.on('close', () => this.sockets.delete(socket))
})
await new Promise<void>((resolve) => this.server?.listen(0, '127.0.0.1', resolve))
const address = this.server.address()
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
this.origin = `http://127.0.0.1:${address.port}`
}
async stop(): Promise<void> {
for (const streams of this.streams.values()) for (const stream of streams) stream.destroy()
this.server?.closeAllConnections()
await new Promise<void>((resolve) => this.server?.close(() => resolve()))
}
/**
* Cuts the network between the app and Sim: every open connection drops, the doorbell stream and
* kept-alive request sockets included, and every new one is reset until {@link reconnect}.
*/
disconnect(): void {
this.offline = true
for (const socket of this.sockets) socket.destroy()
}
reconnect(): void {
this.offline = false
}
reset(): void {
this.calls.clear()
this.devices.clear()
this.requests.length = 0
this.hits.clear()
this.imported.length = 0
this.enabled = true
this.offline = false
this.droppedWhileOffline = 0
}
/** Persists a call for a device and rings its doorbell, as Sim's pre-persist and offer do. */
issue(
deviceId: string,
chatId: string,
toolName: string,
args: Record<string, unknown>,
status: CallStatus = 'pending'
): string {
const toolCallId = `call-${this.calls.size + 1}-${toolName}`
this.calls.set(toolCallId, {
toolCallId,
toolName,
args,
chatId,
deviceId,
status,
issuedAt: Date.now(),
claims: 0,
renewals: 0,
completions: [],
acknowledged: false,
})
this.ring(deviceId, status === 'awaiting_approval' ? 'approval' : 'call')
return toolCallId
}
/** Stop from any surface: Sim settles the call and tells the device to cancel. */
stopCall(toolCallId: string): void {
const call = this.requireCall(toolCallId)
call.status = 'cancelled'
this.ring(call.deviceId, 'cancel')
}
approve(toolCallId: string): void {
const call = this.requireCall(toolCallId)
call.status = 'pending'
this.ring(call.deviceId, 'approval')
}
/** The user declines a held call: Sim settles it without the device and rings again. */
decline(toolCallId: string): void {
const call = this.requireCall(toolCallId)
call.status = 'cancelled'
this.ring(call.deviceId, 'approval')
}
requireCall(toolCallId: string): FixtureCall {
const call = this.calls.get(toolCallId)
if (!call) throw new Error(`No fixture call ${toolCallId}`)
return call
}
ring(deviceId: string, reason: string): void {
for (const stream of this.streams.get(deviceId) ?? []) {
stream.write(`event: inbox_changed\ndata: ${JSON.stringify({ reason })}\n\n`)
}
}
private async raw(request: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk))
return Buffer.concat(chunks)
}
private async body(request: IncomingMessage): Promise<Record<string, unknown>> {
const text = (await this.raw(request)).toString()
return text ? (JSON.parse(text) as Record<string, unknown>) : {}
}
/** Stores one entry of a claimed, running import, as Sim's import route does. */
private async importEntry(
url: URL,
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
const query = Object.fromEntries(url.searchParams)
const call = this.calls.get(query.toolCallId ?? '')
if (
request.method !== 'PUT' ||
!call ||
call.deviceId !== query.deviceId ||
call.toolName !== 'import_local_files' ||
call.token !== request.headers['x-sim-execution-token'] ||
call.status !== 'running'
) {
this.json(response, 404, { error: 'Desktop import not found' })
return
}
// As Sim's route does: a file must declare its length, and arrive whole.
if (query.kind === 'file' && request.headers['content-length'] === undefined) {
this.json(response, 411, { error: 'A file import must declare its length' })
return
}
const content = await this.raw(request)
if (query.kind === 'file' && content.length !== Number(request.headers['content-length'])) {
this.json(response, 400, { error: 'The file did not arrive whole' })
return
}
const entry: ImportedEntry = {
toolCallId: call.toolCallId,
kind: query.kind ?? '',
sourceName: query.sourceName ?? '',
relativePath: query.relativePath ?? '',
...(query.kind === 'file' ? { sha256: sha256(content), bytes: content.length } : {}),
}
this.imported.push(entry)
this.json(response, 200, {
id: `entry-${this.imported.length}`,
name: entry.relativePath.split('/').at(-1) || entry.sourceName,
})
}
private json(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(body))
}
private signedIn(request: IncomingMessage): boolean {
return request.headers.cookie?.includes('better-auth.session_token=fixture') ?? false
}
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', this.origin)
const path = url.pathname
this.requests.push(`${request.method} ${path}`)
if (path === '/api/auth/get-session') {
this.json(
response,
200,
this.signedIn(request) ? { user: { id: 'user-e2e' }, session: { id: 'session-e2e' } } : null
)
return
}
if (path.startsWith('/api/desktop/') && !this.signedIn(request)) {
this.json(response, 401, { error: 'Unauthorized' })
return
}
if (path === '/api/desktop/devices' && request.method === 'POST') {
const body = await this.body(request)
if (this.enabled) {
this.devices.set(String(body.deviceId), {
name: String(body.name),
platform: String(body.platform),
})
}
this.json(response, 200, {
enabled: this.enabled,
protocolVersion: 1,
leaseMs: 60_000,
leaseRenewMs: LEASE_RENEW_MS,
reconcileMs: RECONCILE_MS,
})
return
}
const deviceId = url.searchParams.get('deviceId')
if (path === '/api/desktop/inbox/stream') {
if (!deviceId || !this.devices.has(deviceId)) {
this.json(response, 401, { error: 'Unregistered' })
return
}
response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' })
response.write(': connected\n\n')
const streams = this.streams.get(deviceId) ?? new Set<ServerResponse>()
streams.add(response)
this.streams.set(deviceId, streams)
request.on('close', () => streams.delete(response))
return
}
if (path === '/api/desktop/inbox') {
if (!deviceId || !this.devices.has(deviceId)) {
this.json(response, 401, { error: 'Unregistered' })
return
}
const items = [...this.calls.values()]
.filter((call) => call.deviceId === deviceId)
.flatMap((call) => {
if (call.status === 'pending' && !call.token)
return [
{
kind: 'call',
toolCallId: call.toolCallId,
toolName: call.toolName,
chatId: call.chatId,
workspaceId: WORKSPACE,
createdAt: new Date(call.issuedAt).toISOString(),
},
]
if (call.status === 'awaiting_approval')
return [
{
kind: 'approval_needed',
toolCallId: call.toolCallId,
toolName: call.toolName,
chatId: call.chatId,
chatTitle: 'Fix CI',
workspaceId: WORKSPACE,
summary: String((call.args.args as { command?: string } | undefined)?.command),
},
]
if (call.token && call.status === 'cancelled' && !call.acknowledged)
return [{ kind: 'cancel', toolCallId: call.toolCallId }]
return []
})
this.json(response, 200, { items })
return
}
if (path === '/api/desktop/tool/import') {
await this.importEntry(url, request, response)
return
}
if (path.startsWith('/api/desktop/tool/')) {
const body = await this.body(request)
const call = this.calls.get(String(body.toolCallId))
if (!call || call.deviceId !== body.deviceId) {
this.json(response, 404, { error: 'Desktop tool call not found' })
return
}
if (path === '/api/desktop/tool/claim') {
if (call.status !== 'pending' || call.token) {
this.json(response, 404, { error: 'This call is no longer waiting for this device' })
return
}
call.claims += 1
call.status = 'running'
call.claimedAt = Date.now()
call.token = `token-${++this.nextToken}`
this.json(response, 200, {
toolName: call.toolName,
args: call.args,
chatId: call.chatId,
workspaceId: WORKSPACE,
executionToken: call.token,
})
return
}
if (body.executionToken !== call.token) {
this.json(response, 404, { error: 'Desktop tool call not found' })
return
}
if (path === '/api/desktop/tool/lease') {
call.renewals += 1
if (call.status !== 'running') {
this.json(response, 410, { error: 'This call was stopped or settled. Stop running it.' })
return
}
this.json(response, 200, { renewed: true })
return
}
if (path === '/api/desktop/tool/complete') {
const status = String(body.status)
const settled =
status === 'success' ? 'completed' : status === 'cancelled' ? 'cancelled' : 'failed'
const outcome =
call.status === 'running'
? 'recorded'
: call.completions.length > 0
? 'duplicate'
: 'superseded'
if (outcome === 'recorded') call.status = settled
if (call.status === 'cancelled') call.acknowledged = true
call.completions.push({
status,
message: String(body.message ?? ''),
...(body.data && typeof body.data === 'object'
? { data: body.data as Record<string, unknown> }
: {}),
outcome,
at: Date.now(),
})
this.json(response, 200, {
outcome,
status: call.status === 'running' ? settled : call.status,
})
return
}
}
if (path === '/hit') {
const chat = url.searchParams.get('chat') ?? ''
this.hits.set(chat, (this.hits.get(chat) ?? 0) + 1)
this.json(response, 200, { count: this.hits.get(chat) })
return
}
if (path === '/session-ended') {
// Another account signing in replaces the session: the old session cookie is cleared.
response.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'better-auth.session_token=; Max-Age=0; HttpOnly; SameSite=Lax; Path=/',
})
response.end('<!doctype html><title>Signed in elsewhere</title>')
return
}
response.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
})
response.end(
path === '/counter'
? `<!doctype html><title>Counter ${url.searchParams.get('chat')}</title>
<button onclick="fetch('/hit?chat=${url.searchParams.get('chat')}').then(r => r.json()).then(b => { document.getElementById('count').textContent = String(b.count) })">Count visit</button>
<p id="count">0</p>`
: `<!doctype html><title>Sim fixture</title><h1>${path}</h1>`
)
}
}
/**
* Launches the app against the fixture Sim. Its shells are zsh with an empty config directory, so
* what they do is the app's and not this machine's: a developer's `.zshrc` can stop at a question
* (oh-my-zsh asks before updating) and hold the first prompt for as long as nobody answers it.
* The terminal's handling of slow and stalled startup files has its own tests.
*/
export async function launch(
sim: FixtureSim,
userData: string,
env: Record<string, string> = {}
): Promise<{ app: ElectronApplication; window: Page }> {
const app = await electron.launch({
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
cwd: DESKTOP_DIR,
env: {
...process.env,
SHELL: '/bin/zsh',
ZDOTDIR: mkdtempSync(join(tmpdir(), 'sim-e2e-zdotdir-')),
SIM_DESKTOP_ORIGIN: sim.origin,
SIM_DESKTOP_USER_DATA: userData,
...env,
},
})
// A dialog listener stops Playwright auto-dismissing page dialogs, so the desktop's own handling
// decides their outcome exactly as it does in production.
const leaveDialogsToDesktop = (page: Page) => page.on('dialog', () => {})
app.context().pages().forEach(leaveDialogsToDesktop)
app.context().on('page', leaveDialogsToDesktop)
const window = await app.firstWindow()
return { app, window }
}
/** The device this app registered, once it has opened its doorbell. */
export async function registeredDevice(
sim: FixtureSim,
knownDevices: ReadonlySet<string> = new Set()
): Promise<string> {
let deviceId: string | undefined
await expect
.poll(
() => {
deviceId = [...sim.devices.keys()].find(
(id) => !knownDevices.has(id) && (sim.streams.get(id)?.size ?? 0) > 0
)
return deviceId
},
{ timeout: 30_000 }
)
.toBeTruthy()
return deviceId as string
}
export async function settled(
sim: FixtureSim,
toolCallId: string,
timeout = 60_000
): Promise<Completion> {
await expect
.poll(() => sim.requireCall(toolCallId).completions.length, { timeout })
.toBeGreaterThan(0)
return sim.requireCall(toolCallId).completions[0] as Completion
}
export function refFor(outline: string, name: string): number {
const match = outline
.split('\n')
.find((line) => line.includes(`"${name}"`) && /\[ref=\d+\]/.test(line))
?.match(/\[ref=(\d+)\]/)
if (!match) throw new Error(`No reference for ${name}: ${outline}`)
return Number(match[1])
}
export function processRunning(pattern: string): boolean {
try {
execFileSync('pgrep', ['-f', pattern])
return true
} catch {
return false
}
}
export function readFileSafe(path: string): string {
try {
return readFileSync(path, 'utf8')
} catch {
return ''
}
}
+794
View File
@@ -0,0 +1,794 @@
import { createHmac } from 'node:crypto'
import {
createServer,
request as httpRequest,
type IncomingHttpHeaders,
type IncomingMessage,
type Server,
type ServerResponse,
} from 'node:http'
import { connect, type Socket } from 'node:net'
import type { Duplex } from 'node:stream'
import { sleep } from '@sim/utils/helpers'
import { generateId, generateShortId } from '@sim/utils/id'
import { toArray } from '@sim/utils/object'
import postgres from 'postgres'
/**
* A real local Sim app for the desktop E2E suite: the Electron app talks to it through a
* recording proxy, and Sim talks to a scripted agent standing in for the Mothership worker at
* `SIM_AGENT_API_URL`. Both sides of the desktop tools are the shipped code; only the model's
* decisions are scripted.
*
* Start Sim (for example `next dev`) with `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` set to the
* proxy's origin, `SIM_AGENT_API_URL` set to the agent's, `REDIS_URL`, and
* `COPILOT_TOOL_PERMISSIONS_ENABLED=true`, then provide the environment `liveSimConfig` reads.
*/
export interface LiveSimConfig {
/** Where Sim itself listens. */
upstream: string
/** The proxy's port: the origin Sim and Electron both use. */
proxyPort: number
/** The scripted agent's port. */
agentPort: number
databaseUrl: string
redisUrl: string
/** Sim's `BETTER_AUTH_SECRET`, to sign the session cookie of a seeded session. */
authSecret: string
}
/** The live Sim this run was given, or why the suite is skipped. */
export function liveSimConfig(): LiveSimConfig | string {
const env = process.env
const missing = [
'SIM_DESKTOP_E2E_SIM_URL',
'SIM_DESKTOP_E2E_PROXY_PORT',
'SIM_DESKTOP_E2E_AGENT_PORT',
'SIM_DESKTOP_E2E_DATABASE_URL',
'SIM_DESKTOP_E2E_REDIS_URL',
'SIM_DESKTOP_E2E_AUTH_SECRET',
].filter((name) => !env[name])
if (missing.length > 0) return `Needs a local Sim app: set ${missing.join(', ')}`
const databaseUrl = env.SIM_DESKTOP_E2E_DATABASE_URL ?? ''
if (!/(?:^|[_-])test$/i.test(new URL(databaseUrl).pathname.slice(1)))
return 'SIM_DESKTOP_E2E_DATABASE_URL must name a dedicated test database'
return {
upstream: env.SIM_DESKTOP_E2E_SIM_URL ?? '',
proxyPort: Number(env.SIM_DESKTOP_E2E_PROXY_PORT),
agentPort: Number(env.SIM_DESKTOP_E2E_AGENT_PORT),
databaseUrl,
redisUrl: env.SIM_DESKTOP_E2E_REDIS_URL ?? '',
authSecret: env.SIM_DESKTOP_E2E_AUTH_SECRET ?? '',
}
}
async function readBody(request: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
for await (const chunk of request)
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
return Buffer.concat(chunks)
}
function listen(server: Server, port: number): Promise<void> {
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
}
function close(server: Server): Promise<void> {
server.closeAllConnections()
return new Promise((resolve) => server.close(() => resolve()))
}
/** One request the proxy saw, from the renderer or Electron's main process. */
interface ProxiedRequest {
method: string
path: string
at: number
status?: number
/** When the client gave up on the response before it finished (a reader that let go). */
clientClosedAt?: number
}
/**
* A request the proxy holds before it reaches Sim, so a test can act while a tool's request is
* in flight. A request whose client gave up while held is dropped, never forwarded, as a
* cancelled request on a real network would be.
*/
class HeldRequest {
readonly reached: Promise<ProxiedRequest>
/** Settles once the client gives up on the held request (it cancelled it). */
readonly abandoned: Promise<void>
isAbandoned = false
private reach!: (request: ProxiedRequest) => void
private abandon!: () => void
private releaseHeld: (() => void) | undefined
private released = false
constructor(
readonly matches: (method: string, path: string) => boolean,
/** Deliver the request to Sim on release even if its client gave up, as a late request would arrive. */
readonly deliverIfAbandoned = false
) {
this.reached = new Promise((resolve) => {
this.reach = resolve
})
this.abandoned = new Promise((resolve) => {
this.abandon = resolve
})
}
/** Called by the proxy when the matching request arrives; resolves when the test releases it. */
hold(request: ProxiedRequest, response: ServerResponse): Promise<boolean> {
response.once('close', () => {
if (response.writableFinished || this.released) return
this.isAbandoned = true
this.abandon()
})
this.reach(request)
return new Promise((resolve) => {
this.releaseHeld = () => resolve(!this.isAbandoned)
if (this.released) this.releaseHeld()
})
}
/** The held request, once it arrives; fails if it has not within `timeoutMs`. */
async arrival(timeoutMs: number, label: string): Promise<ProxiedRequest> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} did not arrive in ${timeoutMs} ms`)),
timeoutMs
)
})
try {
return await Promise.race([this.reached, timeout])
} finally {
clearTimeout(timer)
}
}
release(): void {
this.released = true
this.releaseHeld?.()
}
}
/**
* The origin Electron and Sim share. It forwards everything to Sim, records each request, can
* hold one until the test releases it, rewrite an answer or cut the network, and serves one
* test-only route that installs a seeded session's cookie the way Sim's own sign-in response
* would.
*/
export class SimProxy {
readonly requests: ProxiedRequest[] = []
readonly origin: string
private readonly server: Server
private holds: HeldRequest[] = []
/** Requests a hold is keeping from Sim right now. */
private readonly heldEntries = new Set<ProxiedRequest>()
private readonly answerRewrites = new Map<string, (body: Record<string, unknown>) => void>()
private readonly sockets = new Set<Duplex>()
/** Every client connection open now, HTTP and upgraded alike. */
private readonly connections = new Set<Socket>()
private networkCut = false
constructor(private readonly config: LiveSimConfig) {
this.origin = `http://127.0.0.1:${config.proxyPort}`
this.server = createServer((request, response) => {
void this.handle(request, response).catch((error: unknown) => {
if (!response.headersSent) response.writeHead(502)
response.end(String(error))
})
})
// While the network is cut, a new connection is reset as an unreachable host's would be.
this.server.on('connection', (socket: Socket) => {
if (this.networkCut) {
socket.destroy()
return
}
this.connections.add(socket)
socket.once('close', () => this.connections.delete(socket))
})
// Next's dev server pushes over a websocket; pass upgrades straight through.
this.server.on('upgrade', (request, socket, head) => {
const target = new URL(config.upstream)
const upstream = connect(Number(target.port), target.hostname, () => {
const lines = [`${request.method} ${request.url} HTTP/1.1`]
for (let i = 0; i < request.rawHeaders.length; i += 2)
lines.push(`${request.rawHeaders[i]}: ${request.rawHeaders[i + 1]}`)
upstream.write(`${lines.join('\r\n')}\r\n\r\n`)
upstream.write(head)
upstream.pipe(socket)
socket.pipe(upstream)
})
const client = socket
this.sockets.add(upstream)
this.sockets.add(client)
const end = () => {
upstream.destroy()
client.destroy()
this.sockets.delete(upstream)
this.sockets.delete(client)
}
for (const side of [upstream, client]) {
side.on('error', end)
side.on('close', end)
}
})
}
start(): Promise<void> {
return listen(this.server, this.config.proxyPort)
}
async stop(): Promise<void> {
for (const socket of this.sockets) socket.destroy()
await close(this.server)
}
/** Holds the next request that matches until the returned handle releases it. */
hold(
matches: (method: string, path: string) => boolean,
options: { deliverIfAbandoned?: boolean } = {}
): HeldRequest {
const held = new HeldRequest(matches, options.deliverIfAbandoned)
this.holds.push(held)
return held
}
/** Drops holds that never matched, so none outlives the test that set it. */
clearHolds(): void {
for (const held of this.holds) held.release()
this.holds = []
}
/**
* Rewrites Sim's JSON answer to `path`, as a Sim configured otherwise would answer it; `undefined`
* stops rewriting it.
*/
rewriteAnswer(
path: string,
rewrite: ((body: Record<string, unknown>) => void) | undefined
): void {
if (rewrite) this.answerRewrites.set(path, rewrite)
else this.answerRewrites.delete(path)
}
/**
* Sim's JSON answers to `path` that reach the app from now on, recorded as they pass and left
* unchanged. An answer to a request whose client gave up reaches no one, so it is not recorded.
*/
recordAnswers(path: string): Record<string, unknown>[] {
const answers: Record<string, unknown>[] = []
this.rewriteAnswer(path, (body) => {
answers.push(structuredClone(body))
})
return answers
}
/**
* Cuts the network between the app and Sim: every open connection drops mid-flight, the
* doorbell stream included, and new ones are reset until `restoreNetwork`.
*/
cutNetwork(): void {
this.networkCut = true
for (const socket of this.connections) socket.destroy()
}
restoreNetwork(): void {
this.networkCut = false
}
/**
* Resolves once every request the app made has been answered (held ones aside), staying so for
* a moment, which on a dev app means none is waiting on a route to compile.
*/
async settled(timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
let quietSince = Date.now()
for (;;) {
const waiting = this.requests.filter(
(entry) =>
entry.status === undefined &&
entry.clientClosedAt === undefined &&
!this.heldEntries.has(entry)
)
if (waiting.length > 0) quietSince = Date.now()
else if (Date.now() - quietSince >= 3_000) return
if (Date.now() > deadline)
throw new Error(
`Requests still unanswered: ${waiting.map((entry) => entry.path).join(', ')}`
)
await sleep(250)
}
}
/** The requests seen since `since`, optionally only those under a path prefix. */
seen(since = 0, prefix = '/'): ProxiedRequest[] {
return this.requests.filter((entry) => entry.at >= since && entry.path.startsWith(prefix))
}
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', this.origin)
const method = request.method ?? 'GET'
const entry: ProxiedRequest = { method, path: url.pathname, at: Date.now() }
this.requests.push(entry)
response.once('close', () => {
if (!response.writableFinished) entry.clientClosedAt = Date.now()
})
if (url.pathname === '/__e2e/sign-in') {
const cookie = url.searchParams.get('cookie') ?? ''
response.writeHead(302, {
'Set-Cookie': `better-auth.session_token=${cookie}; Path=/; HttpOnly; SameSite=Lax`,
Location: url.searchParams.get('to') ?? '/',
})
response.end()
entry.status = 302
return
}
const body = await readBody(request)
const held = this.holds.find((candidate) => candidate.matches(method, url.pathname))
if (held) {
this.holds = this.holds.filter((candidate) => candidate !== held)
this.heldEntries.add(entry)
const deliver = await held.hold(entry, response)
this.heldEntries.delete(entry)
if (!deliver && !held.deliverIfAbandoned) return
}
const target = new URL(url.pathname + url.search, this.config.upstream)
const rewriteAnswer = this.answerRewrites.get(url.pathname)
// The body is forwarded whole, so it is sent with a length rather than chunked. An answer to
// rewrite is asked for uncompressed.
const {
'transfer-encoding': _chunked,
'accept-encoding': acceptEncoding,
...forwarded
} = request.headers
const headers: IncomingHttpHeaders = {
...forwarded,
...(rewriteAnswer ? {} : { 'accept-encoding': acceptEncoding }),
'content-length': String(body.length),
}
await new Promise<void>((resolve, reject) => {
const clientGone = response.destroyed
const upstream = httpRequest(target, { method, headers }, (upstreamResponse) => {
entry.status = upstreamResponse.statusCode
upstreamResponse.on('end', resolve)
upstreamResponse.on('error', reject)
// A request delivered after its client gave up is answered to no one.
if (clientGone) {
upstreamResponse.resume()
return
}
if (rewriteAnswer && upstreamResponse.statusCode === 200) {
void readBody(upstreamResponse).then((raw) => {
const answer: Record<string, unknown> = JSON.parse(raw.toString('utf8'))
rewriteAnswer(answer)
const rewritten = Buffer.from(JSON.stringify(answer))
const { 'transfer-encoding': _chunked, ...answerHeaders } = upstreamResponse.headers
response.writeHead(200, {
...answerHeaders,
'content-length': String(rewritten.length),
})
response.end(rewritten)
}, reject)
return
}
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
upstreamResponse.pipe(response)
})
if (!clientGone) response.on('close', () => upstream.destroy())
upstream.on('error', reject)
upstream.end(body)
})
}
}
/** One tool call a scripted turn issues. */
interface ScriptedToolCall {
toolName: string
args: Record<string, unknown>
/** `awaiting_approval` asks Sim to hold the call for the user's decision. */
status?: 'awaiting_approval'
}
/** One result Sim sends back when it resumes the agent after a checkpoint. */
interface ResumedResult {
callId: string
name?: string
success?: boolean
data?: unknown
}
/** A resume request Sim sent, with when it arrived. */
interface Resume {
at: number
streamId: string
results: ResumedResult[]
}
/** A chat turn Sim opened against the agent, written to as the scripted model acts. */
class AgentTurn {
readonly toolCallIds: string[] = []
/** Settles once this leg's stream has ended, from either side. */
readonly closed: Promise<void>
private markClosed!: () => void
private seq = 0
private readonly keepAlive: ReturnType<typeof setInterval>
private ended = false
constructor(
readonly body: Record<string, unknown>,
readonly streamId: string,
private readonly response: ServerResponse
) {
this.closed = new Promise((resolve) => {
this.markClosed = resolve
})
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// The worker keeps an idle stream alive with SSE comments; so does this one.
this.keepAlive = setInterval(() => {
if (!this.ended) response.write(': keepalive\n\n')
}, 2_000)
response.on('close', () => this.finish())
this.emit('session', { kind: 'start' })
}
get message(): string {
return typeof this.body.message === 'string' ? this.body.message : ''
}
emit(type: string, payload: Record<string, unknown>): void {
if (this.ended) return
this.seq += 1
const chatId = typeof this.body.chatId === 'string' ? this.body.chatId : undefined
const envelope = {
v: 1,
type,
seq: this.seq,
ts: new Date().toISOString(),
stream: { streamId: this.streamId, ...(chatId ? { chatId } : {}), cursor: String(this.seq) },
trace: { requestId: `agent-${this.streamId}` },
payload,
}
this.response.write(`data: ${JSON.stringify(envelope)}\n\n`)
}
text(text: string): void {
this.emit('text', { channel: 'assistant', text })
}
/** Issues a client-executed tool call and returns its id. */
toolCall(call: ScriptedToolCall): string {
const toolCallId = `toolu_${generateShortId()}`
this.toolCallIds.push(toolCallId)
this.emit('tool', {
phase: 'call',
toolCallId,
toolName: call.toolName,
executor: 'client',
mode: 'async',
arguments: call.args,
ui: { clientExecutable: true },
...(call.status ? { status: call.status } : {}),
})
return toolCallId
}
/** Ends this leg waiting on the turn's tool calls, as the worker does at a checkpoint. */
pause(): void {
this.emit('run', {
kind: 'checkpoint_pause',
checkpointId: generateId(),
executionId: generateId(),
runId: generateId(),
pendingToolCallIds: [...this.toolCallIds],
})
this.finish()
}
complete(text?: string): void {
if (text) this.text(text)
this.emit('complete', { status: 'complete' })
this.finish()
}
private finish(): void {
if (this.ended) return
this.ended = true
clearInterval(this.keepAlive)
this.response.end()
this.markClosed()
}
}
type TurnScript = (turn: AgentTurn) => void | Promise<void>
type ResumeScript = (resume: Resume, turn: AgentTurn) => void | Promise<void>
/**
* Stands in for the Mothership worker at Sim's `SIM_AGENT_API_URL`: each chat turn runs the
* script registered for its message, and each resume after a checkpoint records the tool results
* Sim delivers, which is what the real model would read.
*/
export class ScriptedAgent {
readonly turns: AgentTurn[] = []
readonly resumes: Resume[] = []
readonly unexpected: string[] = []
private readonly server: Server
private readonly scripts = new Map<string, TurnScript>()
private readonly resumeScripts = new Map<string, ResumeScript>()
private readonly waiters: (() => void)[] = []
constructor(private readonly port: number) {
this.server = createServer((request, response) => {
void this.handle(request, response).catch((error: unknown) => {
this.unexpected.push(String(error))
if (!response.headersSent) response.writeHead(500)
response.end()
})
})
}
get url(): string {
return `http://127.0.0.1:${this.port}`
}
start(): Promise<void> {
return listen(this.server, this.port)
}
stop(): Promise<void> {
return close(this.server)
}
/**
* Runs `script` for the turn whose message contains `marker`; `onResume` answers each resume of
* that turn (by default the turn completes with a short reply).
*/
script(marker: string, script: TurnScript, onResume?: ResumeScript): void {
this.scripts.set(marker, script)
if (onResume) this.resumeScripts.set(marker, onResume)
}
/** Resolves with the first resume matching `predicate`, failing after `timeoutMs`. */
async waitForResume(predicate: (resume: Resume) => boolean, timeoutMs: number): Promise<Resume> {
const deadline = Date.now() + timeoutMs
for (;;) {
const found = this.resumes.find(predicate)
if (found) return found
const remaining = deadline - Date.now()
if (remaining <= 0) throw new Error(`No matching resume within ${timeoutMs} ms`)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, remaining)
this.waiters.push(() => {
clearTimeout(timer)
resolve()
})
})
}
}
/** The result Sim delivered for `toolCallId`, if any resume carried it. */
resultFor(toolCallId: string): (ResumedResult & { at: number }) | undefined {
for (const resume of this.resumes) {
const result = resume.results.find((entry) => entry.callId === toolCallId)
if (result) return { ...result, at: resume.at }
}
return undefined
}
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const path = new URL(request.url ?? '/', this.url).pathname
const raw = (await readBody(request)).toString('utf8')
const body: Record<string, unknown> = raw ? JSON.parse(raw) : {}
if (path === '/api/mothership' || path === '/api/copilot') {
const message = typeof body.message === 'string' ? body.message : ''
const streamId = typeof body.messageId === 'string' ? body.messageId : generateId()
const turn = new AgentTurn(body, streamId, response)
this.turns.push(turn)
const entry = [...this.scripts].find(([marker]) => message.includes(marker))
if (!entry) {
turn.complete('No script for this message.')
return
}
await entry[1](turn)
return
}
if (path === '/api/tools/resume') {
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
const results = toArray<ResumedResult>(body.results)
const resume: Resume = { at: Date.now(), streamId, results }
this.resumes.push(resume)
for (const wake of this.waiters.splice(0)) wake()
const turn = this.turns.find((candidate) => candidate.streamId === streamId)
const resumed = new AgentTurn(turn?.body ?? body, streamId, response)
const marker = turn
? [...this.resumeScripts.keys()].find((key) => turn.message.includes(key))
: undefined
const onResume = marker ? this.resumeScripts.get(marker) : undefined
if (onResume) await onResume(resume, resumed)
else resumed.complete('Done.')
return
}
if (path === '/api/generate-chat-title') {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ title: 'Desktop tools E2E' }))
return
}
// Stop, cleanup and every other worker callback: acknowledge.
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ settled: true }))
}
}
/** A seeded user signed in to Sim, with one workspace and named chats. */
export interface SeededUser {
userId: string
name: string
workspaceId: string
sessionId: string
/** The `better-auth.session_token` cookie value for the seeded session. */
cookie: string
chats: Record<string, string>
}
/** Direct database access for seeding and for asserting what Sim persisted. */
export class SimDatabase {
readonly sql: postgres.Sql
private readonly userIds: string[] = []
constructor(private readonly config: LiveSimConfig) {
this.sql = postgres(config.databaseUrl, { max: 4, onnotice: () => {} })
}
async close(): Promise<void> {
if (this.userIds.length > 0)
await this.sql`delete from "user" where id in ${this.sql(this.userIds)}`.catch(() => {})
await this.sql.end({ timeout: 5 })
}
/** A user with a workspace, a session as the desktop sign-in creates, and one chat per title. */
async seedUser(chatTitles: string[]): Promise<SeededUser> {
const userId = generateId()
const workspaceId = generateId()
const sessionId = generateId()
const token = generateShortId()
const name = `Desktop E2E ${userId.slice(0, 6)}`
const email = `${userId}@desktop-tools-e2e.test`
const chats: Record<string, string> = {}
await this.sql.begin(async (tx) => {
await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at)
values (${userId}, ${name}, ${email}, ${email}, true, now(), now())`
await tx`insert into user_stats (id, user_id) values (${generateId()}, ${userId})`
await tx`insert into workspace (id, name, owner_id, billed_account_user_id)
values (${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})`
await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type)
values (${generateId()}, ${userId}, 'workspace', ${workspaceId}, 'admin')`
await tx`insert into session (id, token, user_id, user_agent, expires_at, created_at, updated_at)
values (${sessionId}, ${token}, ${userId}, 'Sim Desktop', now() + interval '1 day', now(), now())`
for (const title of chatTitles) {
const chatId = generateId()
chats[title] = chatId
await tx`insert into copilot_chats (id, user_id, workspace_id, type, title)
values (${chatId}, ${userId}, ${workspaceId}, 'mothership', ${title})`
}
})
this.userIds.push(userId)
const signature = createHmac('sha256', this.config.authSecret).update(token).digest('base64')
return {
userId,
name,
workspaceId,
sessionId,
cookie: encodeURIComponent(`${token}.${signature}`),
chats,
}
}
/** The persisted desktop calls of a chat's runs, oldest first. */
toolCalls(chatId: string) {
return this.sql<
{
toolCallId: string
toolName: string
status: string
claimedBy: string | null
error: string | null
result: unknown
permissionRequestedAt: Date | null
persistSeq: number | null
runId: string
}[]
>`select c.tool_call_id as "toolCallId", c.tool_name as "toolName", c.status,
c.claimed_by as "claimedBy", c.error, c.result,
c.permission_requested_at as "permissionRequestedAt", c.persist_seq as "persistSeq",
c.run_id as "runId"
from copilot_async_tool_calls c join copilot_runs r on r.id = c.run_id
where r.chat_id = ${chatId} order by c.created_at`
}
runs(chatId: string) {
return this.sql<{ id: string; status: string; desktopDeviceId: string | null }[]>`
select id, status, desktop_device_id as "desktopDeviceId" from copilot_runs
where chat_id = ${chatId} order by created_at`
}
/** Names of the files a workspace holds. */
async workspaceFileNames(workspaceId: string): Promise<string[]> {
const rows = await this.sql<{ name: string }[]>`
select original_name as name from workspace_files
where workspace_id = ${workspaceId} and context = 'workspace' and deleted_at is null
order by original_name`
return rows.map((row) => row.name)
}
/** Names of the file folders a workspace holds. */
async workspaceFolderNames(workspaceId: string): Promise<string[]> {
const rows = await this.sql<{ name: string }[]>`
select name from folder where workspace_id = ${workspaceId} and resource_type = 'file'
order by name`
return rows.map((row) => row.name)
}
}
/**
* Records every command Sim sends Redis (`MONITOR`), so a test can assert that a turn rang no
* desktop doorbell. Speaks just enough RESP for that.
*/
export class RedisMonitor {
readonly lines: string[] = []
private socket: Socket | undefined
constructor(private readonly url: string) {}
async start(): Promise<void> {
const target = new URL(this.url)
const socket = connect(Number(target.port || 6379), target.hostname)
this.socket = socket
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve)
socket.once('error', reject)
})
// Each command sent before MONITOR answers `+OK`; MONITOR's own `+OK` means it is recording.
let pendingAcks = target.password ? 2 : 1
let buffer = ''
const acknowledged = new Promise<void>((resolve, reject) => {
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
const lines = buffer.split('\r\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (pendingAcks > 0) {
if (line.startsWith('-')) reject(new Error(`Redis refused MONITOR: ${line}`))
else if (line === '+OK' && --pendingAcks === 0) resolve()
continue
}
if (line.startsWith('+')) this.lines.push(line)
}
})
})
if (target.password) socket.write(`AUTH ${decodeURIComponent(target.password)}\r\n`)
socket.write('MONITOR\r\n')
await acknowledged
}
/** Commands seen that publish to a channel whose name contains `channel`. */
publishesTo(channel: string): string[] {
return this.lines.filter(
(line) => /"publish"/i.test(line) && line.toLowerCase().includes(channel.toLowerCase())
)
}
stop(): void {
this.socket?.destroy()
}
}
@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test'
/** Runs only the worker-restart fixture, for `check-report.spec.ts`. */
export default defineConfig({
testDir: '.',
testMatch: 'worker-restart.ts',
workers: 1,
retries: 0,
reporter: [['line']],
})
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test'
import { recordCheck } from '../check-report'
/**
* Two checks in one file, the first failing, run by `check-report.spec.ts` in a Playwright run of
* its own. The failure makes Playwright start a new worker for the second, as it does mid-suite.
*/
const reportPath = process.env.WORKER_RESTART_REPORT_PATH
function record(name: string, status: string): void {
recordCheck(reportPath, 'worker-restart', {
name,
status,
durationMs: 0,
error: `worker ${test.info().workerIndex}`,
})
}
test('fails first', () => {
record('fails first', 'failed')
expect('failed').toBe('passed')
})
test('passes after the worker restarts', () => {
record('passes after the worker restarts', 'passed')
})
+8 -3
View File
@@ -16,7 +16,8 @@ test('native file tools read and import through the installed preload without Si
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII='
writeFileSync(join(source, 'image.png'), Buffer.from(png, 'base64'))
let claimed = false
/** Calls the server saw claimed; like the server, only an import refuses a second claim. */
const claimed = new Set<string>()
const calls: Record<string, { toolName: string; args: Record<string, unknown> }> = {
text: { toolName: 'read_local_file', args: { path: join(source, 'report.txt') } },
image: { toolName: 'read_local_file', args: { path: join(source, 'image.png') } },
@@ -44,11 +45,14 @@ test('native file tools read and import through the installed preload without Si
for await (const chunk of request) body += chunk.toString()
const input = JSON.parse(body)
const call = calls[input.toolCallId]
if (!call || (input.claim && claimed)) {
if (
!call ||
(input.claim && call.toolName === 'import_local_files' && claimed.has(input.toolCallId))
) {
response.writeHead(call ? 409 : 403, { 'Content-Type': 'application/json' }).end('{}')
return
}
if (input.claim) claimed = true
if (input.claim) claimed.add(input.toolCallId)
response
.writeHead(200, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ ...call, chatId: 'org-chat' }))
@@ -92,6 +96,7 @@ test('native file tools read and import through the installed preload without Si
ok: true,
data: { observations: [{ mediaType: 'image/png', data: png }] },
})
expect([...claimed]).toEqual(['text', 'image'])
const result = await invoke({ operation: 'manifest', toolCallId: 'import' })
if (!result.ok || result.data.kind !== 'manifest') throw new Error(JSON.stringify(result))
expect(result.data.targetWorkspaceId).toBe('target-workspace')
+609
View File
@@ -0,0 +1,609 @@
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { type ElectronApplication, expect, type Page, test } from '@playwright/test'
import type { SimDesktopApi } from '@sim/desktop-bridge'
import { sleep } from '@sim/utils/helpers'
import { randomInt } from '@sim/utils/random'
import { recordCheck } from './check-report'
import { FixtureSim, launch, processRunning, registeredDevice, settled } from './executor-sim'
/**
* Stopping the agent's terminal commands in the real app, with real shells and a real tmux
* server: Stop, sign-out (by the web app's logout and by the session cookie going away), switching
* Terminal off, and the launch-time recovery of an interrupted sign-out. What is asserted is the
* machine's own state (which processes still run, which tmux panes still exist), never which
* calls were made.
*
* Agent commands in a plain shell ignore SIGHUP, SIGINT and SIGTERM, so closing the shell cannot
* end them by accident: only Sim's own stop, which escalates to SIGKILL on the command's process
* group, does. The user's commands ignore the same signals, so a stop that reached them would
* show. Each launch gets its own tmux server (TMUX_TMPDIR), which the app's shells and its tmux
* calls inherit. The tmux scenarios skip where tmux is not installed.
*/
const CHAT_PLAIN = 'chat-cancel-plain'
const CHAT_TMUX = 'chat-cancel-tmux'
const TMUX_SESSION = 'agent-e2e'
/** Commands that only a SIGKILL ends, so a shell closing around them proves nothing. */
const STUBBORN = "trap '' HUP INT TERM"
const sim = new FixtureSim()
/** The app's own log output for the current test, attached when it fails. */
const appOutput: string[] = []
/** The shells, sleeps and tmux processes running, for a failure about who started what. */
function processes(): string {
try {
return execFileSync('ps', ['-eo', 'pid,ppid,pgid,args'])
.toString()
.split('\n')
.filter((line) => /tmux|sleep|bash|zsh/.test(line))
.join('\n')
} catch (error) {
return String(error)
}
}
const REAL_TMUX = findTmux()
/**
* Unique to this run of the suite, so its processes are never confused with those of another run on
* the same machine (CI boxes run several at once).
*/
const RUN_NONCE = String(randomInt(0, 1_000_000)).padStart(6, '0')
/** A sleep that runs for about `seconds` and that only this test names. */
function slept(seconds: number): string {
return `sleep ${seconds}.${RUN_NONCE}`
}
/** The command line a stubborn sleep runs as, unique to one test by its duration. */
function stubborn(seconds: number): string {
return `bash -c "${STUBBORN}; ${slept(seconds)}"`
}
function findTmux(): string | null {
try {
return execFileSync('sh', ['-c', 'command -v tmux']).toString().trim() || null
} catch {
return null
}
}
/** One launch's own tmux server, and a way to read and reshape it as the user would. */
class TmuxServer {
readonly dir = mkdtempSync('/tmp/sim-tmux-')
run(args: string[]): string {
return execFileSync(REAL_TMUX ?? 'tmux', args, {
env: { ...process.env, TMUX_TMPDIR: this.dir },
}).toString()
}
/** Every pane: its id, its run tag (empty when untagged) and the command it was started with. */
panes(): Array<{ id: string; runId: string; command: string }> {
try {
return this.run([
'list-panes',
'-a',
'-F',
'#{pane_id}\t#{@sim-run-id}\t#{pane_start_command}',
])
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
const [id = '', runId = '', command = ''] = line.split('\t')
return { id, runId, command }
})
} catch {
return []
}
}
/** The pane an agent run opened, found by the run script it was started with. */
runPane(): { id: string; runId: string } | undefined {
return this.panes().find((pane) => pane.command.includes('run.sh'))
}
hasPane(id: string): boolean {
return this.panes().some((pane) => pane.id === id)
}
/** The user splits a pane of their own beside the agent's, running their own command. */
split(target: string, command: string): string {
return this.run(['split-window', '-d', '-t', target, '-P', '-F', '#{pane_id}', command]).trim()
}
clients(): number {
try {
return this.run(['list-clients']).trim().split('\n').filter(Boolean).length
} catch {
return 0
}
}
kill(): void {
try {
this.run(['kill-server'])
} catch {
// Already gone.
}
}
}
/** A `tmux` ahead of the real one on PATH that refuses pane options, as tmux before 3.0 does. */
function oldTmuxPath(): string {
const dir = mkdtempSync(join(tmpdir(), 'sim-old-tmux-'))
const binary = join(dir, 'tmux')
writeFileSync(
binary,
`#!/bin/sh
if [ "$1" = set-option ] && [ "$2" = -p ]; then
printf 'tmux: unknown option -- p\\nusage: set-option [-aFgosquw] [-t target-window] option [value]\\n' >&2
exit 1
fi
exec ${JSON.stringify(REAL_TMUX)} "$@"
`
)
chmodSync(binary, 0o755)
return `${dir}:${process.env.PATH ?? ''}`
}
function bridge(window: Page) {
return {
/** The user opens a terminal tab in a chat's terminal panel and starts a command in it. */
async userRuns(scope: string, command: string): Promise<void> {
const terminalId = await window.evaluate(async (scopeId) => {
const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi })
.simDesktop.terminal
await terminal.activateScope(scopeId)
terminal.setVisible?.(true, scopeId)
terminal.setFocused(true, scopeId)
const opened = await terminal.openTerminal(undefined, scopeId)
return opened.activeTerminalId
}, scope)
if (!terminalId) throw new Error('No terminal tab opened')
// Typing is gated on a real keypress in the window, as it is for the panel's own input.
await window.keyboard.press('Shift')
await window.evaluate(
([id, text, scopeId]) =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.terminal.write(id, text, scopeId),
[terminalId, `${command}\r`, scope] as const
)
},
/** The user puts a chat away: its terminals close, though tmux runs started in them go on. */
async putAway(scope: string): Promise<boolean> {
return window.evaluate(async (scopeId) => {
const terminal = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi })
.simDesktop.terminal
await terminal.activateScope(scopeId)
return terminal.suspendScope(scopeId)
}, scope)
},
async setTerminalEnabled(enabled: boolean): Promise<void> {
await window.evaluate(
(value) =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.settings.setPreference('terminalEnabled', value),
enabled
)
},
}
}
/** The agent runs a plain-shell command in a chat's terminal; resolves once it is running. */
async function agentRunsPlain(deviceId: string, seconds: number): Promise<string> {
const call = sim.issue(deviceId, CHAT_PLAIN, 'terminal', {
operation: 'run',
args: { command: stubborn(seconds), waitSeconds: 120 },
})
await expect.poll(() => processRunning(`${slept(seconds)}`), { timeout: 30_000 }).toBe(true)
return call
}
/**
* The agent's terminal attaches to tmux (the agent starts a tmux client in it), and the agent then
* runs a command, which tmux gives its own tagged pane. Resolves with the call and that pane.
*/
/** The agent starts a tmux client in its chat's terminal, so its runs go to tmux panes. */
async function agentAttachesTmux(deviceId: string, tmux: TmuxServer): Promise<void> {
const before = tmux.clients()
sim.issue(deviceId, CHAT_TMUX, 'terminal', {
operation: 'run',
args: { command: `tmux new-session -A -s ${TMUX_SESSION}`, waitSeconds: 2 },
})
await expect.poll(() => tmux.clients(), { timeout: 30_000 }).toBe(before + 1)
// The terminal's attachment is looked up again once its short cache lapses.
await sleep(3_500)
}
/**
* The agent runs a command in tmux, which gives it its own tagged pane; with a short
* `waitSeconds` the call hands it back as still running, with that pane.
*/
async function agentRunsInTmux(
deviceId: string,
tmux: TmuxServer,
seconds: number,
waitSeconds = 120
): Promise<{ call: string; pane: { id: string; runId: string } }> {
await agentAttachesTmux(deviceId, tmux)
// It ignores Ctrl-C, so stopping it takes closing its pane, the step that must spare the user's.
const call = sim.issue(deviceId, CHAT_TMUX, 'terminal', {
operation: 'run',
args: {
command: `bash -c "trap '' INT; echo collect-me; ${slept(seconds)}"`,
waitSeconds,
},
})
await expect.poll(() => processRunning(`${slept(seconds)}`), { timeout: 30_000 }).toBe(true)
await expect.poll(() => tmux.runPane(), { timeout: 10_000 }).toBeTruthy()
return { call, pane: tmux.runPane() as { id: string; runId: string } }
}
async function claimed(call: string): Promise<void> {
await expect.poll(() => sim.requireCall(call).claims, { timeout: 30_000 }).toBe(1)
}
test.describe('terminal cancel', () => {
let app: ElectronApplication | null = null
let tmux: TmuxServer
const leftovers: string[] = []
test.beforeAll(async () => {
await sim.start()
})
test.beforeEach(() => {
tmux = new TmuxServer()
})
test.afterEach(async () => {
const testInfo = test.info()
recordCheck(process.env.TERMINAL_CANCEL_REPORT_PATH, 'terminal-cancel', {
name: testInfo.title,
status: testInfo.status ?? 'unknown',
durationMs: testInfo.duration,
retry: testInfo.retry,
})
if (testInfo.status !== testInfo.expectedStatus) {
// What each call came back with, and the panes tmux held, explain most failures.
const calls = [...sim.calls.values()].map((call) => ({
toolCallId: call.toolCallId,
args: call.args,
status: call.status,
completions: call.completions.map(({ status, message, data }) => ({
status,
message,
data,
})),
}))
await testInfo.attach('calls', {
body: JSON.stringify({ calls, panes: tmux.panes() }, null, 2),
})
await testInfo.attach('app-output', { body: appOutput.join('').slice(-6_000) })
await testInfo.attach('processes', { body: processes() })
}
appOutput.length = 0
// Quitting with a command still running in a tab asks first (natively, on macOS), and nobody
// is there to answer, so a close that does not finish promptly ends the app instead.
const closing = app
const closed = await Promise.race([
closing?.close().then(
() => true,
() => true
) ?? Promise.resolve(true),
sleep(5_000).then(() => false),
])
if (!closed) closing?.process().kill('SIGKILL')
app = null
tmux.kill()
for (const pattern of leftovers.splice(0)) {
try {
execFileSync('pkill', ['-9', '-f', pattern])
} catch {
// Nothing left.
}
}
sim.reset()
})
test.afterAll(async () => {
await sim.stop()
})
async function start(
name: string,
env: Record<string, string> = {}
): Promise<{ window: Page; deviceId: string; userData: string }> {
const userData = mkdtempSync(join(tmpdir(), `sim-cancel-${name}-`))
const launched = await launch(sim, userData, { TMUX_TMPDIR: tmux.dir, ...env })
app = launched.app
launched.app.process().stdout?.on('data', (chunk: Buffer) => appOutput.push(chunk.toString()))
launched.app.process().stderr?.on('data', (chunk: Buffer) => appOutput.push(chunk.toString()))
const deviceId = await registeredDevice(sim)
await launched.window.goto(`${sim.origin}/workspace/ws-e2e/chat/${CHAT_PLAIN}`)
return { window: launched.window, deviceId, userData }
}
test('Stop ends the agent command in a plain shell and leaves the user command running', async () => {
const { window, deviceId } = await start('stop-plain')
leftovers.push(slept(701), slept(702))
// The agent's terminal comes first; the user's own tab opens beside it.
const call = await agentRunsPlain(deviceId, 701)
await bridge(window).userRuns(CHAT_PLAIN, stubborn(702))
await expect.poll(() => processRunning(slept(702)), { timeout: 30_000 }).toBe(true)
sim.stopCall(call)
await settled(sim, call, 20_000)
await expect.poll(() => processRunning(slept(701)), { timeout: 15_000 }).toBe(false)
expect(processRunning(slept(702))).toBe(true)
})
test('Stop interrupts a tagged tmux run and closes only its pane, never the user pane beside it', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { deviceId } = await start('stop-tmux')
leftovers.push(slept(711), slept(712))
const { call, pane } = await agentRunsInTmux(deviceId, tmux, 711)
expect(pane.runId).not.toBe('')
const users = tmux.split(pane.id, stubborn(712))
sim.stopCall(call)
await settled(sim, call, 20_000)
await expect.poll(() => processRunning(slept(711)), { timeout: 15_000 }).toBe(false)
await expect.poll(() => tmux.hasPane(pane.id), { timeout: 10_000 }).toBe(false)
expect(tmux.hasPane(users)).toBe(true)
expect(processRunning(slept(712))).toBe(true)
})
test('signing out stops every agent command and leaves the user commands running', async () => {
const { window, deviceId } = await start('sign-out')
leftovers.push(slept(721), slept(722), slept(723), slept(724))
const plain = await agentRunsPlain(deviceId, 721)
await claimed(plain)
await bridge(window).userRuns(CHAT_PLAIN, stubborn(722))
await expect.poll(() => processRunning(slept(722)), { timeout: 30_000 }).toBe(true)
const run = REAL_TMUX ? await agentRunsInTmux(deviceId, tmux, 723) : null
const users = run ? tmux.split(run.pane.id, stubborn(724)) : null
// The web app signs out by navigating to its login page.
await window.goto(`${sim.origin}/login?fromLogout=true`)
await expect.poll(() => processRunning(slept(721)), { timeout: 30_000 }).toBe(false)
expect(processRunning(slept(722))).toBe(true)
if (run && users) {
await expect.poll(() => processRunning(slept(723)), { timeout: 15_000 }).toBe(false)
await expect.poll(() => tmux.hasPane(run.pane.id), { timeout: 10_000 }).toBe(false)
expect(tmux.hasPane(users)).toBe(true)
expect(processRunning(slept(724))).toBe(true)
}
})
test('a session ended by another account signing in stops every agent command and leaves the user pane running', async () => {
const { window, deviceId } = await start('account')
leftovers.push(slept(731), slept(733), slept(734))
await agentRunsPlain(deviceId, 731)
const run = REAL_TMUX ? await agentRunsInTmux(deviceId, tmux, 733) : null
const users = run ? tmux.split(run.pane.id, stubborn(734)) : null
if (users) await expect.poll(() => processRunning(slept(734)), { timeout: 15_000 }).toBe(true)
// Another account signing in replaces the session: the old session cookie goes away.
await window.goto(`${sim.origin}/session-ended`)
await expect.poll(() => processRunning(slept(731)), { timeout: 30_000 }).toBe(false)
if (run && users) {
await expect.poll(() => processRunning(slept(733)), { timeout: 15_000 }).toBe(false)
await expect.poll(() => tmux.hasPane(run.pane.id), { timeout: 10_000 }).toBe(false)
expect(tmux.hasPane(users)).toBe(true)
expect(processRunning(slept(734))).toBe(true)
}
})
test('switching Terminal off stops every agent command and leaves the user commands running', async () => {
const { window, deviceId } = await start('terminal-off')
leftovers.push(slept(741), slept(743), slept(744))
await agentRunsPlain(deviceId, 741)
const run = REAL_TMUX ? await agentRunsInTmux(deviceId, tmux, 743) : null
const users = run ? tmux.split(run.pane.id, stubborn(744)) : null
await bridge(window).setTerminalEnabled(false)
await expect.poll(() => processRunning(slept(741)), { timeout: 30_000 }).toBe(false)
if (run && users) {
await expect.poll(() => processRunning(slept(743)), { timeout: 15_000 }).toBe(false)
await expect.poll(() => tmux.hasPane(run.pane.id), { timeout: 10_000 }).toBe(false)
expect(tmux.hasPane(users)).toBe(true)
expect(processRunning(slept(744))).toBe(true)
}
})
test('signing out stops a tmux run whose chat was put away', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { window, deviceId, userData } = await start('put-away')
leftovers.push(slept(801))
const { pane } = await agentRunsInTmux(deviceId, tmux, 801)
// No live terminal holds the run any more; only its record does.
expect(await bridge(window).putAway(CHAT_TMUX)).toBe(true)
await sleep(1_000)
expect(processRunning(slept(801))).toBe(true)
await window.goto(`${sim.origin}/login?fromLogout=true`)
await expect.poll(() => processRunning(slept(801)), { timeout: 30_000 }).toBe(false)
expect(tmux.hasPane(pane.id)).toBe(false)
await expect.poll(() => recordedRuns(userData), { timeout: 10_000 }).toEqual([])
})
/** The app dies without a word: its shells go with it, and its tmux runs outlive it. */
function crash(): void {
app?.process().kill('SIGKILL')
app = null
}
/** The runs this app has recorded for a later process to find. */
function recordedRuns(userData: string): string[] {
try {
return readdirSync(join(userData, 'terminal-runs'))
} catch {
return []
}
}
test('the launch after an interrupted sign-out stops the tmux run the previous app left running', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { deviceId, userData } = await start('recovery')
leftovers.push(slept(751))
const { pane } = await agentRunsInTmux(deviceId, tmux, 751)
// The app dies mid sign-out: its recovery marker is on disk and the tmux run outlives it.
crash()
writeFileSync(
join(userData, 'account-data-teardown-required.json'),
JSON.stringify({ version: 2, kind: 'account', origin: sim.origin })
)
expect(processRunning(slept(751))).toBe(true)
app = (await launch(sim, userData, { TMUX_TMPDIR: tmux.dir })).app
await expect.poll(() => processRunning(slept(751)), { timeout: 30_000 }).toBe(false)
expect(tmux.hasPane(pane.id)).toBe(false)
await expect.poll(() => recordedRuns(userData), { timeout: 10_000 }).toEqual([])
})
test('the launch after a crash stops the tmux run its call can no longer report', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { deviceId, userData } = await start('crash-live')
leftovers.push(slept(771), slept(772))
const { pane } = await agentRunsInTmux(deviceId, tmux, 771)
const users = tmux.split(pane.id, stubborn(772))
// Same user, plain crash: the journal settles the call as outcome unknown, and nothing will
// ever read what the command does.
crash()
expect(processRunning(slept(771))).toBe(true)
app = (await launch(sim, userData, { TMUX_TMPDIR: tmux.dir })).app
await expect.poll(() => processRunning(slept(771)), { timeout: 30_000 }).toBe(false)
expect(tmux.hasPane(pane.id)).toBe(false)
expect(tmux.hasPane(users)).toBe(true)
expect(processRunning(slept(772))).toBe(true)
await expect.poll(() => recordedRuns(userData), { timeout: 10_000 }).toEqual([])
})
test('the launch after a crash only forgets a tmux run that had already finished', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { deviceId, userData } = await start('crash-finished')
leftovers.push(slept(6), slept(782))
// Handed back as still running, it then finishes on its own.
const { call, pane } = await agentRunsInTmux(deviceId, tmux, 6, 2)
await settled(sim, call)
const users = tmux.split(pane.id, stubborn(782))
await expect.poll(() => tmux.hasPane(pane.id), { timeout: 20_000 }).toBe(false)
expect(recordedRuns(userData)).toHaveLength(1)
crash()
app = (await launch(sim, userData, { TMUX_TMPDIR: tmux.dir })).app
await expect.poll(() => recordedRuns(userData), { timeout: 30_000 }).toEqual([])
expect(tmux.hasPane(users)).toBe(true)
expect(processRunning(slept(782))).toBe(true)
})
test('a run handed back as still going survives a quit and the relaunch, and can still be read', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { deviceId, userData } = await start('collectable')
leftovers.push(slept(811))
const { call, pane } = await agentRunsInTmux(deviceId, tmux, 811, 2)
const handedBack = await settled(sim, call)
expect(handedBack.data).toMatchObject({ status: 'running', pane: pane.id })
// Quitting stops no tmux run; the relaunch leaves one the model may come back to.
const quitting = app
app = null
const quit = await Promise.race([
quitting?.close().then(() => true) ?? Promise.resolve(true),
sleep(10_000).then(() => false),
])
if (!quit) quitting?.process().kill('SIGKILL')
expect(processRunning(slept(811))).toBe(true)
app = (await launch(sim, userData, { TMUX_TMPDIR: tmux.dir })).app
// Same install, same device: it is back once its doorbell is open again.
const relaunched = await registeredDevice(sim)
await sleep(5_000)
expect(processRunning(slept(811))).toBe(true)
expect(tmux.hasPane(pane.id)).toBe(true)
expect(recordedRuns(userData)).toHaveLength(1)
// The model comes back to the pane it was handed.
await agentAttachesTmux(relaunched, tmux)
const read = sim.issue(relaunched, CHAT_TMUX, 'terminal', {
operation: 'read',
args: { pane: pane.id, lines: 50 },
})
const output = await settled(sim, read)
expect(output.status, output.message).toBe('success')
expect(JSON.stringify(output.data)).toContain('collect-me')
})
test("never touches a pane that took a recorded run's id after tmux restarted", async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { deviceId, userData } = await start('stale-record')
leftovers.push(slept(791), slept(792))
const { pane } = await agentRunsInTmux(deviceId, tmux, 791)
crash()
// tmux restarts, ending the run with it, and the user's own pane gets the run's old id.
tmux.kill()
// The old server takes a moment to let go of its socket.
await expect
.poll(
() => {
try {
tmux.run(['-f', '/dev/null', 'new-session', '-d', '-s', 'users', stubborn(792)])
return true
} catch {
return false
}
},
{ timeout: 10_000 }
)
.toBe(true)
for (let panes = 0; panes < 10 && !tmux.hasPane(pane.id); panes += 1) {
tmux.split('users', stubborn(792))
}
expect(processRunning(slept(791))).toBe(false)
app = (await launch(sim, userData, { TMUX_TMPDIR: tmux.dir })).app
await expect.poll(() => recordedRuns(userData), { timeout: 30_000 }).toEqual([])
expect(tmux.hasPane(pane.id)).toBe(true)
expect(processRunning(slept(792))).toBe(true)
})
test('on tmux without pane options a run goes ahead untracked, and Stop and sign-out leave it alone', async () => {
test.skip(!REAL_TMUX, 'tmux is not installed on this machine')
const { window, deviceId } = await start('untracked', { PATH: oldTmuxPath() })
leftovers.push(slept(761))
const { call, pane } = await agentRunsInTmux(deviceId, tmux, 761)
expect(pane.runId).toBe('')
sim.stopCall(call)
await settled(sim, call, 20_000)
await sleep(6_000)
expect(processRunning(slept(761))).toBe(true)
expect(tmux.hasPane(pane.id)).toBe(true)
await window.goto(`${sim.origin}/login?fromLogout=true`)
await sleep(8_000)
expect(processRunning(slept(761))).toBe(true)
expect(tmux.hasPane(pane.id)).toBe(true)
})
})
+1
View File
@@ -60,6 +60,7 @@
"jsdom": "^26.0.0",
"postcss": "^8",
"postcss-load-config": "6.0.1",
"postgres": "^3.4.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "^7.0.2",
@@ -79,6 +79,102 @@ describe('executeTool', () => {
driver = freshDriver()
})
/** The user presses a key in the page the agent drives, as the browser reports it. */
function typeInAgentPage(contents: WebContents) {
const calls: ReadonlyArray<readonly unknown[]> = vi.mocked(contents.on).mock.calls
const listener = calls.find(([event]) => event === 'before-input-event')?.[1]
if (typeof listener !== 'function') throw new Error('No input listener on the agent page')
listener({}, { type: 'keyDown', isAutoRepeat: false })
}
it('lets a call from the chat view act at once while the user works in the page', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
typeInAgentPage(session.requireTab().view.webContents)
expect(session.msSinceUserIntervention()).not.toBeNull()
vi.useFakeTimers()
try {
let settled = false
const opening = driver.executeTool('chat-test', 'browser_open_tab', {}).then((result) => {
settled = true
return result
})
await vi.advanceTimersByTimeAsync(100)
expect(settled).toBe(true)
expect((await opening).ok).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('makes a background call wait until the user leaves the page alone', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
typeInAgentPage(session.requireTab().view.webContents)
vi.useFakeTimers()
try {
let settled = false
const opening = driver
.executeTool('chat-test', 'browser_open_tab', {}, 'background-1', undefined, {
background: true,
})
.then((result) => {
settled = true
return result
})
await vi.advanceTimersByTimeAsync(1_000)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(4_000)
expect((await opening).ok).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('stops a background action before its next input once the user starts working mid-action', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
const contents = session.requireTab().view.webContents
// The user presses a key while the action reads the page before acting on it.
const probe = vi.mocked(contents.executeJavaScript).getMockImplementation()
vi.mocked(contents.executeJavaScript).mockImplementation(async (...args) => {
typeInAgentPage(contents)
return probe ? probe(...args) : undefined
})
const result = await driver.executeTool(
'chat-test',
'browser_press_key',
{ key: 'Enter' },
'background-key',
undefined,
{ background: true }
)
expect(result.ok).toBe(false)
expect(result.error).toContain('the user started working in this page')
})
it('never makes a background read wait for the user', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
typeInAgentPage(session.requireTab().view.webContents)
vi.useFakeTimers()
try {
let settled = false
void driver
.executeTool('chat-test', 'browser_list_tabs', {}, 'background-read', undefined, {
background: true,
})
.then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(100)
expect(settled).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('returns ok:false instead of throwing for tool-level failures', async () => {
// No session exists, so any page-dependent tool fails with guidance.
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 1 })
+81 -3
View File
@@ -19,6 +19,7 @@
import {
BROWSER_DATA_KINDS,
BROWSER_NAVIGATION_NATIVE_WATCHDOG_MS,
BROWSER_TOOL_OBSERVES_ONLY,
BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS,
BROWSER_UPLOAD_MAX_FILES,
type BrowserDataKind,
@@ -32,7 +33,7 @@ import {
import type { BrowserDownloadsState, BrowserToolbarCommand } from '@sim/desktop-bridge'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
import { isRecordLike, omit, toArray, toRecord } from '@sim/utils/object'
import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron'
import { Menu } from 'electron'
@@ -938,6 +939,12 @@ export function restoreBrowserScope(scopeId: string): BrowserTabsState {
})
}
/** Whether a chat's browser has a live page for a tool to act on. */
export function hasBrowserScopeSession(scopeId: string): boolean {
const resolved = resolveDriverScopeId(scopeId)
return session.withBrowserScope(resolved, () => session.hasSession())
}
/** Moves pending-new-chat driver and tab state to the server-issued chat id. */
export function migrateBrowserScope(fromScopeId: string, toScopeId: string): boolean {
const from = resolveDriverScopeId(fromScopeId)
@@ -5088,12 +5095,64 @@ function withNotices(result: unknown): unknown {
return { value: result, notices }
}
/** Quiet time after the user's last click or keystroke before the agent takes the page back. */
const USER_TAKEOVER_IDLE_MS = 4_000
/** The longest an action waits for the user to finish; past it, the action does not run. */
const USER_TAKEOVER_MAX_WAIT_MS = 30_000
const USER_KEPT_WORKING =
'Not run: the user kept working in this page, so this browser action never started and nothing was sent to the page. Ask the user whether they are done before acting in this page again.'
const USER_TOOK_OVER_MID_ACTION =
'Stopped: the user started working in this page, so this browser action stopped before sending its next input. Earlier steps may have taken effect; inspect the page before continuing.'
function isUserWorkingInPage(): boolean {
const since = session.msSinceUserIntervention()
return since !== null && since < USER_TAKEOVER_IDLE_MS
}
/**
* The user and the agent never act in the same page at once. While the user is clicking, typing
* or scrolling in the tab the agent drives, the agent's next action waits, marked as needing
* attention, and resumes once the user has left the page alone for a few seconds.
*/
async function yieldToUser(
toolCallId: string | undefined,
signal: AbortSignal,
maxWaitMs: number
): Promise<void> {
session.setAutomationNeedsAttention(true)
logger.info('Browser automation yielding to the user', { toolCallId })
// Bounded well inside the action's own deadline, so a user who keeps working gets the model a
// clear "not run" rather than a timeout with an unknown outcome.
const deadline = Date.now() + maxWaitMs
try {
while (isUserWorkingInPage()) {
if (Date.now() >= deadline) throw new ToolError(USER_KEPT_WORKING)
await interruptibleSleep(250, signal)
if (signal.aborted) throw new ToolError('This browser action was cancelled.')
}
} finally {
session.setAutomationNeedsAttention(false)
}
logger.info('Browser automation resumed after the user stopped', { toolCallId })
}
/** How a browser call reached the driver. */
interface BrowserToolExecutionOptions {
/**
* Run by the background executor while the user may be working in the same page, so it yields
* the page to them. A call from the chat view the user is watching never does: there the user
* steers the agent directly, as they always have.
*/
background?: boolean
}
export async function executeTool(
scopeId: string,
tool: BrowserToolName,
params: Record<string, unknown>,
toolCallId?: string,
authorizationBoundary?: BrowserToolQueueBoundary
authorizationBoundary?: BrowserToolQueueBoundary,
options: BrowserToolExecutionOptions = {}
): Promise<{ ok: boolean; result?: unknown; error?: string }> {
const resolvedScopeId = resolveDriverScopeId(scopeId)
if (authorizationBoundary) {
@@ -5184,17 +5243,36 @@ export async function executeTool(
session.setAutomationActive(true)
}
try {
// Only a background call acting on the page yields; reading it cannot collide with the user.
const yieldsToUser =
options.background === true &&
tool !== 'browser_request_takeover' &&
!BROWSER_TOOL_OBSERVES_ONLY[tool]
const watchdogMs = browserToolWatchdogMs(tool, params)
if (yieldsToUser && isUserWorkingInPage()) {
await yieldToUser(
toolCallId,
executionController.signal,
watchdogMs === null
? USER_TAKEOVER_MAX_WAIT_MS
: Math.min(USER_TAKEOVER_MAX_WAIT_MS, Math.floor(watchdogMs / 2))
)
}
const response = dialogResponse(tool, params)
state.dialogResponse = response
? { contents: session.requireAutomationTab().view.webContents, response }
: null
const executionEpoch = ++state.toolExecutionEpoch
const watchdogMs = browserToolWatchdogMs(tool, params)
const executionDeadline = watchdogMs === null ? undefined : Date.now() + watchdogMs
// Checked right before each input the action sends: a user who starts working in the
// page mid-action (between a batch's steps, during a click's target probes) wins it.
const assertCurrentExecution = () => {
if (state.toolExecutionEpoch !== executionEpoch) {
throw new ToolError('This browser action expired before it could dispatch input.')
}
if (yieldsToUser && isUserWorkingInPage()) {
throw new ToolError(USER_TOOK_OVER_MID_ACTION)
}
}
let actionOutcome: BrowserActionOutcome | undefined
const execution = withPostActionObservation(
@@ -1071,6 +1071,22 @@ describe('browser-agent session', () => {
expect(session.listTabs()).toHaveLength(1)
})
it('forgets the user input marker when the session closes, so a reused tab id starts clean', () => {
const agent = session.ensureAutomationTab()
const keyDown = (agent.view as unknown as MockView).webContents.on.mock.calls.find(
([eventName]) => eventName === 'before-input-event'
)?.[1]
if (typeof keyDown !== 'function') throw new Error('no before-input-event listener bound')
keyDown({ preventDefault: vi.fn() }, { type: 'keyDown', isAutoRepeat: false })
expect(session.msSinceUserIntervention()).not.toBeNull()
session.closeSession()
const reopened = session.ensureAutomationTab()
expect(reopened.id).toBe(agent.id)
expect(session.msSinceUserIntervention()).toBeNull()
})
it('opens, switches, and closes tabs with stable ids', () => {
const first = session.ensureTab()
const second = session.addTab()
@@ -298,6 +298,11 @@ interface BrowserScopeState {
*/
findingTabId: string | null
findingRequestId: number | null
/**
* When the user last clicked, scrolled or typed in the tab the agent drives, and in which tab:
* activity in a tab the agent no longer drives (closed, replaced) holds nothing up.
*/
userIntervention: { tabId: string; at: number } | null
}
function createBrowserScopeState(): BrowserScopeState {
@@ -319,6 +324,7 @@ function createBrowserScopeState(): BrowserScopeState {
automationNeedsAttention: false,
findingTabId: null,
findingRequestId: null,
userIntervention: null,
}
}
@@ -2368,6 +2374,7 @@ function initializeTabView(
return
}
const tab = tabs.find((entry) => entry.view.webContents === contents)
if (tab && tab.id === currentScope.automationTabId) noteUserIntervention(tab.id)
if (tab?.id === currentScope.activeTabId) {
currentScope.visibleTabUserSelected = true
if (mouse.type === 'mouseDown') tab.lastRealUserGestureAt = Date.now()
@@ -2484,6 +2491,14 @@ function initializeTabView(
'before-input-event',
bindToBrowserScope(scopeId, (event, input) => {
const tab = tabs.find((entry) => entry.view === view)
if (
!isDispatchingAgentInput(contents) &&
input.type === 'keyDown' &&
tab &&
tab.id === currentScope.automationTabId
) {
noteUserIntervention(tab.id)
}
if (!isDispatchingAgentInput(contents) && tab?.id === currentScope.activeTabId) {
currentScope.visibleTabUserSelected = true
if (input.type === 'keyDown' && !input.isAutoRepeat) tab.lastRealUserGestureAt = Date.now()
@@ -3117,8 +3132,21 @@ export function claimActiveTabForUser(): AgentTab | null {
return tab
}
/** The user just acted in the page the agent drives; the agent yields until they stop. */
function noteUserIntervention(tabId: string): void {
currentScope.userIntervention = { tabId, at: Date.now() }
}
/** How long ago the user last acted in the tab the agent drives now, or null if never. */
export function msSinceUserIntervention(): number | null {
const intervention = currentScope.userIntervention
if (!intervention || intervention.tabId !== currentScope.automationTabId) return null
return Math.max(0, Date.now() - intervention.at)
}
/** Explicit hand-back after takeover lets automation resume in the same page. */
export function returnAutomationTabToAgent(): void {
currentScope.userIntervention = null
if (currentScope.activeTabId === currentScope.automationTabId) {
currentScope.visibleTabUserSelected = false
}
@@ -3618,6 +3646,7 @@ function closeLiveTabs(): void {
currentScope.automationActive = false
currentScope.automationNeedsAttention = false
currentScope.visibleTabUserSelected = false
currentScope.userIntervention = null
clearFocusedBrowserTab()
}
+3
View File
@@ -104,6 +104,8 @@ export interface DesktopSettings {
/** Whether omnibox typing may request live Google search completions. */
browserSearchSuggestionsEnabled?: boolean
terminalEnabled?: boolean
/** Keep the machine awake while a chat is running desktop work in the background. */
preventSleepWhileRunning?: boolean
/** Device-wide browser page appearance; `app` follows Sim. */
browserTheme?: 'app' | 'light' | 'dark'
/** Device-wide default zoom for built-in browser pages. */
@@ -236,6 +238,7 @@ const DEFAULT_SETTINGS: DesktopSettings = {
browserEnabled: true,
browserSearchSuggestionsEnabled: true,
terminalEnabled: true,
preventSleepWhileRunning: true,
}
export interface ConfigStore {
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import { createApprovalNotifier } from '@/main/desktop-executor/approval-notifier'
import type { DesktopApprovalItem } from '@/main/desktop-executor/executor'
function approval(toolCallId: string, chatId = 'chat-b'): DesktopApprovalItem {
return {
kind: 'approval_needed',
toolCallId,
toolName: 'terminal',
chatId,
chatTitle: 'Fix CI',
workspaceId: 'ws-1',
summary: 'rm -rf build && npm test',
}
}
function harness(options: { enabled?: boolean; focusedChatId?: string | null } = {}) {
/** Each OS notification the notifier made, and what the user would see of it now. */
const notifications: Array<{
options: { title: string; body: string; silent: boolean }
state: 'created' | 'shown' | 'closed'
click: () => void
}> = []
/** Routes the app was asked to open. */
const opened: Array<string | undefined> = []
let focusedChatId = options.focusedChatId ?? null
const notifier = createApprovalNotifier({
preferences: () => ({
notificationsEnabled: options.enabled ?? true,
notificationSounds: true,
}),
focusedChatId: () => focusedChatId,
openRoute: (route) => {
opened.push(route)
},
createNotification: (notificationOptions) => {
let click = () => {}
const notification = {
options: notificationOptions,
state: 'created' as 'created' | 'shown' | 'closed',
get click() {
return click
},
}
notifications.push(notification)
return {
show: () => {
notification.state = 'shown'
},
close: () => {
notification.state = 'closed'
},
on: (_event, listener) => {
click = listener
},
}
},
})
return {
notifier,
notifications,
opened,
focus: (chatId: string | null) => {
focusedChatId = chatId
},
}
}
describe('approval notifications', () => {
it('notifies once per waiting call and opens its chat, naming neither chat nor command', () => {
const { notifier, notifications, opened } = harness()
notifier.update([approval('call-1')])
notifier.update([approval('call-1')])
expect(notifications).toHaveLength(1)
expect(notifications[0]?.state).toBe('shown')
expect(notifications[0]?.options.body).not.toContain('rm -rf')
expect(notifications[0]?.options.body).not.toContain('Fix CI')
notifications[0]?.click()
expect(opened).toEqual(['/workspace/ws-1/chat/chat-b'])
})
it('closes the notification once the call is decided', () => {
const { notifier, notifications } = harness()
notifier.update([approval('call-1')])
notifier.update([])
expect(notifications[0]?.state).toBe('closed')
})
it('stays quiet for the chat the user is looking at, even after they leave it', () => {
const { notifier, notifications, focus } = harness({ focusedChatId: 'chat-b' })
notifier.update([approval('call-1')])
focus('chat-c')
notifier.update([approval('call-1')])
expect(notifications).toHaveLength(0)
})
it('stays quiet when notifications are switched off', () => {
const { notifier, notifications } = harness({ enabled: false })
notifier.update([approval('call-1')])
expect(notifications).toHaveLength(0)
})
})
@@ -0,0 +1,73 @@
/**
* Tells the user when a chat they are not looking at waits for their approval. The notification
* only opens the chat at its approval card; approving happens there, where the user sees what
* they are approving. It closes itself once the call is decided, and names neither the chat nor
* the command, since a notification can show on a locked screen.
*/
import type { DesktopApprovalItem } from '@/main/desktop-executor/executor'
interface ApprovalNotification {
show(): void
close(): void
on(event: 'click', listener: () => void): void
}
export interface ApprovalNotifierDeps {
/** Whether the user allows desktop notifications, and with sound. */
preferences: () => { notificationsEnabled: boolean; notificationSounds: boolean }
/** The chat the focused Sim window shows, if any; its own approvals need no notification. */
focusedChatId: () => string | null
openRoute: (route: string | undefined) => void
createNotification: (options: {
title: string
body: string
silent: boolean
}) => ApprovalNotification | null
}
/**
* Creates the notifier the executor feeds each inbox read's approval items to. `update` notifies
* once per newly waiting call and closes notifications for calls decided since; `clear` closes
* them all, for sign-out.
*/
export function createApprovalNotifier(deps: ApprovalNotifierDeps) {
/** Calls already brought to the user's attention, by notification or by being on screen. */
const shown = new Map<string, ApprovalNotification | null>()
return {
/** Shows a notification for each newly waiting call and closes those no longer waiting. */
update(items: DesktopApprovalItem[]): void {
const waiting = new Set(items.map((item) => item.toolCallId))
for (const [toolCallId, notification] of shown) {
if (waiting.has(toolCallId)) continue
notification?.close()
shown.delete(toolCallId)
}
const preferences = deps.preferences()
if (!preferences.notificationsEnabled) return
for (const item of items) {
if (shown.has(item.toolCallId)) continue
if (deps.focusedChatId() === item.chatId) {
shown.set(item.toolCallId, null)
continue
}
const notification = deps.createNotification({
title: 'Approval needed',
body: 'A chat is waiting for your approval.',
silent: !preferences.notificationSounds,
})
if (!notification) return
const route = item.workspaceId
? `/workspace/${encodeURIComponent(item.workspaceId)}/chat/${encodeURIComponent(item.chatId)}`
: undefined
notification.on('click', () => deps.openRoute(route))
notification.show()
shown.set(item.toolCallId, notification)
}
},
clear(): void {
for (const notification of shown.values()) notification?.close()
shown.clear()
},
}
}
@@ -0,0 +1,86 @@
import { DESKTOP_IMPORT_TOKEN_HEADER } from '@sim/desktop-bridge'
import { describe, expect, it } from 'vitest'
import { createDesktopExecutorClient, UnsendableRequestError } from '@/main/desktop-executor/client'
describe('desktop executor client', () => {
it('refuses a request it cannot encode without sending anything', async () => {
let sent = 0
const client = createDesktopExecutorClient({
origin: () => 'https://sim.test',
fetch: async () => {
sent += 1
return Response.json({ outcome: 'recorded', status: 'completed' })
},
deviceId: '00000000-0000-4000-8000-000000000000',
})
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
await expect(
client.complete({
toolCallId: 'call-1',
executionToken: 'token-1',
completion: { status: 'success', message: 'done', data: cyclic },
})
).rejects.toBeInstanceOf(UnsendableRequestError)
expect(sent).toBe(0)
})
it('never splits a character when it shortens a long completion message', async () => {
const sent: string[] = []
const client = createDesktopExecutorClient({
origin: () => 'https://sim.test',
fetch: async (_url, init) => {
sent.push(String(init.body))
return Response.json({ outcome: 'recorded', status: 'completed' })
},
deviceId: '00000000-0000-4000-8000-000000000000',
})
// An emoji straddles the cut point, so a cut by code units would leave half of it behind.
const message = `${'a'.repeat(9_996)}😀${'b'.repeat(10)}`
await client.complete({
toolCallId: 'call-1',
executionToken: 'token-1',
completion: { status: 'success', message },
})
const sentMessage: string = JSON.parse(sent[0] ?? '{}').message
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
expect(loneSurrogate.test(sentMessage)).toBe(false)
})
it('sends an import entry with its execution token in a header, never in the URL', async () => {
const received: Array<{ url: string; token: string | null }> = []
const client = createDesktopExecutorClient({
origin: () => 'https://sim.test',
fetch: async (url, init) => {
received.push({ url, token: new Headers(init.headers).get(DESKTOP_IMPORT_TOKEN_HEADER) })
return Response.json({ id: 'file-1', name: 'notes.txt' })
},
deviceId: '00000000-0000-4000-8000-000000000000',
})
await client.importEntry(
{
call: {
toolCallId: 'call-1',
toolName: 'import_local_files',
args: {},
chatId: 'chat-1',
workspaceId: 'ws-1',
executionToken: 'secret-token-1',
},
kind: 'file',
sourceName: 'notes.txt',
relativePath: '',
content: new Blob(['hello']),
},
new AbortController().signal
)
expect(received).toHaveLength(1)
expect(received[0]?.token).toBe('secret-token-1')
expect(decodeURIComponent(received[0]?.url ?? '')).not.toContain('secret-token-1')
})
})
@@ -0,0 +1,244 @@
/**
* HTTP client for Sim's background executor routes. Every request carries the app partition's
* own session cookie, which is the session the device registered under; Sim refuses any other.
*/
import { DESKTOP_IMPORT_TOKEN_HEADER } from '@sim/desktop-bridge'
import { getErrorMessage } from '@sim/utils/errors'
import { parseRetryAfter } from '@sim/utils/retry'
import { truncateAtCodePoint } from '@sim/utils/string'
import {
type ClaimedDesktopCall,
COMPLETION_MESSAGE_MAX_CHARS,
type DesktopCompletionOutcome,
type DesktopCompletionRequest,
type DesktopDeviceRegistration,
type DesktopExecutorTiming,
type DesktopImportEntryRequest,
type DesktopImportedEntry,
type DesktopInboxItem,
parseClaim,
parseCompletionOutcome,
parseImportedEntry,
parseInbox,
parseRegistration,
} from '@/main/desktop-executor/protocol'
const REQUEST_TIMEOUT_MS = 15_000
/** An import entry carries up to a 64 MB file, so it gets longer than a control request. */
const IMPORT_TIMEOUT_MS = 300_000
/** A request Sim answered with a failure, or that never got an answer (`status` 0). */
export class DeviceRequestError extends Error {
constructor(
readonly status: number,
message: string,
readonly retryAfterMs: number | null = null
) {
super(message)
this.name = 'DeviceRequestError'
}
/** The device's registration no longer holds for this session; it must register again. */
get unregistered(): boolean {
return this.status === 401
}
/** Worth retrying unchanged: no answer, a timeout, a server fault, or a rate limit. */
get transient(): boolean {
return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500
}
}
/**
* A request this device could not even encode (a result holding a cycle or a BigInt). Nothing was
* sent, and sending it again cannot help.
*/
export class UnsendableRequestError extends Error {
constructor(message: string) {
super(message)
this.name = 'UnsendableRequestError'
}
}
function encode(body: Record<string, unknown>): string {
try {
return JSON.stringify(body)
} catch (error) {
throw new UnsendableRequestError(getErrorMessage(error))
}
}
type DeviceFetch = (url: string, init: RequestInit) => Promise<Response>
export interface DesktopExecutorClient {
register(registration: DesktopDeviceRegistration): Promise<DesktopExecutorTiming>
listInbox(): Promise<DesktopInboxItem[]>
/** Opens the doorbell; the caller reads the event stream and aborts it to close. */
openInboxStream(signal: AbortSignal): Promise<ReadableStream<Uint8Array>>
claim(toolCallId: string): Promise<ClaimedDesktopCall>
renewLease(toolCallId: string, executionToken: string): Promise<void>
complete(request: DesktopCompletionRequest): Promise<DesktopCompletionOutcome>
importEntry(
request: DesktopImportEntryRequest,
signal: AbortSignal
): Promise<DesktopImportedEntry>
}
interface DesktopExecutorClientOptions {
origin: () => string
fetch: DeviceFetch
deviceId: string
}
async function errorMessage(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as { error?: unknown } | null
return typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`
}
/**
* Creates the client for one install id. Each request times out on its own and fails with a
* {@link DeviceRequestError}, so the executor decides what to retry; responses are parsed before
* they are returned, and a malformed one is a 502.
*/
export function createDesktopExecutorClient(
options: DesktopExecutorClientOptions
): DesktopExecutorClient {
const { deviceId } = options
async function send(
method: 'GET' | 'POST' | 'PUT',
path: string,
body?: Record<string, unknown> | Blob,
signal?: AbortSignal,
timeoutMs = REQUEST_TIMEOUT_MS,
extraHeaders: Record<string, string> = {}
): Promise<Response> {
const timeout = AbortSignal.timeout(timeoutMs)
const raw = body instanceof Blob
const encoded = body === undefined ? undefined : raw ? body : encode(body)
let response: Response
try {
response = await options.fetch(`${options.origin()}${path}`, {
method,
credentials: 'include',
headers: {
Accept: 'application/json',
...(body
? { 'Content-Type': raw ? 'application/octet-stream' : 'application/json' }
: {}),
...extraHeaders,
},
...(encoded !== undefined ? { body: encoded } : {}),
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
})
} catch (error) {
throw new DeviceRequestError(0, getErrorMessage(error))
}
if (!response.ok) {
throw new DeviceRequestError(
response.status,
await errorMessage(response),
parseRetryAfter(response.headers.get('retry-after'))
)
}
return response
}
async function json(
method: 'GET' | 'POST',
path: string,
body?: Record<string, unknown>
): Promise<unknown> {
const response = await send(method, path, body)
return response.json().catch(() => null)
}
function malformed(route: string): DeviceRequestError {
return new DeviceRequestError(502, `Sim returned a malformed ${route} response`)
}
return {
async register(registration) {
const timing = parseRegistration(
await json('POST', '/api/desktop/devices', { ...registration })
)
if (!timing) throw malformed('registration')
return timing
},
async listInbox() {
const items = parseInbox(
await json('GET', `/api/desktop/inbox?deviceId=${encodeURIComponent(deviceId)}`)
)
if (!items) throw malformed('inbox')
return items
},
async openInboxStream(signal) {
let response: Response
try {
response = await options.fetch(
`${options.origin()}/api/desktop/inbox/stream?deviceId=${encodeURIComponent(deviceId)}`,
{
method: 'GET',
credentials: 'include',
headers: { Accept: 'text/event-stream' },
cache: 'no-store',
signal,
}
)
} catch (error) {
throw new DeviceRequestError(0, getErrorMessage(error))
}
if (!response.ok || !response.body) {
throw new DeviceRequestError(response.status, `Doorbell refused: HTTP ${response.status}`)
}
return response.body
},
async claim(toolCallId) {
const claimed = parseClaim(
toolCallId,
await json('POST', '/api/desktop/tool/claim', { deviceId, toolCallId })
)
if (!claimed) throw malformed('claim')
return claimed
},
async renewLease(toolCallId, executionToken) {
await send('POST', '/api/desktop/tool/lease', { deviceId, toolCallId, executionToken })
},
async complete({ toolCallId, executionToken, completion }) {
const outcome = parseCompletionOutcome(
await json('POST', '/api/desktop/tool/complete', {
deviceId,
toolCallId,
executionToken,
status: completion.status,
// Leaves room for the ellipsis, so a cut message still fits Sim's limit.
message: truncateAtCodePoint(completion.message, COMPLETION_MESSAGE_MAX_CHARS - 3),
...(completion.data !== undefined ? { data: completion.data } : {}),
})
)
if (!outcome) throw malformed('completion')
return outcome
},
async importEntry({ call, kind, sourceName, relativePath, content }, signal) {
const query = new URLSearchParams({
deviceId,
toolCallId: call.toolCallId,
kind,
sourceName,
relativePath,
})
const response = await send(
'PUT',
`/api/desktop/tool/import?${query}`,
content,
signal,
IMPORT_TIMEOUT_MS,
{ [DESKTOP_IMPORT_TOKEN_HEADER]: call.executionToken }
)
const entry = parseImportedEntry(await response.json().catch(() => null))
if (!entry) throw malformed('import')
return entry
},
}
}
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
import { DeviceRequestError } from '@/main/desktop-executor/client'
import { InboxDoorbell, parseServerSentEvents } from '@/main/desktop-executor/doorbell'
const encoder = new TextEncoder()
/** A stream the test writes SSE text into, closing when told to or when its reader aborts. */
function controllableStream(signal: AbortSignal) {
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c
},
})
signal.addEventListener('abort', () => {
try {
controller?.error(new Error('aborted'))
} catch {}
})
return {
stream,
write: (text: string) => controller?.enqueue(encoder.encode(text)),
end: () => controller?.close(),
}
}
function harness(options: { staleAfterMs?: number; retryBaseMs?: number } = {}) {
const connections: ReturnType<typeof controllableStream>[] = []
const failures: DeviceRequestError[] = []
/** How many times the doorbell rang, and asked to register again. */
const counts = { rings: 0, unregistered: 0 }
const onRing = () => {
counts.rings += 1
}
const onUnregistered = () => {
counts.unregistered += 1
}
const doorbell = new InboxDoorbell({
client: {
openInboxStream: async (signal) => {
const failure = failures.shift()
if (failure) throw failure
const connection = controllableStream(signal)
connections.push(connection)
return connection.stream
},
},
onRing,
onUnregistered,
retryBaseMs: 5,
...options,
})
return { doorbell, connections, failures, counts }
}
describe('parseServerSentEvents', () => {
it('keeps a partial event for the next chunk and skips comments', () => {
const { events, rest } = parseServerSentEvents(
': heartbeat\n\nevent: inbox_changed\ndata: {"reason":"call"}\n\nevent: rot'
)
expect(events).toEqual([{ event: 'inbox_changed', data: '{"reason":"call"}' }])
expect(rest).toBe('event: rot')
})
})
describe('InboxDoorbell', () => {
it('rings on connect and on every inbox change', async () => {
const { doorbell, connections, counts } = harness()
doorbell.start()
await vi.waitFor(() => expect(counts.rings).toBe(1))
connections[0]?.write('event: inbox_changed\ndata: {"reason":"call"}\n\n')
await vi.waitFor(() => expect(counts.rings).toBe(2))
doorbell.stop()
})
it('opens a new connection when Sim rotates the stream', async () => {
const { doorbell, connections } = harness()
doorbell.start()
await vi.waitFor(() => expect(connections).toHaveLength(1))
connections[0]?.write('event: rotate\ndata: {}\n\n')
await vi.waitFor(() => expect(connections).toHaveLength(2))
doorbell.stop()
})
it('reconnects after the server drops the stream or refuses a connection', async () => {
const { doorbell, connections, failures, counts } = harness()
failures.push(new DeviceRequestError(0, 'offline'), new DeviceRequestError(502, 'deploy'))
doorbell.start()
await vi.waitFor(() => expect(connections).toHaveLength(1))
connections[0]?.end()
await vi.waitFor(() => expect(connections).toHaveLength(2))
expect(counts.rings).toBe(2)
doorbell.stop()
})
it('backs off when every stream ends as soon as it opens', async () => {
vi.useFakeTimers()
const opened: number[] = []
const doorbell = new InboxDoorbell({
client: {
openInboxStream: async () => {
opened.push(Date.now())
return new ReadableStream<Uint8Array>({ start: (controller) => controller.close() })
},
},
onRing: () => {},
onUnregistered: () => {},
retryBaseMs: 5,
})
try {
doorbell.start()
await vi.advanceTimersByTimeAsync(400)
doorbell.stop()
} finally {
vi.useRealTimers()
}
// A fixed 5 ms retry would open 80; doubling from 5 ms opens a handful.
expect(opened.length).toBeGreaterThan(2)
expect(opened.length).toBeLessThan(15)
})
it('replaces a connection that goes silent past the heartbeat', async () => {
const { doorbell, connections } = harness({ staleAfterMs: 30 })
doorbell.start()
await vi.waitFor(() => expect(connections.length).toBeGreaterThanOrEqual(2))
doorbell.stop()
})
it('reports a device Sim no longer recognizes', async () => {
const { doorbell, failures, counts } = harness()
failures.push(new DeviceRequestError(401, 'unregistered'))
doorbell.start()
await vi.waitFor(() => expect(counts.unregistered).toBeGreaterThan(0))
doorbell.stop()
})
it('reconnects at once on wake, without waiting out a backoff', async () => {
const { doorbell, connections, counts } = harness({ retryBaseMs: 60_000 })
doorbell.start()
await vi.waitFor(() => expect(connections).toHaveLength(1))
doorbell.wake()
await vi.waitFor(() => expect(connections).toHaveLength(2))
await vi.waitFor(() => expect(counts.rings).toBe(2))
doorbell.stop()
})
it('starts on wake after sleep stopped it', async () => {
const { doorbell, connections } = harness({ retryBaseMs: 60_000 })
doorbell.start()
await vi.waitFor(() => expect(connections).toHaveLength(1))
doorbell.stop()
doorbell.wake()
await vi.waitFor(() => expect(connections).toHaveLength(2))
doorbell.stop()
})
})
@@ -0,0 +1,186 @@
/**
* The inbox doorbell: a long-lived SSE stream on which Sim hints that this device's inbox
* changed. Hints only; the inbox read they trigger is the record, and the executor also reads
* it on a timer, so a lost event costs latency, never correctness. The stream reconnects with
* backoff, opens its replacement when Sim announces a rotation, and is replaced when it goes
* silent past the server's heartbeat.
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { interruptibleSleep } from '@sim/utils/helpers'
import { backoffWithJitter } from '@sim/utils/retry'
import { type DesktopExecutorClient, DeviceRequestError } from '@/main/desktop-executor/client'
const logger = createLogger('DesktopExecutorDoorbell')
/** Sim heartbeats every 30 s; two missed ones mean the connection is gone. */
const STALE_STREAM_MS = 75_000
const RECONNECT_MAX_MS = 30_000
/** A stream that stayed open this long was healthy; its clean end starts backoff afresh. */
const HEALTHY_STREAM_MS = 60_000
const HANDSHAKE_TIMEOUT_MS = 15_000
interface DoorbellOptions {
client: Pick<DesktopExecutorClient, 'openInboxStream'>
/** The inbox may have changed: on every event, and on every (re)connect. */
onRing: () => void
onUnregistered: () => void
staleAfterMs?: number
retryBaseMs?: number
}
interface ServerSentEvent {
event: string
data: string
}
/** Parses complete SSE blocks out of `buffer`, returning them and the unparsed remainder. */
export function parseServerSentEvents(buffer: string): {
events: ServerSentEvent[]
rest: string
} {
const blocks = buffer.split(/\r?\n\r?\n/)
const rest = blocks.pop() ?? ''
const events: ServerSentEvent[] = []
for (const block of blocks) {
let event = 'message'
const data: string[] = []
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(':')) continue
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart())
}
if (data.length > 0 || event !== 'message') events.push({ event, data: data.join('\n') })
}
return { events, rest }
}
export class InboxDoorbell {
private running = false
/** Which loop is current; a loop left over from before a stop ends at its next turn. */
private loopGeneration = 0
/** The connection was dropped on purpose; open the next one without backing off. */
private reconnectNow = false
private connection: AbortController | null = null
private sleeper: AbortController | null = null
constructor(private readonly options: DoorbellOptions) {}
start(): void {
if (this.running) return
this.running = true
this.loopGeneration += 1
void this.loop(this.loopGeneration)
}
stop(): void {
this.running = false
this.loopGeneration += 1
this.connection?.abort()
this.sleeper?.abort()
}
/**
* After waking or coming back online: a stopped doorbell starts, and a running one drops its
* connection, which may be dead without knowing it, and reconnects at once.
*/
wake(): void {
if (!this.running) {
this.start()
return
}
this.reconnectNow = true
this.connection?.abort()
this.sleeper?.abort()
}
private async loop(generation: number): Promise<void> {
let attempt = 0
const current = () => this.loopGeneration === generation
while (current()) {
const openedAt = Date.now()
const rotated = await this.connectOnce().then(
(result) => {
if (result === 'rotated') {
attempt = 0
return true
}
// A stream something keeps closing right away is a failing connection, not a healthy one.
attempt = Date.now() - openedAt >= HEALTHY_STREAM_MS ? 0 : attempt + 1
return false
},
(error: unknown) => {
if (this.reconnectNow) {
this.reconnectNow = false
attempt = 0
return true
}
attempt += 1
if (error instanceof DeviceRequestError && error.unregistered) {
this.options.onUnregistered()
}
logger.info('Doorbell disconnected', {
attempt,
error: getErrorMessage(error),
})
return false
}
)
if (!current() || rotated) continue
this.sleeper = new AbortController()
await interruptibleSleep(
attempt === 0
? (this.options.retryBaseMs ?? 1_000)
: backoffWithJitter(attempt, null, {
baseMs: this.options.retryBaseMs ?? 1_000,
maxMs: RECONNECT_MAX_MS,
}),
this.sleeper.signal
)
this.sleeper = null
}
}
/** Holds one connection open; resolves when it ends cleanly or Sim asks for a rotation. */
private async connectOnce(): Promise<'ended' | 'rotated'> {
const connection = new AbortController()
this.connection = connection
const staleAfterMs = this.options.staleAfterMs ?? STALE_STREAM_MS
let staleTimer: ReturnType<typeof setTimeout> | null = null
const touch = () => {
if (staleTimer) clearTimeout(staleTimer)
staleTimer = setTimeout(() => connection.abort(), staleAfterMs)
}
try {
// A stream whose response never starts is as dead as one that went quiet.
const handshake = setTimeout(() => connection.abort(), HANDSHAKE_TIMEOUT_MS)
const stream = await this.options.client
.openInboxStream(connection.signal)
.finally(() => clearTimeout(handshake))
touch()
this.options.onRing()
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) return 'ended'
touch()
buffer += decoder.decode(value, { stream: true })
const parsed = parseServerSentEvents(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
if (event.event === 'rotate') {
void reader.cancel().catch(() => {})
return 'rotated'
}
if (event.event === 'inbox_changed') this.options.onRing()
}
}
} finally {
if (staleTimer) clearTimeout(staleTimer)
connection.abort()
if (this.connection === connection) this.connection = null
}
}
}
@@ -0,0 +1,925 @@
import type { DesktopToolCompletion } from '@sim/desktop-bridge/tool-results'
import { sleep } from '@sim/utils/helpers'
import { describe, expect, it, vi } from 'vitest'
import {
type DesktopExecutorClient,
DeviceRequestError,
UnsendableRequestError,
} from '@/main/desktop-executor/client'
import {
type DesktopApprovalItem,
DesktopExecutor,
type DesktopToolRunner,
} from '@/main/desktop-executor/executor'
import type { ExecutorJournal, JournalEntry } from '@/main/desktop-executor/journal'
import type {
ClaimedDesktopCall,
DesktopCompletionRequest,
DesktopInboxItem,
} from '@/main/desktop-executor/protocol'
/**
* Failure modes of the executor's state machine, each against fakes of its three boundaries:
* Sim's device routes, the encrypted journal, and the tools on this machine.
*/
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => {}
let reject: (error: unknown) => void = () => {}
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function callItem(
toolCallId: string,
chatId: string,
toolName = 'browser_click'
): DesktopInboxItem {
return { kind: 'call', toolCallId, toolName, chatId, workspaceId: 'ws-1' }
}
const DONE: DesktopToolCompletion = { status: 'success', message: 'done', data: { ok: true } }
class FakeSim {
inbox: DesktopInboxItem[] = []
readonly claims: string[] = []
readonly renewals: string[] = []
readonly completions: DesktopCompletionRequest[] = []
claimError: ((toolCallId: string) => DeviceRequestError | null) | null = null
renewError: ((toolCallId: string) => DeviceRequestError | null) | null = null
completeErrors: DeviceRequestError[] = []
completionOutcome: 'recorded' | 'duplicate' | 'superseded' = 'recorded'
readonly client: DesktopExecutorClient = {
register: async () => {
throw new Error('not used')
},
listInbox: async () => [...this.inbox],
openInboxStream: async () => {
throw new Error('not used')
},
importEntry: async () => {
throw new Error('not used')
},
claim: async (toolCallId) => {
this.claims.push(toolCallId)
const error = this.claimError?.(toolCallId)
if (error) throw error
const item = this.inbox.find(
(entry): entry is Extract<DesktopInboxItem, { kind: 'call' }> =>
entry.kind === 'call' && entry.toolCallId === toolCallId
)
if (!item) throw new DeviceRequestError(404, 'gone')
this.inbox = this.inbox.filter((entry) => entry !== item)
return {
toolCallId,
toolName: item.toolName,
args: { step: toolCallId },
chatId: item.chatId,
workspaceId: item.workspaceId,
executionToken: `token-${toolCallId}`,
} satisfies ClaimedDesktopCall
},
renewLease: async (toolCallId) => {
this.renewals.push(toolCallId)
const error = this.renewError?.(toolCallId)
if (error) throw error
},
complete: async (request) => {
const error = this.completeErrors.shift()
if (error) throw error
this.completions.push(request)
return this.completionOutcome
},
}
}
class MemoryJournal implements ExecutorJournal {
readonly entries = new Map<string, JournalEntry>()
readonly history: JournalEntry[] = []
/** A transition the disk refuses, as a full disk or a failed encryption would. */
failOn: JournalEntry['state'] | null = null
async load() {
return [...this.entries.values()]
}
async put(entry: JournalEntry) {
if (entry.state === this.failOn) throw new Error('disk full')
this.entries.set(entry.toolCallId, entry)
this.history.push(entry)
}
async remove(toolCallId: string) {
this.entries.delete(toolCallId)
}
async clear() {
this.entries.clear()
}
}
class FakeRunner implements DesktopToolRunner {
readonly started: string[] = []
readonly cancelled: string[] = []
readonly pending = new Map<string, Deferred<DesktopToolCompletion>>()
/** When set, every call finishes at once with this completion. */
immediate: DesktopToolCompletion | null = null
onStart: ((call: ClaimedDesktopCall) => void) | null = null
/** An action that finishes with its full result even after being told to stop. */
ignoresAbort = false
async run(call: ClaimedDesktopCall, signal: AbortSignal) {
this.started.push(call.toolCallId)
this.onStart?.(call)
if (this.immediate) return this.immediate
const result = deferred<DesktopToolCompletion>()
this.pending.set(call.toolCallId, result)
signal.addEventListener('abort', () => {
if (!this.ignoresAbort)
result.resolve({ status: 'error', message: 'This browser action was cancelled.' })
})
return result.promise
}
async cancel(call: ClaimedDesktopCall) {
this.cancelled.push(call.toolCallId)
}
finish(toolCallId: string, completion: DesktopToolCompletion = DONE) {
const result = this.pending.get(toolCallId)
if (!result) throw new Error(`${toolCallId} is not running`)
result.resolve(completion)
}
}
function setup(
options: { leaseRenewMs?: number; maxHeldCalls?: number; deliveryAwakeLimitMs?: number } = {}
) {
/** Approval items the executor handed to the notifier, one array per inbox read. */
const approvals: DesktopApprovalItem[][] = []
const sim = new FakeSim()
const journal = new MemoryJournal()
const runner = new FakeRunner()
const onUnregistered = vi.fn()
const busy: boolean[] = []
/** Calls whose result the executor reported as reaching the model, in order. */
const delivered: string[] = []
/** Calls Sim finished with without their real result reaching the model, in order. */
const notDelivered: string[] = []
const executor = new DesktopExecutor({
client: sim.client,
journal,
runner,
leaseRenewMs: options.leaseRenewMs ?? 60_000,
retryBaseMs: 5,
onUnregistered,
onBusyChange: (value) => busy.push(value),
onApprovals: (items) => approvals.push(items),
onResultDelivered: (toolCallId) => delivered.push(toolCallId),
onResultNotDelivered: (toolCallId) => notDelivered.push(toolCallId),
...(options.maxHeldCalls ? { maxHeldCalls: options.maxHeldCalls } : {}),
...(options.deliveryAwakeLimitMs !== undefined
? { deliveryAwakeLimitMs: options.deliveryAwakeLimitMs }
: {}),
})
return {
sim,
journal,
runner,
executor,
onUnregistered,
busy,
approvals,
delivered,
notDelivered,
}
}
describe('claiming', () => {
it('claims an offered call, runs it from the server record, and reports with its token', async () => {
const { sim, journal, runner, executor } = setup()
runner.immediate = DONE
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
expect(sim.completions[0]).toEqual({
toolCallId: 'call-1',
executionToken: 'token-call-1',
completion: DONE,
})
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
expect(executor.heldCallCount()).toBe(0)
})
it('reports a result as reaching the model only once Sim takes it as the call own', async () => {
const { sim, journal, runner, executor, delivered, notDelivered } = setup()
runner.immediate = DONE
// Sim settled this call first: the result never reached the model.
sim.completionOutcome = 'superseded'
sim.inbox = [callItem('call-superseded', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(notDelivered).toEqual(['call-superseded']))
expect(delivered).toEqual([])
// Taken by Sim, even with nothing written locally (no OS encryption, say).
sim.completionOutcome = 'recorded'
journal.failOn = 'result'
sim.inbox = [callItem('call-recorded', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(delivered).toEqual(['call-recorded']))
expect(notDelivered).toEqual(['call-superseded'])
})
it('reports a result Sim settled first, while a failed send waited to retry, as never reaching the model', async () => {
const { sim, runner, executor, delivered, notDelivered } = setup()
// The terminal handed back a command still going, and the first send of that result fails.
runner.immediate = { status: 'success', message: 'running', data: { status: 'running' } }
sim.completeErrors = [new DeviceRequestError(503, 'unavailable')]
sim.inbox = [callItem('call-1', 'chat-a', 'terminal')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
// The user pressed Stop meanwhile, so Sim settled the call before the retry reached it.
sim.completionOutcome = 'superseded'
sim.inbox = [{ kind: 'cancel', toolCallId: 'call-1' }]
await executor.reconcile()
await vi.waitFor(() => expect(notDelivered).toEqual(['call-1']))
expect(delivered).toEqual([])
})
it('reports a stand-in Sim holds as a duplicate as never reaching the model, within one app run', async () => {
const { sim, runner, executor, delivered, notDelivered } = setup()
// Too large to send, so Sim never took the real result; the stand-in's first answer was lost.
runner.immediate = DONE
sim.completeErrors = [
new DeviceRequestError(413, 'too large'),
new DeviceRequestError(0, 'connection reset'),
]
sim.completionOutcome = 'duplicate'
sim.inbox = [callItem('call-1', 'chat-a', 'terminal')]
await executor.reconcile()
await vi.waitFor(() => expect(notDelivered).toEqual(['call-1']))
expect(delivered).toEqual([])
})
it('reports a result Sim refused as never reaching the model', async () => {
const { sim, runner, executor, notDelivered } = setup()
runner.immediate = DONE
sim.completeErrors = [new DeviceRequestError(403, 'forbidden')]
sim.inbox = [callItem('call-refused', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(notDelivered).toEqual(['call-refused']))
})
it('reports no result that stands in for what the action produced as reaching the model', async () => {
const { sim, runner, executor, delivered, notDelivered } = setup()
const standIns: DesktopToolCompletion[] = [
{ status: 'cancelled', message: 'Stopped.' },
{ status: 'error', message: 'Too large.', data: { resultOmitted: true } },
{ status: 'error', message: 'Not started.', data: { notStarted: true } },
{ status: 'error', message: 'Unknown.', data: { outcomeUnknown: true } },
]
for (const [index, completion] of standIns.entries()) {
runner.immediate = completion
sim.inbox = [callItem(`stand-in-${index}`, 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(sim.completions).toHaveLength(index + 1))
}
runner.immediate = DONE
sim.inbox = [callItem('real', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(delivered).toEqual(['real']))
expect(notDelivered).toEqual(['stand-in-0', 'stand-in-1', 'stand-in-2', 'stand-in-3'])
})
it('claims a whole backlog at once, before any of it runs', async () => {
const { sim, runner, executor } = setup()
sim.inbox = [callItem('a-1', 'chat-a'), callItem('a-2', 'chat-a'), callItem('a-3', 'chat-a')]
await executor.reconcile()
expect(sim.claims).toEqual(['a-1', 'a-2', 'a-3'])
await vi.waitFor(() => expect(runner.started).toEqual(['a-1']))
})
it('runs one chat in inbox order and different chats side by side', async () => {
const { sim, runner, executor } = setup()
sim.inbox = [
callItem('a-1', 'chat-a'),
callItem('b-1', 'chat-b', 'terminal'),
callItem('a-2', 'chat-a'),
]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['a-1', 'b-1']))
runner.finish('b-1')
runner.finish('a-1')
await vi.waitFor(() => expect(runner.started).toEqual(['a-1', 'b-1', 'a-2']))
})
it('does not claim a call it already holds when the inbox lists it again', async () => {
const { sim, executor } = setup()
const item = callItem('call-1', 'chat-a')
sim.inbox = [item]
await executor.reconcile()
sim.inbox = [item]
await executor.reconcile()
expect(sim.claims).toEqual(['call-1'])
})
it.each([
[404, 'claimed elsewhere or settled'],
[403, 'still awaiting approval'],
[409, 'turn ended'],
])('drops a claim Sim refuses with %i (%s) without running anything', async (status) => {
const { sim, journal, runner, executor } = setup()
sim.claimError = () => new DeviceRequestError(status, 'refused')
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
expect(runner.started).toEqual([])
expect(journal.entries.size).toBe(0)
expect(executor.heldCallCount()).toBe(0)
})
it('stops claiming at its in-flight cap and claims the rest once a slot frees', async () => {
const { sim, runner, executor } = setup({ maxHeldCalls: 2 })
sim.inbox = [callItem('a-1', 'chat-a'), callItem('b-1', 'chat-b'), callItem('c-1', 'chat-c')]
await executor.reconcile()
expect(sim.claims).toEqual(['a-1', 'b-1'])
await vi.waitFor(() => expect(runner.started).toContain('a-1'))
runner.finish('a-1')
await vi.waitFor(() => expect(executor.heldCallCount()).toBe(1))
await executor.reconcile()
expect(sim.claims).toEqual(['a-1', 'b-1', 'c-1'])
})
it('does not claim while paused for sleep', async () => {
const { sim, executor } = setup()
sim.inbox = [callItem('call-1', 'chat-a')]
executor.setPaused(true)
await executor.reconcile()
expect(sim.claims).toEqual([])
})
it('records that a call started before the action begins', async () => {
const { sim, journal, runner, executor } = setup()
let stateAtStart: JournalEntry['state'] | undefined
runner.onStart = (call) => {
stateAtStart = journal.entries.get(call.toolCallId)?.state
}
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(stateAtStart).toBe('started'))
})
})
describe('leases', () => {
it('renews the lease while a call waits in its chat queue and while it runs', async () => {
const { sim, runner, executor } = setup({ leaseRenewMs: 20 })
sim.inbox = [callItem('a-1', 'chat-a'), callItem('a-2', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => {
expect(sim.renewals).toContain('a-1')
expect(sim.renewals).toContain('a-2')
})
expect(runner.started).toEqual(['a-1'])
runner.finish('a-1')
await vi.waitFor(() => expect(runner.started).toEqual(['a-1', 'a-2']))
runner.finish('a-2')
await vi.waitFor(() => expect(executor.heldCallCount()).toBe(0))
const renewalsAfterAck = sim.renewals.length
await sleep(80)
expect(sim.renewals.length).toBe(renewalsAfterAck)
})
it('stops the action when Sim revokes its lease', async () => {
const { sim, runner, executor } = setup({ leaseRenewMs: 20 })
sim.inbox = [callItem('call-1', 'chat-a')]
sim.renewError = () => new DeviceRequestError(410, 'revoked')
await executor.reconcile()
await vi.waitFor(() => expect(runner.cancelled).toEqual(['call-1']))
await vi.waitFor(() => expect(sim.completions.map((c) => c.toolCallId)).toEqual(['call-1']))
expect(executor.heldCallCount()).toBe(0)
})
})
describe('reporting', () => {
it('retries a result Sim did not acknowledge, without running the action again', async () => {
const { sim, journal, runner, executor } = setup()
runner.immediate = DONE
sim.completeErrors = [
new DeviceRequestError(0, 'offline'),
new DeviceRequestError(503, 'deploying'),
]
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(journal.history.map((entry) => entry.state)).toContain('result'))
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
expect(runner.started).toEqual(['call-1'])
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
})
it.each(['duplicate', 'superseded'] as const)(
'treats a %s completion as acknowledged',
async (outcome) => {
const { sim, journal, runner, executor } = setup()
runner.immediate = DONE
sim.completionOutcome = outcome
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
expect(sim.completions).toHaveLength(1)
}
)
})
describe('stopping', () => {
it('stops a running call the inbox lists as cancelled, and acknowledges it', async () => {
const { sim, runner, executor } = setup()
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
sim.inbox = [{ kind: 'cancel', toolCallId: 'call-1' }]
await executor.reconcile()
await vi.waitFor(() => expect(runner.cancelled).toEqual(['call-1']))
await vi.waitFor(() => expect(sim.completions.map((c) => c.toolCallId)).toEqual(['call-1']))
await vi.waitFor(() => expect(executor.heldCallCount()).toBe(0))
})
it('never starts a queued call that was stopped, and acknowledges the stop', async () => {
const { sim, runner, executor } = setup()
sim.inbox = [callItem('a-1', 'chat-a'), callItem('a-2', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['a-1']))
sim.inbox = [{ kind: 'cancel', toolCallId: 'a-2' }]
await executor.reconcile()
await vi.waitFor(() => expect(sim.completions.map((c) => c.toolCallId)).toEqual(['a-2']))
expect(sim.completions[0]?.completion.status).toBe('cancelled')
runner.finish('a-1')
await vi.waitFor(() => expect(executor.heldCallCount()).toBe(0))
expect(runner.started).toEqual(['a-1'])
})
it('sends nothing the stopped action produced, only the acknowledgement', async () => {
const { sim, runner, executor } = setup()
runner.ignoresAbort = true
sim.inbox = [callItem('call-1', 'chat-a', 'read_local_file')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
sim.inbox = [{ kind: 'cancel', toolCallId: 'call-1' }]
await executor.reconcile()
await vi.waitFor(() => expect(runner.cancelled).toEqual(['call-1']))
runner.finish('call-1', { status: 'success', message: 'read', data: { text: 'secret' } })
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
expect(sim.completions[0]?.completion.status).toBe('cancelled')
expect(JSON.stringify(sim.completions[0])).not.toContain('secret')
})
it('ignores a cancel for a call it never held', async () => {
const { sim, runner, executor } = setup()
sim.inbox = [{ kind: 'cancel', toolCallId: 'someone-else' }]
await executor.reconcile()
expect(runner.cancelled).toEqual([])
expect(sim.completions).toEqual([])
})
})
describe('restarting', () => {
it('reports what each journaled call reached, and never runs any of them again', async () => {
const { sim, journal, runner, executor } = setup()
await journal.put({ toolCallId: 'claiming-1', state: 'claiming' })
await journal.put({ toolCallId: 'claimed-1', state: 'claimed', executionToken: 't-claimed' })
await journal.put({ toolCallId: 'started-1', state: 'started', executionToken: 't-started' })
await journal.put({
toolCallId: 'result-1',
state: 'result',
executionToken: 't-result',
completion: DONE,
})
await executor.recover()
await vi.waitFor(() => expect(sim.completions).toHaveLength(3))
const reported = new Map(sim.completions.map((request) => [request.toolCallId, request]))
expect(reported.get('claimed-1')?.completion.data).toMatchObject({ notStarted: true })
expect(reported.get('claimed-1')?.executionToken).toBe('t-claimed')
expect(reported.get('started-1')?.completion.data).toMatchObject({
outcomeUnknown: true,
doNotRetry: true,
})
expect(reported.get('result-1')?.completion).toEqual(DONE)
expect(reported.has('claiming-1')).toBe(false)
expect(runner.started).toEqual([])
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
})
it('leaves open whether the model has a result when Sim already held one for the call', async () => {
const { sim, journal, executor, delivered, notDelivered } = setup()
// The previous run's send of the real result may have landed just before the app went down.
sim.completionOutcome = 'duplicate'
await journal.put({ toolCallId: 'started-1', state: 'started', executionToken: 't-started' })
await executor.recover()
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
expect(sim.completions).toHaveLength(1)
expect(delivered).toEqual([])
expect(notDelivered).toEqual([])
})
it('still leaves a recovered result open when Sim held one, after parking it', async () => {
const { sim, journal, executor, onUnregistered, delivered, notDelivered } = setup()
sim.completeErrors = [new DeviceRequestError(401, 'unregistered')]
sim.completionOutcome = 'duplicate'
await journal.put({ toolCallId: 'started-1', state: 'started', executionToken: 't-started' })
await executor.recover()
await vi.waitFor(() => expect(onUnregistered).toHaveBeenCalledTimes(1))
// Registered again.
executor.resumeParked()
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
expect(delivered).toEqual([])
expect(notDelivered).toEqual([])
})
it('reports a recovered result too large to send, then held as a duplicate, as never reaching the model', async () => {
const { sim, journal, executor, delivered, notDelivered } = setup()
// No run could have sent this result: it is too large every time.
sim.completeErrors = [new DeviceRequestError(413, 'too large')]
sim.completionOutcome = 'duplicate'
await journal.put({
toolCallId: 'result-1',
state: 'result',
executionToken: 't',
completion: DONE,
})
await executor.recover()
await vi.waitFor(() => expect(notDelivered).toEqual(['result-1']))
expect(delivered).toEqual([])
})
})
describe('delivery', () => {
it('retries a result whose request timed out', async () => {
const { sim, journal, executor } = setup()
sim.completeErrors = [new DeviceRequestError(408, 'request timeout')]
await journal.put({
toolCallId: 'r-1',
state: 'result',
executionToken: 't-1',
completion: DONE,
})
await executor.recover()
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
})
it('reports a result it cannot encode as outcome unknown, once, instead of retrying it', async () => {
const { sim, journal, executor } = setup()
const complete = sim.client.complete
let attempts = 0
sim.client.complete = async (request) => {
attempts += 1
if (request.completion.data && 'cyclic' in request.completion.data) {
throw new UnsendableRequestError('Converting circular structure to JSON')
}
return complete(request)
}
await journal.put({
toolCallId: 'r-1',
state: 'result',
executionToken: 't-1',
completion: { status: 'success', message: 'done', data: { cyclic: true } },
})
await executor.recover()
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
expect(attempts).toBe(2)
expect(sim.completions[0]?.completion).toMatchObject({
status: 'error',
data: { outcomeUnknown: true, doNotRetry: true },
})
})
it('holds a result Sim refuses as unregistered until the device registers again', async () => {
const { sim, journal, executor, onUnregistered } = setup()
sim.completeErrors = [new DeviceRequestError(401, 'unregistered')]
await journal.put({
toolCallId: 'r-1',
state: 'result',
executionToken: 't-1',
completion: DONE,
})
await executor.recover()
await vi.waitFor(() => expect(onUnregistered).toHaveBeenCalledTimes(1))
await sleep(40)
expect(onUnregistered).toHaveBeenCalledTimes(1)
expect(sim.completions).toHaveLength(0)
expect(journal.entries.get('r-1')?.state).toBe('result')
executor.resumeParked()
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
await vi.waitFor(() => expect(journal.entries.size).toBe(0))
})
})
describe('keeping the machine awake', () => {
it('is busy from the claim until Sim has the result', async () => {
const { sim, runner, executor, busy } = setup()
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
expect(busy).toEqual([true])
runner.finish('call-1')
await vi.waitFor(() => expect(busy).toEqual([true, false]))
expect(sim.completions).toHaveLength(1)
})
it('leaves nothing of the previous session behind when sign-out lands mid-recovery', async () => {
const { sim, journal, executor } = setup()
await journal.put({ toolCallId: 'c-1', state: 'claiming' })
await journal.put({
toolCallId: 'r-1',
state: 'result',
executionToken: 't-1',
completion: DONE,
})
const loaded = deferred<void>()
const load = journal.load.bind(journal)
journal.load = async () => {
await loaded.promise
return load()
}
const recovering = executor.recover()
const signingOut = executor.dispose()
loaded.resolve()
await Promise.all([recovering, signingOut])
await sleep(40)
expect(sim.completions).toEqual([])
expect(journal.entries.size).toBe(0)
})
it('goes idle at sign-out and stays silent when a recovered delivery settles afterwards', async () => {
const { sim, journal, executor, busy } = setup()
const answer = deferred<void>()
const complete = sim.client.complete
let sending = false
sim.client.complete = async (request) => {
sending = true
await answer.promise
return complete(request)
}
await journal.put({
toolCallId: 'r-1',
state: 'result',
executionToken: 't-1',
completion: DONE,
})
await executor.recover()
await vi.waitFor(() => expect(sending).toBe(true))
expect(busy).toEqual([true])
await executor.dispose()
expect(busy).toEqual([true, false])
answer.resolve()
await sleep(40)
expect(busy).toEqual([true, false])
})
it('lets the machine sleep once a result has kept failing to reach Sim, and keeps retrying', async () => {
const { sim, journal, executor, busy } = setup({ deliveryAwakeLimitMs: 20 })
sim.completeErrors = Array.from({ length: 6 }, () => new DeviceRequestError(503, 'deploying'))
await journal.put({
toolCallId: 'r-1',
state: 'result',
executionToken: 't-1',
completion: DONE,
})
await executor.recover()
await vi.waitFor(() => expect(busy).toEqual([true, false]))
expect(sim.completions).toHaveLength(0)
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
expect(busy).toEqual([true, false])
})
it('stays busy while a result a previous run left is still on its way to Sim', async () => {
const { sim, journal, executor, busy } = setup()
sim.completeErrors = [new DeviceRequestError(503, 'deploying')]
await journal.put({
toolCallId: 'result-1',
state: 'result',
executionToken: 't-result',
completion: DONE,
})
await executor.recover()
expect(busy).toEqual([true])
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
await vi.waitFor(() => expect(busy).toEqual([true, false]))
})
})
describe('registration', () => {
it('asks to register again when Sim no longer recognizes the device', async () => {
const { sim, executor, onUnregistered } = setup()
sim.claimError = () => new DeviceRequestError(401, 'unregistered')
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
expect(onUnregistered).toHaveBeenCalled()
})
it('drops everything it holds on sign-out', async () => {
const { sim, journal, runner, executor } = setup()
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
await executor.dispose()
expect(runner.cancelled).toEqual(['call-1'])
expect(journal.entries.size).toBe(0)
expect(executor.heldCallCount()).toBe(0)
})
it('stops running actions at sign-out without waiting on a claim still in flight', async () => {
const { sim, journal, runner, executor } = setup()
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
const answer = deferred<void>()
const claim = sim.client.claim
sim.client.claim = async (toolCallId) => {
await answer.promise
return claim(toolCallId)
}
sim.inbox = [callItem('call-2', 'chat-b')]
const reading = executor.reconcile()
await vi.waitFor(() => expect(journal.entries.get('call-2')?.state).toBe('claiming'))
const signingOut = executor.dispose()
await vi.waitFor(() => expect(runner.cancelled).toEqual(['call-1']))
answer.resolve()
await Promise.all([reading, signingOut])
expect(runner.started).toEqual(['call-1'])
expect(executor.heldCallCount()).toBe(0)
expect(journal.entries.size).toBe(0)
})
it('still claims the calls of an inbox read whose notifier throws', async () => {
const sim = new FakeSim()
const runner = new FakeRunner()
runner.immediate = DONE
const executor = new DesktopExecutor({
client: sim.client,
journal: new MemoryJournal(),
runner,
leaseRenewMs: 60_000,
retryBaseMs: 5,
onUnregistered: () => {},
onApprovals: () => {
throw new Error('notifications are unavailable')
},
})
sim.inbox = [callItem('call-1', 'chat-a')]
await expect(executor.reconcile()).resolves.toBeUndefined()
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
})
it('raises no approval from an inbox read that Sim answers after sign-out', async () => {
const { sim, executor, approvals } = setup()
const answer = deferred<void>()
const listInbox = sim.client.listInbox
sim.client.listInbox = async () => {
await answer.promise
return listInbox()
}
sim.inbox = [
{
kind: 'approval_needed',
toolCallId: 'gated-1',
toolName: 'terminal',
chatId: 'chat-a',
chatTitle: 'Fix CI',
workspaceId: 'ws-1',
summary: 'npm publish',
},
]
const reading = executor.reconcile()
const signingOut = executor.dispose()
answer.resolve()
await Promise.all([reading, signingOut])
expect(approvals).toEqual([])
})
it('does not keep a claim that Sim answers after sign-out', async () => {
const { sim, journal, runner, executor } = setup({ leaseRenewMs: 10 })
const answer = deferred<void>()
const claim = sim.client.claim
sim.client.claim = async (toolCallId) => {
await answer.promise
return claim(toolCallId)
}
sim.inbox = [callItem('call-1', 'chat-a')]
const reading = executor.reconcile()
await vi.waitFor(() => expect(journal.entries.get('call-1')?.state).toBe('claiming'))
const signingOut = executor.dispose()
answer.resolve()
await Promise.all([reading, signingOut])
await sleep(40)
expect(executor.heldCallCount()).toBe(0)
expect(runner.started).toEqual([])
expect(sim.renewals).toEqual([])
expect(journal.entries.size).toBe(0)
})
})
describe('recording', () => {
it('leaves a call on offer when it cannot record the claim', async () => {
const { sim, journal, executor } = setup()
journal.failOn = 'claiming'
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
expect(sim.claims).toEqual([])
})
it('never starts an action it could not record, and reports it as not run', async () => {
const { sim, journal, runner, executor } = setup()
journal.failOn = 'started'
sim.inbox = [callItem('call-1', 'chat-a')]
await executor.reconcile()
await vi.waitFor(() => expect(sim.completions).toHaveLength(1))
expect(sim.completions[0]?.completion.data).toMatchObject({ notStarted: true })
expect(runner.started).toEqual([])
})
})
@@ -0,0 +1,589 @@
/**
* The background executor's state machine: takes the calls Sim offers this device, runs each in
* its chat's own browser, terminal or file scope, and delivers every result until Sim
* acknowledges it, whether or not any window shows that chat.
*
* A call is claimed as soon as it is offered, then waits in a local queue per chat and surface,
* so a busy chat never lets a backlog miss Sim's pickup window. Its lease is renewed from claim
* until Sim acknowledges the result. Each step is journaled before the step it guards, so a
* restart reports a call that never started as not started, one that did as outcome unknown, and
* a finished one with its real result; none is ever run twice.
*/
import type { DesktopToolCompletion } from '@sim/desktop-bridge/tool-results'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { backoffWithJitter } from '@sim/utils/retry'
import {
type DesktopExecutorClient,
DeviceRequestError,
UnsendableRequestError,
} from '@/main/desktop-executor/client'
import type { ExecutorJournal, JournalEntry } from '@/main/desktop-executor/journal'
import type { ClaimedDesktopCall, DesktopInboxItem } from '@/main/desktop-executor/protocol'
const logger = createLogger('DesktopExecutor')
/** Claimed calls this device holds at once, across every chat. */
const DEFAULT_MAX_HELD_CALLS = 32
/** Ceiling on the wait between attempts to deliver one result. */
const DELIVERY_RETRY_MAX_MS = 30_000
/**
* How long a result that keeps failing to reach Sim holds the machine awake. Delivery keeps
* retrying after this; it just stops counting as work that needs the machine awake.
*/
const DELIVERY_AWAKE_LIMIT_MS = 10 * 60_000
const NOT_STARTED_AFTER_RESTART =
'Not run: this action never started, because the Sim desktop app restarted before it began. Nothing happened on the user’s computer. Do not retry it in this turn; tell the user, who can ask again.'
const OUTCOME_UNKNOWN_AFTER_RESTART =
'The Sim desktop app restarted while this action was running, so its result was lost. It may already have taken effect: inspect the current state before repeating it, and do not retry it automatically.'
const STOPPED_BEFORE_START = 'Stopped before the Sim desktop app started this action.'
const STOPPED_WHILE_RUNNING = 'Stopped while the Sim desktop app was running this action.'
const NOT_RECORDED =
'Not run: this action never started, because the Sim desktop app could not record it on the user’s computer first. Nothing happened on the user’s computer. Do not retry it in this turn; tell the user, who can ask again later.'
const RESULT_UNSENDABLE =
'The action finished, but its result could not be encoded to send back. It may have taken effect: inspect the current state before repeating it, and do not retry it automatically.'
const RESULT_TOO_LARGE =
'The action finished, but its result was too large to send back. Do not repeat a side-effecting action; inspect the current state instead.'
/** Runs one claimed call on this machine. Implementations never throw; failures are completions. */
export interface DesktopToolRunner {
run(call: ClaimedDesktopCall, signal: AbortSignal): Promise<DesktopToolCompletion>
/** Stops the action a running call started, through its surface's own cancel. */
cancel(call: ClaimedDesktopCall): Promise<void>
}
export type DesktopApprovalItem = Extract<DesktopInboxItem, { kind: 'approval_needed' }>
/**
* Whether a result hands the model what the action produced: not a stop, and not one standing in
* for an action that did not start, whose outcome is unknown, or whose output was too large to send.
*/
export function isDeliveredResult(completion: DesktopToolCompletion): boolean {
const data = completion.data
return (
completion.status !== 'cancelled' &&
data?.notStarted !== true &&
data?.outcomeUnknown !== true &&
data?.resultOmitted !== true
)
}
export interface DesktopExecutorOptions {
client: DesktopExecutorClient
journal: ExecutorJournal
runner: DesktopToolRunner
leaseRenewMs: number
/** Called when Sim no longer recognizes this device for the current session. */
onUnregistered: () => void
/** Every inbox read's calls that wait for the user's approval. */
onApprovals?: (items: DesktopApprovalItem[]) => void
/** Called whenever the number of held calls changes between zero and more. */
onBusyChange?: (busy: boolean) => void
/**
* Called once Sim has taken a call's real result ({@link isDeliveredResult}) as the call's own
* (recorded, or a duplicate of one it recorded): the model has it, so anything it hands back (a
* pane still running) is in use.
*/
onResultDelivered?: (toolCallId: string) => void
/**
* Called once Sim is done with a call without taking its real result: it settled the call
* first, refused the result, or recorded one that stands in for it. The model never learns of
* anything the action handed back as still going, so nothing will come back to it.
*/
onResultNotDelivered?: (toolCallId: string) => void
maxHeldCalls?: number
/** First delivery retry delay; tests shorten it. */
retryBaseMs?: number
/** How long a failing delivery keeps the machine awake; tests shorten it. */
deliveryAwakeLimitMs?: number
}
type HeldPhase = 'queued' | 'running' | 'reporting'
interface HeldCall {
call: ClaimedDesktopCall
phase: HeldPhase
controller: AbortController
renewTimer: ReturnType<typeof setInterval> | null
stopped: boolean
}
function surfaceOf(toolName: string): 'browser' | 'terminal' | 'files' {
if (toolName.startsWith('browser_')) return 'browser'
if (toolName === 'terminal') return 'terminal'
return 'files'
}
export class DesktopExecutor {
private readonly held = new Map<string, HeldCall>()
/** Each chat surface's tail, so calls in one chat run in the order they were offered. */
private readonly queues = new Map<string, Promise<void>>()
private reconciling: Promise<void> | null = null
private reconcileAgain = false
private paused = false
private disposed = false
private busy = false
/** The journal walk after a restart; sign-out waits for it before clearing the journal. */
private recovering: Promise<void> | null = null
/** Results a previous app run left, still on their way to Sim. */
private readonly recoveringIds = new Set<string>()
/**
* Results a previous app run left, until Sim answers for them, parked ones included: that run's
* send of the real result may already have landed.
*/
private readonly recoveredResults = new Set<string>()
/** Results that have failed to reach Sim for longer than {@link DELIVERY_AWAKE_LIMIT_MS}. */
private readonly stalledDeliveries = new Set<string>()
/** Results Sim refused because it no longer recognized this device; sent once it registers again. */
private readonly parked = new Map<
string,
{ executionToken: string; completion: DesktopToolCompletion }
>()
constructor(private readonly options: DesktopExecutorOptions) {}
heldCallCount(): number {
return this.held.size
}
/** Suspend stops new claims; held calls keep running and reporting. */
setPaused(paused: boolean): void {
this.paused = paused
}
/** Reports every call the journal says a previous run of the app left unfinished. */
async recover(): Promise<void> {
this.recovering = this.recoverEntries()
await this.recovering
}
private async recoverEntries(): Promise<void> {
const entries = await this.options.journal.load()
for (const entry of entries) {
// Signed out mid-recovery: the previous session's results stay unsent and are cleared.
if (this.disposed) return
if (entry.state === 'claiming') {
// Unknown whether the claim landed. If it did not, the inbox offers the call again. If it
// did, its token never reached this device, so nothing here can report it; Sim settles it
// as outcome unknown once its lease lapses, which is conservative because nothing ran.
await this.forget(entry.toolCallId)
continue
}
const completion: DesktopToolCompletion =
entry.state === 'result'
? entry.completion
: entry.state === 'claimed'
? {
status: 'error',
message: NOT_STARTED_AFTER_RESTART,
data: { error: NOT_STARTED_AFTER_RESTART, notStarted: true },
}
: {
status: 'error',
message: OUTCOME_UNKNOWN_AFTER_RESTART,
data: {
error: OUTCOME_UNKNOWN_AFTER_RESTART,
outcomeUnknown: true,
doNotRetry: true,
},
}
logger.info('Reporting a call left unfinished by the previous app run', {
toolCallId: entry.toolCallId,
state: entry.state,
})
// Held awake like a running call: the result exists only on this machine until Sim has it.
this.recoveringIds.add(entry.toolCallId)
this.recoveredResults.add(entry.toolCallId)
this.updateBusy()
void this.deliver(entry.toolCallId, entry.executionToken, completion).finally(() => {
this.recoveringIds.delete(entry.toolCallId)
this.updateBusy()
})
}
}
/** After registering again: sends the results parked while Sim did not recognize the device. */
resumeParked(): void {
const parked = [...this.parked]
this.parked.clear()
for (const [toolCallId, { executionToken, completion }] of parked) {
void this.deliver(toolCallId, executionToken, completion)
}
}
/**
* Reads the inbox and acts on it. Concurrent requests coalesce: one more read runs after the
* current one, so a doorbell that rings mid-read is never lost.
*/
reconcile(): Promise<void> {
if (this.reconciling) {
this.reconcileAgain = true
return this.reconciling
}
const run = async () => {
try {
do {
this.reconcileAgain = false
await this.reconcileOnce()
} while (this.reconcileAgain && !this.disposed)
} catch (error) {
// Callers fire and forget; one bad read must never become an unhandled rejection.
logger.error('Desktop inbox read failed unexpectedly', { error: getErrorMessage(error) })
} finally {
this.reconciling = null
}
}
this.reconciling = run()
return this.reconciling
}
/** Sign-out: stops every action and forgets every call; the session that owned them is gone. */
async dispose(): Promise<void> {
this.disposed = true
const stopping = this.dropHeld()
// A claim still in flight sees `disposed` once Sim answers and is never held; the journal is
// cleared only after it, so its `claiming` record does not outlive the session.
await this.reconciling?.catch(() => {})
await Promise.all([stopping, this.dropHeld()])
await this.recovering?.catch(() => {})
this.updateBusy()
await this.options.journal.clear()
}
/** Releases every held call and stops the actions already running. */
private async dropHeld(): Promise<void> {
const held = [...this.held.values()]
for (const entry of held) this.release(entry)
await Promise.allSettled(
held
.filter((entry) => entry.phase === 'running')
.map((entry) => {
entry.controller.abort()
return this.options.runner.cancel(entry.call)
})
)
}
private async reconcileOnce(): Promise<void> {
if (this.disposed) return
let items: DesktopInboxItem[]
try {
items = await this.options.client.listInbox()
} catch (error) {
this.noteRequestFailure('Could not read the desktop inbox', error)
return
}
// Signed out while the read was in flight: its approvals and calls belong to the old account.
if (this.disposed) return
for (const item of items) {
if (item.kind === 'cancel') void this.stop(item.toolCallId, 'Stopped by the user.')
}
try {
this.options.onApprovals?.(
items.filter((item): item is DesktopApprovalItem => item.kind === 'approval_needed')
)
} catch (error) {
// Notifications are a courtesy; the calls in this read still get claimed.
logger.warn('Could not notify about desktop approvals', { error: getErrorMessage(error) })
}
for (const item of items) {
if (item.kind !== 'call' || this.held.has(item.toolCallId)) continue
if (this.paused || this.disposed) return
if (this.held.size >= (this.options.maxHeldCalls ?? DEFAULT_MAX_HELD_CALLS)) return
await this.claim(item.toolCallId)
}
}
/** Writes a journal transition; false when it could not be made durable. */
private async record(entry: JournalEntry): Promise<boolean> {
try {
await this.options.journal.put(entry)
return true
} catch (error) {
logger.warn('Could not record a desktop call locally', {
toolCallId: entry.toolCallId,
state: entry.state,
error: getErrorMessage(error),
})
return false
}
}
private async forget(toolCallId: string): Promise<void> {
await this.options.journal.remove(toolCallId).catch((error) =>
logger.warn('Could not forget a desktop call locally', {
toolCallId,
error: getErrorMessage(error),
})
)
}
private async claim(toolCallId: string): Promise<void> {
// Unrecorded, a claim that then crashed could never be accounted for; leave it on offer.
if (!(await this.record({ toolCallId, state: 'claiming' }))) return
let call: ClaimedDesktopCall
try {
call = await this.options.client.claim(toolCallId)
} catch (error) {
await this.forget(toolCallId)
this.noteRequestFailure('Desktop call was not claimed', error, { toolCallId })
return
}
if (this.disposed) return
// Best effort: an unrecorded token leaves `claiming`, which recovery treats conservatively.
await this.record({ toolCallId, state: 'claimed', executionToken: call.executionToken })
const entry: HeldCall = {
call,
phase: 'queued',
controller: new AbortController(),
renewTimer: null,
stopped: false,
}
this.held.set(toolCallId, entry)
this.updateBusy()
entry.renewTimer = setInterval(() => void this.renew(entry), this.options.leaseRenewMs)
logger.info('Claimed a desktop call', {
toolCallId,
toolName: call.toolName,
chatId: call.chatId,
})
this.enqueue(entry)
}
private enqueue(entry: HeldCall): void {
const key = `${entry.call.chatId}:${surfaceOf(entry.call.toolName)}`
const tail = this.queues.get(key) ?? Promise.resolve()
const next = tail.then(() => this.execute(entry))
this.queues.set(key, next)
void next.finally(() => {
if (this.queues.get(key) === next) this.queues.delete(key)
})
}
private async execute(entry: HeldCall): Promise<void> {
if (entry.stopped || this.disposed) return
const { call } = entry
const recorded = await this.record({
toolCallId: call.toolCallId,
state: 'started',
executionToken: call.executionToken,
})
if (entry.stopped || this.disposed) return
entry.phase = 'reporting'
if (!recorded) {
// Running it unrecorded could let a crash report an action that ran as never started.
await this.deliver(call.toolCallId, call.executionToken, {
status: 'error',
message: NOT_RECORDED,
data: { error: NOT_RECORDED, notStarted: true },
})
this.release(entry)
return
}
entry.phase = 'running'
const completion = await this.options.runner.run(call, entry.controller.signal)
if (this.disposed || this.held.get(call.toolCallId) !== entry) return
entry.phase = 'reporting'
// A stopped call's result is only an acknowledgement: Sim already settled it, so nothing the
// action produced (page text, file contents) leaves the machine.
await this.deliver(
call.toolCallId,
call.executionToken,
entry.stopped ? { status: 'cancelled', message: STOPPED_WHILE_RUNNING } : completion
)
this.release(entry)
}
/**
* Delivers a result until Sim acknowledges it. Any answer Sim gives for the token acknowledges
* it: recorded, a duplicate of one already recorded, or superseded by Sim settling it first.
*/
private async deliver(
toolCallId: string,
executionToken: string,
completion: DesktopToolCompletion
): Promise<void> {
if (this.disposed) return
// Best effort: unrecorded, a crash reports the call from its `started` entry as outcome unknown.
await this.record({ toolCallId, state: 'result', executionToken, completion })
const sendingSince = Date.now()
try {
await this.sendResult(toolCallId, executionToken, completion, sendingSince)
} finally {
// Whoever holds the delivery (a held call, a recovered result) updates the busy state as it
// lets go; doing it here first would flash the machine awake again in between.
this.stalledDeliveries.delete(toolCallId)
}
}
private async sendResult(
toolCallId: string,
executionToken: string,
completion: DesktopToolCompletion,
sendingSince: number
): Promise<void> {
let pending = completion
let notDelivered = true
for (let attempt = 1; !this.disposed; attempt++) {
try {
const outcome = await this.options.client.complete({
toolCallId,
executionToken,
completion: pending,
})
logger.info('Desktop call result acknowledged', { toolCallId, outcome })
// Superseded: Sim settled the call first, so this result never reached the model.
const delivered = outcome !== 'superseded' && isDeliveredResult(pending)
if (delivered) this.options.onResultDelivered?.(toolCallId)
// A duplicate holds what this app run sent, unless the result was recovered unchanged from
// an earlier run, whose send of the real result may have landed before the restart.
const takenEarlier =
outcome === 'duplicate' && pending === completion && this.recoveredResults.has(toolCallId)
notDelivered = !delivered && !takenEarlier
break
} catch (error) {
// Encoding failed on this machine, so nothing was sent; the same data would fail again.
if (error instanceof UnsendableRequestError && pending.data !== undefined) {
pending = {
status: 'error',
message: RESULT_UNSENDABLE,
data: { error: RESULT_UNSENDABLE, outcomeUnknown: true, doNotRetry: true },
}
continue
}
if (!(error instanceof DeviceRequestError)) {
logger.error('Could not send a desktop call result; dropping it', {
toolCallId,
error: getErrorMessage(error),
})
break
}
if (error.unregistered) {
// Retrying cannot help until the device registers again; the journal keeps the result.
this.parked.set(toolCallId, { executionToken, completion: pending })
this.options.onUnregistered()
return
}
if (error.status === 413 && pending.data !== undefined) {
pending = {
status: pending.status,
message: RESULT_TOO_LARGE,
data: { error: RESULT_TOO_LARGE, resultOmitted: true },
}
continue
}
if (!error.transient) {
logger.warn('Sim refused a desktop call result; dropping it', {
toolCallId,
status: error.status,
})
break
}
if (
Date.now() - sendingSince >
(this.options.deliveryAwakeLimitMs ?? DELIVERY_AWAKE_LIMIT_MS) &&
!this.stalledDeliveries.has(toolCallId)
) {
// Still retried, but no longer a reason to keep the machine awake.
this.stalledDeliveries.add(toolCallId)
this.updateBusy()
}
await sleep(
backoffWithJitter(attempt, error.retryAfterMs, {
baseMs: this.options.retryBaseMs,
maxMs: DELIVERY_RETRY_MAX_MS,
})
)
}
}
this.recoveredResults.delete(toolCallId)
if (this.disposed) return
if (notDelivered) this.options.onResultNotDelivered?.(toolCallId)
await this.forget(toolCallId)
}
private async renew(entry: HeldCall): Promise<void> {
if (this.held.get(entry.call.toolCallId) !== entry) return
try {
await this.options.client.renewLease(entry.call.toolCallId, entry.call.executionToken)
} catch (error) {
if (error instanceof DeviceRequestError && error.status === 410) {
this.clearRenewal(entry)
await this.stop(entry.call.toolCallId, 'Sim no longer holds this call for this device.')
return
}
this.noteRequestFailure('Could not renew a desktop call lease', error, {
toolCallId: entry.call.toolCallId,
})
}
}
/**
* Stops a held call Sim settled without it. A queued call never starts; a running one is
* interrupted. Either way its result is still delivered, which acknowledges the stop.
*/
private async stop(toolCallId: string, reason: string): Promise<void> {
const entry = this.held.get(toolCallId)
if (!entry || entry.stopped) return
entry.stopped = true
logger.info('Stopping a desktop call', { toolCallId, phase: entry.phase, reason })
if (entry.phase === 'queued') {
entry.phase = 'reporting'
await this.deliver(toolCallId, entry.call.executionToken, {
status: 'cancelled',
message: STOPPED_BEFORE_START,
data: { error: STOPPED_BEFORE_START, notStarted: true },
})
this.release(entry)
return
}
if (entry.phase === 'running') {
entry.controller.abort()
await this.options.runner.cancel(entry.call).catch((error) =>
logger.warn('Could not stop a desktop action', {
toolCallId,
error: getErrorMessage(error),
})
)
}
}
private release(entry: HeldCall): void {
this.clearRenewal(entry)
if (this.held.get(entry.call.toolCallId) === entry) this.held.delete(entry.call.toolCallId)
this.updateBusy()
}
private clearRenewal(entry: HeldCall): void {
if (entry.renewTimer) clearInterval(entry.renewTimer)
entry.renewTimer = null
}
private updateBusy(): void {
// A disposed executor reports idle once, at dispose; a delivery that settles later must not
// speak for the executor that replaced it.
const awake = (toolCallId: string) => !this.stalledDeliveries.has(toolCallId)
const busy =
!this.disposed && ([...this.held.keys()].some(awake) || [...this.recoveringIds].some(awake))
if (busy === this.busy) return
this.busy = busy
this.options.onBusyChange?.(busy)
}
private noteRequestFailure(
message: string,
error: unknown,
context: Record<string, unknown> = {}
): void {
if (error instanceof DeviceRequestError && error.unregistered) {
this.options.onUnregistered()
}
logger.warn(message, {
...context,
...(error instanceof DeviceRequestError ? { status: error.status } : {}),
error: getErrorMessage(error),
})
}
}
@@ -0,0 +1,134 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
import { createExecutorJournal } from '@/main/desktop-executor/journal'
function testEncryption(available = true) {
return {
isEncryptionAvailable: vi.fn(() => available),
encryptString: vi.fn((value: string) => Buffer.from(`protected:${value}`, 'utf8')),
decryptString: vi.fn((value: Buffer) => value.toString('utf8').replace(/^protected:/, '')),
}
}
async function journalPath(): Promise<string> {
return join(await mkdtemp(join(tmpdir(), 'sim-executor-journal-')), 'journal.json')
}
const RESULT = {
toolCallId: 'call-1',
state: 'result' as const,
executionToken: 'token-1',
completion: { status: 'success' as const, message: 'done', data: { secret: 'page text' } },
}
describe('executor journal', () => {
it('survives a restart, encrypted at rest', async () => {
const filePath = await journalPath()
const encryption = testEncryption()
await createExecutorJournal(filePath, encryption).put(RESULT)
const raw = await readFile(filePath, 'utf8')
expect(raw).not.toContain('page text')
expect(raw).not.toContain('token-1')
await expect(createExecutorJournal(filePath, encryption).load()).resolves.toEqual([RESULT])
})
it('removes the file once every call is acknowledged', async () => {
const filePath = await journalPath()
const journal = createExecutorJournal(filePath, testEncryption())
await journal.put(RESULT)
await journal.remove('call-1')
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
})
it('applies transitions in order, so an earlier write cannot resurrect a removed call', async () => {
const filePath = await journalPath()
const encryption = testEncryption()
const journal = createExecutorJournal(filePath, encryption)
await Promise.all([
journal.put({ toolCallId: 'call-1', state: 'claimed', executionToken: 'token-1' }),
journal.put(RESULT),
journal.remove('call-1'),
])
await expect(createExecutorJournal(filePath, encryption).load()).resolves.toEqual([])
})
it('writes nothing in plaintext when OS encryption is unavailable', async () => {
const filePath = await journalPath()
const journal = createExecutorJournal(filePath, testEncryption(false))
await journal.put(RESULT)
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
})
it('reports a transition it could not make durable', async () => {
const encryption = testEncryption()
encryption.encryptString.mockImplementation(() => {
throw new Error('keychain locked')
})
await expect(
createExecutorJournal(await journalPath(), encryption).put(RESULT)
).rejects.toThrow('keychain locked')
})
it('keeps results within what a restart can read back, dropping the data of the excess', async () => {
const filePath = await journalPath()
const encryption = testEncryption()
const journal = createExecutorJournal(filePath, encryption)
const screenshot = 'x'.repeat(20 * 1024 * 1024)
for (const toolCallId of ['call-1', 'call-2']) {
await journal.put({
toolCallId,
state: 'result',
executionToken: `token-${toolCallId}`,
completion: { status: 'success', message: 'done', data: { screenshot } },
})
}
const restored = await createExecutorJournal(filePath, encryption).load()
expect(restored).toHaveLength(2)
const kept = restored.filter(
(entry) => entry.state === 'result' && entry.completion.data?.screenshot === screenshot
)
expect(kept).toHaveLength(1)
expect(JSON.stringify(restored)).toContain('resultOmitted')
})
it('budgets results by their encoded size, not their length in characters', async () => {
const filePath = await journalPath()
const encryption = testEncryption()
const journal = createExecutorJournal(filePath, encryption)
// Three bytes per character: within the budget by length, three times over it in bytes.
const pageText = '€'.repeat(6 * 1024 * 1024)
for (const toolCallId of ['call-1', 'call-2', 'call-3']) {
await journal.put({
toolCallId,
state: 'result',
executionToken: `token-${toolCallId}`,
completion: { status: 'success', message: 'done', data: { pageText } },
})
}
const restored = await createExecutorJournal(filePath, encryption).load()
expect(restored).toHaveLength(3)
const kept = restored.filter(
(entry) => entry.state === 'result' && entry.completion.data?.pageText === pageText
)
expect(kept).toHaveLength(1)
})
it('starts empty from a corrupt or foreign file instead of failing', async () => {
const filePath = await journalPath()
await writeFile(filePath, '{"version":1,"ciphertext":"not-base64-json"}')
await expect(createExecutorJournal(filePath, testEncryption()).load()).resolves.toEqual([])
})
})
@@ -0,0 +1,205 @@
/**
* The background executor's durable outbox: one entry per call this device took, advanced before
* each step it guards, so a restart knows exactly how far each call got.
*
* - `claiming`: a claim request is in flight; nothing ran.
* - `claimed`: this device owns the call (its token is recorded) but has not started it.
* - `started`: the action may have begun on this machine.
* - `result`: the action finished; its completion is waiting for Sim to acknowledge it.
*
* An acknowledged entry is removed. The file is encrypted with Electron safeStorage and written
* atomically, like every other account-bearing store in userData; without OS encryption nothing
* is written and the journal lives in memory only.
*/
import type { DesktopToolCompletion } from '@sim/desktop-bridge/tool-results'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { safeStorage } from 'electron'
import {
FileResourceLimitError,
readFileWithinLimit,
removeFileIfPresent,
writeJsonFileAtomically,
} from '@/main/atomic-json-file'
const logger = createLogger('DesktopExecutorJournal')
const JOURNAL_VERSION = 1
/** Results can carry a screenshot, so the bound is the size of a few of them. */
const MAX_JOURNAL_BYTES = 64 * 1024 * 1024
/**
* Result data kept on disk across all entries, in UTF-8 bytes. Encryption and base64 grow it by
* about a third, so a journal within this budget always reads back under {@link MAX_JOURNAL_BYTES}.
*/
const MAX_PERSISTED_RESULT_BYTES = 32 * 1024 * 1024
const RESULT_NOT_KEPT =
'The action finished, but its result was too large to keep on this computer. Do not repeat a side-effecting action; inspect the current state instead.'
/**
* The entries as written to disk: a result whose data would push the journal past its budget is
* kept as finished without its data, so the file never grows past what a restart can read.
*/
function boundedEntries(entries: Map<string, JournalEntry>): JournalEntry[] {
let budget = MAX_PERSISTED_RESULT_BYTES
return [...entries.values()].map((entry) => {
if (entry.state !== 'result' || entry.completion.data === undefined) return entry
const size = Buffer.byteLength(JSON.stringify(entry.completion.data), 'utf8')
if (size <= budget) {
budget -= size
return entry
}
return {
...entry,
completion: {
status: entry.completion.status,
message: RESULT_NOT_KEPT,
data: { error: RESULT_NOT_KEPT, resultOmitted: true },
},
}
})
}
export type JournalEntry =
| { toolCallId: string; state: 'claiming' }
| { toolCallId: string; state: 'claimed' | 'started'; executionToken: string }
| {
toolCallId: string
state: 'result'
executionToken: string
completion: DesktopToolCompletion
}
export interface ExecutorJournal {
load(): Promise<JournalEntry[]>
put(entry: JournalEntry): Promise<void>
remove(toolCallId: string): Promise<void>
clear(): Promise<void>
}
interface EncryptionProvider {
isEncryptionAvailable(): boolean
encryptString(value: string): Buffer
decryptString(value: Buffer): string
}
function encryptionAvailable(encryption: EncryptionProvider): boolean {
try {
return encryption.isEncryptionAvailable()
} catch {
return false
}
}
function isCompletion(value: unknown): value is DesktopToolCompletion {
return (
isRecordLike(value) &&
(value.status === 'success' || value.status === 'error' || value.status === 'cancelled') &&
typeof value.message === 'string' &&
(value.data === undefined || isRecordLike(value.data))
)
}
function parseEntry(value: unknown): JournalEntry | null {
if (!isRecordLike(value) || typeof value.toolCallId !== 'string' || !value.toolCallId) return null
const { toolCallId } = value
if (value.state === 'claiming') return { toolCallId, state: 'claiming' }
if (typeof value.executionToken !== 'string' || !value.executionToken) return null
const { executionToken } = value
if (value.state === 'claimed' || value.state === 'started') {
return { toolCallId, state: value.state, executionToken }
}
if (value.state === 'result' && isCompletion(value.completion)) {
return { toolCallId, state: 'result', executionToken, completion: value.completion }
}
return null
}
export function createExecutorJournal(
filePath: string,
encryption: EncryptionProvider = safeStorage
): ExecutorJournal {
const entries = new Map<string, JournalEntry>()
let mutationTail = Promise.resolve()
const enqueue = (operation: () => Promise<void>): Promise<void> => {
const result = mutationTail.then(operation)
mutationTail = result.catch(() => undefined)
return result
}
/**
* Rewrites the whole journal. A failure rejects, so the caller knows the transition is not
* durable; the in-memory journal stays current and the next transition rewrites it all.
*/
const persist = async (): Promise<void> => {
if (!encryptionAvailable(encryption)) return
if (entries.size === 0) {
await removeFileIfPresent(filePath)
return
}
const payload = JSON.stringify({ version: JOURNAL_VERSION, entries: boundedEntries(entries) })
const ciphertext = encryption.encryptString(payload).toString('base64')
await writeJsonFileAtomically(filePath, { version: JOURNAL_VERSION, ciphertext })
}
return {
async load() {
await enqueue(async () => {
entries.clear()
if (!encryptionAvailable(encryption)) return
let raw: Buffer
try {
raw = await readFileWithinLimit(filePath, MAX_JOURNAL_BYTES)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
logger.warn('Could not read the executor journal', {
error: error instanceof FileResourceLimitError ? 'too large' : getErrorMessage(error),
})
return
}
try {
const envelope = JSON.parse(raw.toString('utf8')) as unknown
if (
!isRecordLike(envelope) ||
envelope.version !== JOURNAL_VERSION ||
typeof envelope.ciphertext !== 'string'
) {
return
}
const payload = JSON.parse(
encryption.decryptString(Buffer.from(envelope.ciphertext, 'base64'))
) as unknown
if (!isRecordLike(payload) || !Array.isArray(payload.entries)) return
for (const candidate of payload.entries) {
const entry = parseEntry(candidate)
if (entry) entries.set(entry.toolCallId, entry)
}
} catch (error) {
logger.warn('Discarding an unreadable executor journal', {
error: getErrorMessage(error),
})
}
})
return [...entries.values()]
},
put(entry) {
return enqueue(async () => {
entries.set(entry.toolCallId, entry)
await persist()
})
},
remove(toolCallId) {
return enqueue(async () => {
if (!entries.delete(toolCallId)) return
await persist()
})
},
clear() {
return enqueue(async () => {
entries.clear()
await removeFileIfPresent(filePath)
})
},
}
}
@@ -0,0 +1,206 @@
/**
* The device side of Sim's background executor protocol (`/api/desktop/devices`, `/inbox`,
* `/inbox/stream`, `/tool/claim`, `/tool/lease`, `/tool/complete`, `/tool/import`). Sim's contracts are the
* source of truth; responses are parsed defensively here because a malformed one must never
* reach a tool.
*/
import { isDesktopScopeId } from '@sim/desktop-bridge'
import type { DesktopToolCompletion } from '@sim/desktop-bridge/tool-results'
import { isRecordLike } from '@sim/utils/object'
/** The executor protocol version this build speaks. */
export const DESKTOP_EXECUTOR_PROTOCOL_VERSION = 1
/** Sim refuses a longer completion message, so longer ones are cut before they are sent. */
export const COMPLETION_MESSAGE_MAX_CHARS = 10_000
export interface DesktopDeviceRegistration {
deviceId: string
name: string
appVersion: string
platform: string
capabilities: { executor: number; browser: boolean; terminal: boolean; localFiles: boolean }
}
/** Sim's answer to a registration. `enabled: false` means no new turn will bind to this device. */
export interface DesktopExecutorTiming {
enabled: boolean
protocolVersion: number
leaseMs: number
leaseRenewMs: number
reconcileMs: number
}
export type DesktopInboxItem =
| {
kind: 'call'
toolCallId: string
toolName: string
chatId: string
workspaceId: string | null
}
| {
kind: 'approval_needed'
toolCallId: string
toolName: string
chatId: string
chatTitle: string | null
workspaceId: string | null
summary: string | null
}
| { kind: 'cancel'; toolCallId: string }
/** A call this device now owns: the server's canonical arguments and the token that fences it. */
export interface ClaimedDesktopCall {
toolCallId: string
toolName: string
args: Record<string, unknown>
chatId: string
workspaceId: string | null
executionToken: string
}
export type DesktopCompletionOutcome = 'recorded' | 'duplicate' | 'superseded'
/**
* One entry of a claimed import, stored under the call's target folder: `sourceName` is the
* import source's own name and `relativePath` the entry's place inside it (`''` for the source).
*/
export interface DesktopImportEntryRequest {
call: ClaimedDesktopCall
kind: 'file' | 'directory'
sourceName: string
relativePath: string
/** A file's bytes; a directory has none. */
content?: Blob
}
/** What Sim stored an import entry as: the file, or the folder it reused or created. */
export interface DesktopImportedEntry {
id: string
name: string
}
export interface DesktopCompletionRequest {
toolCallId: string
executionToken: string
completion: DesktopToolCompletion
}
function positiveInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0
}
function nullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
export function parseRegistration(body: unknown): DesktopExecutorTiming | null {
if (
!isRecordLike(body) ||
typeof body.enabled !== 'boolean' ||
!positiveInteger(body.protocolVersion) ||
!positiveInteger(body.leaseMs) ||
!positiveInteger(body.leaseRenewMs) ||
!positiveInteger(body.reconcileMs)
) {
return null
}
return {
enabled: body.enabled,
protocolVersion: body.protocolVersion,
leaseMs: body.leaseMs,
leaseRenewMs: body.leaseRenewMs,
reconcileMs: body.reconcileMs,
}
}
function parseInboxItem(value: unknown): DesktopInboxItem | null {
if (!isRecordLike(value) || typeof value.toolCallId !== 'string' || !value.toolCallId) return null
const { toolCallId } = value
if (value.kind === 'cancel') return { kind: 'cancel', toolCallId }
if (
typeof value.toolName !== 'string' ||
typeof value.chatId !== 'string' ||
!isDesktopScopeId(value.chatId) ||
!nullableString(value.workspaceId)
) {
return null
}
if (value.kind === 'call') {
return {
kind: 'call',
toolCallId,
toolName: value.toolName,
chatId: value.chatId,
workspaceId: value.workspaceId,
}
}
if (
value.kind === 'approval_needed' &&
nullableString(value.chatTitle) &&
nullableString(value.summary)
) {
return {
kind: 'approval_needed',
toolCallId,
toolName: value.toolName,
chatId: value.chatId,
chatTitle: value.chatTitle,
workspaceId: value.workspaceId,
summary: value.summary,
}
}
return null
}
/** Unknown item kinds are skipped, so a newer Sim can add one without breaking this build. */
export function parseInbox(body: unknown): DesktopInboxItem[] | null {
if (!isRecordLike(body) || !Array.isArray(body.items)) return null
return body.items.flatMap((item) => {
const parsed = parseInboxItem(item)
return parsed ? [parsed] : []
})
}
export function parseClaim(toolCallId: string, body: unknown): ClaimedDesktopCall | null {
if (
!isRecordLike(body) ||
typeof body.toolName !== 'string' ||
!isRecordLike(body.args) ||
typeof body.chatId !== 'string' ||
!isDesktopScopeId(body.chatId) ||
!nullableString(body.workspaceId) ||
typeof body.executionToken !== 'string' ||
!body.executionToken
) {
return null
}
return {
toolCallId,
toolName: body.toolName,
args: body.args,
chatId: body.chatId,
workspaceId: body.workspaceId,
executionToken: body.executionToken,
}
}
export function parseCompletionOutcome(body: unknown): DesktopCompletionOutcome | null {
if (!isRecordLike(body)) return null
return body.outcome === 'recorded' ||
body.outcome === 'duplicate' ||
body.outcome === 'superseded'
? body.outcome
: null
}
/**
* Reads Sim's answer to an import entry: the id and name it stored the entry as. Null when either
* is missing or empty, which the client reports as a malformed response.
*/
export function parseImportedEntry(body: unknown): DesktopImportedEntry | null {
if (!isRecordLike(body) || typeof body.id !== 'string' || typeof body.name !== 'string')
return null
return body.id && body.name ? { id: body.id, name: body.name } : null
}
@@ -0,0 +1,298 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { TerminalToolResponse } from '@sim/terminal-protocol'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DeviceRequestError } from '@/main/desktop-executor/client'
import type {
ClaimedDesktopCall,
DesktopImportEntryRequest,
} from '@/main/desktop-executor/protocol'
import { createDesktopToolRunner, type DesktopToolRunnerDeps } from '@/main/desktop-executor/runner'
import { executeLocalFileRequest } from '@/main/local-files'
function terminalCall(toolCallId: string, operation: string): ClaimedDesktopCall {
return {
toolCallId,
toolName: 'terminal',
args: { operation, args: {} },
chatId: 'chat-b',
workspaceId: 'ws-1',
executionToken: `token-${toolCallId}`,
}
}
function runner(overrides: Partial<DesktopToolRunnerDeps> = {}) {
return createDesktopToolRunner({
preferences: () => ({ browserEnabled: true, terminalEnabled: true }),
accountDataAvailable: () => true,
browser: {
executeTool: vi.fn(),
cancelTool: vi.fn(),
hasSession: () => true,
restoreScope: vi.fn(),
},
terminal: { executeTool: vi.fn(), cancelTool: vi.fn(async () => true) },
localFiles: {
request: (call, request) =>
executeLocalFileRequest(request, { toolName: call.toolName, args: call.args }),
},
imports: { importEntry: vi.fn() },
localFilesystem: { handle: vi.fn(), vfsRoot: () => 'user-local/x--1' },
...overrides,
})
}
function runnerWithTerminal(executeTool: (toolCallId: string) => Promise<TerminalToolResponse>) {
return runner({
terminal: {
executeTool: (_scope, toolCallId) => executeTool(toolCallId),
cancelTool: vi.fn(async () => true),
},
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('background terminal calls', () => {
it('holds a chat terminal for an operation that outlived its deadline', async () => {
vi.useFakeTimers()
let releaseWedged: (response: TerminalToolResponse) => void = () => {}
const started: string[] = []
const runner = runnerWithTerminal((toolCallId) => {
started.push(toolCallId)
if (toolCallId === 'wedged')
return new Promise((resolve) => {
releaseWedged = resolve
})
return Promise.resolve({ ok: true, result: { output: '' } })
})
const first = runner.run(terminalCall('wedged', 'input'), new AbortController().signal)
await vi.advanceTimersByTimeAsync(15_000)
const timedOut = await first
expect(timedOut.status).toBe('error')
expect(timedOut.data).toMatchObject({ outcomeUnknown: true, doNotRetry: true })
const second = runner.run(terminalCall('next', 'read'), new AbortController().signal)
await vi.advanceTimersByTimeAsync(0)
expect(started).toEqual(['wedged'])
releaseWedged({ ok: true, result: {} })
expect((await second).status).toBe('success')
expect(started).toEqual(['wedged', 'next'])
})
it('never starts a terminal operation stopped while it waited for the chat terminal', async () => {
vi.useFakeTimers()
let releaseWedged: (response: TerminalToolResponse) => void = () => {}
const started: string[] = []
const runner = runnerWithTerminal((toolCallId) => {
started.push(toolCallId)
if (toolCallId === 'wedged')
return new Promise((resolve) => {
releaseWedged = resolve
})
return Promise.resolve({ ok: true, result: { output: '' } })
})
const first = runner.run(terminalCall('wedged', 'input'), new AbortController().signal)
await vi.advanceTimersByTimeAsync(15_000)
await first
const stop = new AbortController()
const second = runner.run(terminalCall('stopped', 'run'), stop.signal)
await vi.advanceTimersByTimeAsync(0)
stop.abort()
const completion = await second
releaseWedged({ ok: true, result: {} })
await vi.advanceTimersByTimeAsync(0)
expect(completion.status).toBe('error')
expect(started).toEqual(['wedged'])
})
})
describe('local file calls', () => {
it('names a passing storage state, not a setting, when local files are out of reach', async () => {
const completion = await runner({ accountDataAvailable: () => false }).run(
{
toolCallId: 'read-1',
toolName: 'read_local_file',
args: { path: '~/notes.txt' },
chatId: 'chat-b',
workspaceId: 'ws-1',
executionToken: 'token-read-1',
},
new AbortController().signal
)
expect(completion.data).toMatchObject({ notStarted: true })
expect(completion.message).toContain('cannot reach local files')
expect(completion.message).not.toContain('settings')
})
})
describe('background imports', () => {
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
/** A `Reports` folder holding `q3/summary.txt` and `notes.txt`. */
async function reportsFolder(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'sim-runner-import-'))
roots.push(root)
const reports = join(root, 'Reports')
await mkdir(join(reports, 'q3'), { recursive: true })
await writeFile(join(reports, 'q3', 'summary.txt'), 'quarterly numbers')
await writeFile(join(reports, 'notes.txt'), 'remember')
return reports
}
function importCall(path: string): ClaimedDesktopCall {
return {
toolCallId: 'import-1',
toolName: 'import_local_files',
args: { path, targetWorkspaceId: 'ws-1', folderId: 'folder-1' },
chatId: 'chat-b',
workspaceId: 'ws-1',
executionToken: 'token-import-1',
}
}
/**
* A fake Sim import route: records every entry it stores, with each file's bytes as text, and
* the token each request presented. `answer` can refuse a request instead.
*/
function recordingSim(answer?: (request: DesktopImportEntryRequest) => Error | null) {
const stored: Array<{ kind: string; relativePath: string; text?: string }> = []
const tokens: string[] = []
let requests = 0
const importEntry = async (request: DesktopImportEntryRequest) => {
requests += 1
tokens.push(request.call.executionToken)
const refusal = answer?.(request)
if (refusal) throw refusal
stored.push({
kind: request.kind,
relativePath: request.relativePath,
...(request.content ? { text: await request.content.text() } : {}),
})
return { id: `id-${stored.length}`, name: request.relativePath || request.sourceName }
}
return { stored, tokens, importEntry, requests: () => requests }
}
it("stores a folder's tree in Sim, each file with the bytes on disk", async () => {
const sim = recordingSim()
const completion = await runner({ imports: { importEntry: sim.importEntry } }).run(
importCall(await reportsFolder()),
new AbortController().signal
)
expect(completion.status).toBe('success')
expect(sim.stored).toEqual([
{ kind: 'directory', relativePath: '' },
{ kind: 'file', relativePath: 'notes.txt', text: 'remember' },
{ kind: 'directory', relativePath: 'q3' },
{ kind: 'file', relativePath: 'q3/summary.txt', text: 'quarterly numbers' },
])
expect(new Set(sim.tokens)).toEqual(new Set(['token-import-1']))
expect(completion.data).toMatchObject({
success: true,
workspaceId: 'ws-1',
folders: [
{ id: 'id-1', relativePath: '' },
{ id: 'id-3', relativePath: 'q3' },
],
files: [
{ id: 'id-2', relativePath: 'notes.txt' },
{ id: 'id-4', relativePath: 'q3/summary.txt' },
],
})
})
it('reports what landed when an import stops part way, and not to retry it', async () => {
const sim = recordingSim((request) =>
request.relativePath === 'q3/summary.txt' ? new Error('Sim refused the entry') : null
)
const completion = await runner({ imports: { importEntry: sim.importEntry } }).run(
importCall(await reportsFolder()),
new AbortController().signal
)
expect(completion.status).toBe('error')
expect(completion.data).toMatchObject({
success: false,
partial: true,
doNotRetry: true,
outcomeUnknown: true,
error: 'Sim refused the entry',
files: [{ id: 'id-2', relativePath: 'notes.txt' }],
})
})
it('stores nothing more once the call is stopped', async () => {
const controller = new AbortController()
const sim = recordingSim(() => {
controller.abort()
return null
})
const completion = await runner({ imports: { importEntry: sim.importEntry } }).run(
importCall(await reportsFolder()),
controller.signal
)
expect(completion.status).toBe('error')
expect(sim.stored).toEqual([{ kind: 'directory', relativePath: '' }])
})
it("waits out Sim's rate limit instead of failing the import part way", async () => {
let limited = false
const sim = recordingSim((request) => {
if (request.relativePath !== 'notes.txt' || limited) return null
limited = true
return new DeviceRequestError(429, 'Too many requests', 10)
})
const completion = await runner({ imports: { importEntry: sim.importEntry } }).run(
importCall(await reportsFolder()),
new AbortController().signal
)
expect(completion.status).toBe('success')
expect(sim.stored.map((entry) => entry.relativePath)).toEqual([
'',
'notes.txt',
'q3',
'q3/summary.txt',
])
})
it('reports an import the rate limit never let start as safe to ask for again', async () => {
const sim = recordingSim(() => new DeviceRequestError(429, 'Too many requests', 1))
const completion = await runner({ imports: { importEntry: sim.importEntry } }).run(
importCall(await reportsFolder()),
new AbortController().signal
)
expect(completion.status).toBe('error')
expect(completion.data).toMatchObject({ partial: false, files: [], folders: [] })
expect(completion.data).not.toHaveProperty('doNotRetry')
expect(completion.data).not.toHaveProperty('outcomeUnknown')
})
it('fails without storing anything when the source cannot be read', async () => {
const sim = recordingSim()
const completion = await runner({ imports: { importEntry: sim.importEntry } }).run(
importCall(join(tmpdir(), 'sim-runner-import-missing', 'Reports')),
new AbortController().signal
)
expect(completion.status).toBe('error')
expect(sim.requests()).toBe(0)
expect(completion.data).toMatchObject({ workspaceId: 'ws-1', partial: false })
})
})
@@ -0,0 +1,377 @@
/**
* Runs one claimed call on this machine, in the scope of the chat Sim says it belongs to: that
* chat's built-in browser tabs, its terminals, or the user's granted folders. Arguments always
* come from Sim's record of the call. Every outcome, including a refusal, is a completion built
* by the same projections the chat view uses, so the model sees one shape either way.
*/
import {
type BrowserToolName,
browserToolRendererTimeoutMs,
isCurrentBrowserToolName,
} from '@sim/browser-protocol'
import type {
DesktopLocalFileRequest,
DesktopLocalFileResponse,
LocalFilesystemRequest,
LocalFilesystemResponse,
} from '@sim/desktop-bridge'
import { runUserLocalFilesystemTool } from '@sim/desktop-bridge/local-filesystem-tools'
import {
assertImportableManifest,
browserSessionClosedCompletion,
browserToolCompletion,
browserToolFailure,
browserToolNeedsLivePage,
browserToolTimeoutMessage,
type DesktopLocalFileImportResult,
type DesktopToolCompletion,
localFileImportCompletion,
localFileImportFailure,
localFileReadCompletion,
localFilesystemToolCompletion,
readImportEntry,
terminalOperationTimeoutMs,
terminalToolCompletion,
terminalToolFailure,
} from '@sim/desktop-bridge/tool-results'
import { createLogger } from '@sim/logger'
import {
isTerminalOperation,
type TerminalOperation,
type TerminalToolArgs,
type TerminalToolResponse,
} from '@sim/terminal-protocol'
import { getErrorMessage } from '@sim/utils/errors'
import { interruptibleSleep } from '@sim/utils/helpers'
import { isRecordLike } from '@sim/utils/object'
import { backoffWithJitter } from '@sim/utils/retry'
import { DeviceRequestError } from '@/main/desktop-executor/client'
import type { DesktopToolRunner } from '@/main/desktop-executor/executor'
import type {
ClaimedDesktopCall,
DesktopImportEntryRequest,
DesktopImportedEntry,
} from '@/main/desktop-executor/protocol'
const logger = createLogger('DesktopExecutorRunner')
const USER_LOCAL_TOOLS: ReadonlySet<string> = new Set(['read', 'grep', 'glob'])
/** A rate-limited import entry is retried this many times; Sim never stored a refused one. */
const IMPORT_RATE_LIMIT_ATTEMPTS = 8
const IMPORT_RATE_LIMIT_MAX_WAIT_MS = 30_000
/** The model learns a call never ran because a surface is switched off on this machine. */
function surfaceOff(surface: string): DesktopToolCompletion {
const message = `Not run: this action never started, because ${surface} is switched off in the Sim desktop app’s settings. Nothing happened on the user’s computer. Do not retry it in this turn; continue without it, or ask the user to switch it on.`
return { status: 'error', message, data: { error: message, notStarted: true } }
}
/** Local files are out of reach while the app has no usable account storage, a passing state. */
function localAccessUnavailable(): DesktopToolCompletion {
const message =
'Not run: this action never started, because the Sim desktop app cannot reach local files right now (it is signing out, switching account, or its storage is unavailable). Nothing happened on the user’s computer. Do not retry it in this turn; tell the user, who can ask again once the app is ready.'
return { status: 'error', message, data: { error: message, notStarted: true } }
}
function unsupported(toolName: string): DesktopToolCompletion {
const message = `Not run: this action never started, because this version of the Sim desktop app cannot run ${toolName} in the background. Nothing happened on the user’s computer. Do not retry it in this turn; tell the user to update the Sim desktop app.`
return { status: 'error', message, data: { error: message, notStarted: true } }
}
export interface DesktopToolRunnerDeps {
preferences: () => { browserEnabled: boolean; terminalEnabled: boolean }
/** False while account-bearing local storage is unavailable; local tools then refuse. */
accountDataAvailable: () => boolean
browser: {
executeTool(
scopeId: string,
tool: BrowserToolName,
params: Record<string, unknown>,
toolCallId: string
): Promise<{ ok: boolean; result?: unknown; error?: string }>
cancelTool(scopeId: string, toolCallId: string): boolean
hasSession(scopeId: string): boolean
restoreScope(scopeId: string): void
}
terminal: {
executeTool(
scope: string,
toolCallId: string,
operation: TerminalOperation,
args: TerminalToolArgs
): Promise<TerminalToolResponse>
cancelTool(scope: string, toolCallId: string): Promise<boolean>
}
/** Reads a `read_local_file` or `import_local_files` source, authorized by the call itself. */
localFiles: {
request(
call: ClaimedDesktopCall,
request: DesktopLocalFileRequest
): Promise<DesktopLocalFileResponse>
}
imports: {
importEntry(
request: DesktopImportEntryRequest,
signal: AbortSignal
): Promise<DesktopImportedEntry>
}
localFilesystem: {
handle(request: LocalFilesystemRequest): Promise<LocalFilesystemResponse>
vfsRoot(mount: { id: string; name: string }): string
}
}
/** A terminal operation that outlived its deadline may still land, as a browser action may. */
function terminalTimedOut(timeoutMs: number): DesktopToolCompletion {
const message = `The terminal did not respond within ${timeoutMs}ms. The operation may still be running and take effect: do not retry it automatically; check the terminal's state first.`
return {
status: 'error',
message,
data: { error: message, outcomeUnknown: true, doNotRetry: true },
}
}
/** Resolves once the signal aborts, which may be never. */
function untilAborted(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
}
/** Resolves with the work's result, or with `onTimeout`'s once `timeoutMs` passes first. */
async function withDeadline<T>(
work: Promise<T>,
timeoutMs: number | null,
onTimeout: () => T
): Promise<T> {
if (timeoutMs === null) return work
// Aborted once the race settles, which cancels the sleep; a cancelled sleep never times out.
const settled = new AbortController()
const timeout = interruptibleSleep(timeoutMs, settled.signal).then(() =>
settled.signal.aborted ? new Promise<T>(() => {}) : onTimeout()
)
try {
return await Promise.race([work, timeout])
} finally {
settled.abort()
}
}
export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToolRunner {
/** Each chat's last terminal operation, until it actually settles. */
const unsettledTerminalWork = new Map<string, Promise<void>>()
async function runBrowser(
call: ClaimedDesktopCall,
tool: BrowserToolName
): Promise<DesktopToolCompletion> {
if (!deps.preferences().browserEnabled) return surfaceOff('the built-in browser')
if (browserToolNeedsLivePage(tool) && !deps.browser.hasSession(call.chatId)) {
try {
deps.browser.restoreScope(call.chatId)
} catch (error) {
logger.warn('Could not restore a chat browser before a background call', {
toolCallId: call.toolCallId,
error: getErrorMessage(error),
})
}
if (!deps.browser.hasSession(call.chatId)) return browserSessionClosedCompletion()
}
const timeoutMs =
tool === 'browser_request_takeover' ? null : browserToolRendererTimeoutMs(tool, call.args)
return withDeadline(
deps.browser.executeTool(call.chatId, tool, call.args, call.toolCallId).then((response) =>
response.ok
? browserToolCompletion(tool, response.result)
: browserToolFailure(response.error ?? 'The browser action failed.', {
sessionClosed: !deps.browser.hasSession(call.chatId),
})
),
timeoutMs,
() => {
deps.browser.cancelTool(call.chatId, call.toolCallId)
return browserToolFailure(browserToolTimeoutMessage(timeoutMs ?? 0), {
outcomeUnknown: true,
})
}
)
}
async function runTerminal(
call: ClaimedDesktopCall,
signal: AbortSignal
): Promise<DesktopToolCompletion> {
if (!deps.preferences().terminalEnabled) return surfaceOff('the terminal')
const { operation } = call.args
if (!isTerminalOperation(operation)) {
return terminalToolFailure(
`Unknown terminal operation: ${String(operation)}`,
'INVALID_REQUEST'
)
}
const args = isRecordLike(call.args.args) ? (call.args.args as TerminalToolArgs) : {}
const timeoutMs = terminalOperationTimeoutMs(operation)
// An operation reported as unresponsive may still land; the chat's next one waits for it, so
// two never act on the same terminals at once.
const previous = unsettledTerminalWork.get(call.chatId)
if (previous) await Promise.race([previous, untilAborted(signal)])
// Stopped while it waited: the terminal never heard of it, so its own cancel cannot reach it.
if (signal.aborted) {
return terminalToolFailure('The terminal action was stopped before it started.', 'CANCELLED')
}
const operationDone = deps.terminal.executeTool(call.chatId, call.toolCallId, operation, args)
const settled = operationDone.then(
() => undefined,
() => undefined
)
unsettledTerminalWork.set(call.chatId, settled)
void settled.then(() => {
if (unsettledTerminalWork.get(call.chatId) === settled)
unsettledTerminalWork.delete(call.chatId)
})
return withDeadline(operationDone.then(terminalToolCompletion), timeoutMs, () =>
terminalTimedOut(timeoutMs ?? 0)
)
}
async function runUserLocal(
call: ClaimedDesktopCall,
signal: AbortSignal
): Promise<DesktopToolCompletion> {
try {
const data = await runUserLocalFilesystemTool(call.toolCallId, call.toolName, call.args, {
invoke: (request) => deps.localFilesystem.handle(request),
vfsRoot: deps.localFilesystem.vfsRoot,
signal,
})
return localFilesystemToolCompletion({ ok: true, data })
} catch (error) {
return localFilesystemToolCompletion({ ok: false, error: getErrorMessage(error) })
}
}
/**
* Imports the call's source into its workspace one entry at a time: each directory as a folder,
* each file read in chunks and checked against the manifest that listed it.
*/
/**
* Sends one entry, waiting out Sim's rate limit: a large tree can outrun it, and a 429 means
* nothing was stored, so sending the entry again cannot duplicate it.
*/
async function importEntry(
request: DesktopImportEntryRequest,
signal: AbortSignal
): Promise<DesktopImportedEntry> {
for (let attempt = 1; ; attempt++) {
try {
return await deps.imports.importEntry(request, signal)
} catch (error) {
if (
!(error instanceof DeviceRequestError) ||
error.status !== 429 ||
attempt >= IMPORT_RATE_LIMIT_ATTEMPTS
) {
throw error
}
await interruptibleSleep(
backoffWithJitter(attempt, error.retryAfterMs, { maxMs: IMPORT_RATE_LIMIT_MAX_WAIT_MS }),
signal
)
signal.throwIfAborted()
}
}
}
async function runImport(
call: ClaimedDesktopCall,
signal: AbortSignal
): Promise<DesktopToolCompletion> {
const files: DesktopLocalFileImportResult['files'] = []
const folders: DesktopLocalFileImportResult['folders'] = []
const targetWorkspaceId =
typeof call.args.targetWorkspaceId === 'string' ? call.args.targetWorkspaceId : ''
const read = (request: DesktopLocalFileRequest) => deps.localFiles.request(call, request)
try {
const response = await read({ operation: 'manifest', toolCallId: call.toolCallId })
if (!response.ok) throw new Error(response.error)
if (response.data.kind !== 'manifest') throw new Error('Unexpected file manifest response.')
const manifest = response.data
assertImportableManifest(manifest)
for (const entry of manifest.entries) {
signal.throwIfAborted()
const target = { call, sourceName: manifest.name, relativePath: entry.relativePath }
if (entry.kind === 'directory') {
const folder = await importEntry({ ...target, kind: 'directory' }, signal)
folders.push({ id: folder.id, relativePath: entry.relativePath })
continue
}
const parts = await readImportEntry(call.toolCallId, entry, read, signal)
const file = await importEntry(
{ ...target, kind: 'file', content: new Blob(parts) },
signal
)
files.push({ id: file.id, name: file.name, relativePath: entry.relativePath })
}
return localFileImportCompletion({
success: true,
workspaceId: manifest.targetWorkspaceId,
files,
folders,
})
} catch (error) {
return localFileImportCompletion(
localFileImportFailure(
{ workspaceId: targetWorkspaceId, files, folders },
getErrorMessage(error),
// A rate limit that outlasted every retry refused the entry outright: nothing of it landed.
{ outcomeKnown: error instanceof DeviceRequestError && error.status === 429 }
)
)
}
}
return {
async run(call, signal) {
try {
if (isCurrentBrowserToolName(call.toolName)) return await runBrowser(call, call.toolName)
if (call.toolName === 'terminal') return await runTerminal(call, signal)
if (!deps.accountDataAvailable()) return localAccessUnavailable()
if (call.toolName === 'read_local_file') {
return localFileReadCompletion(
await deps.localFiles.request(call, {
operation: 'read',
toolCallId: call.toolCallId,
})
)
}
if (call.toolName === 'import_local_files') return await runImport(call, signal)
if (USER_LOCAL_TOOLS.has(call.toolName)) return await runUserLocal(call, signal)
return unsupported(call.toolName)
} catch (error) {
logger.error('Background desktop call failed unexpectedly', {
toolCallId: call.toolCallId,
toolName: call.toolName,
error: getErrorMessage(error),
})
const message = `The Sim desktop app could not finish this action: ${getErrorMessage(error)}`
return {
status: 'error',
message,
data: { error: message, outcomeUnknown: true, doNotRetry: true },
}
}
},
async cancel(call) {
if (isCurrentBrowserToolName(call.toolName)) {
deps.browser.cancelTool(call.chatId, call.toolCallId)
return
}
if (call.toolName === 'terminal') {
await deps.terminal.cancelTool(call.chatId, call.toolCallId)
return
}
if (USER_LOCAL_TOOLS.has(call.toolName)) {
await deps.localFilesystem.handle({ operation: 'cancel', requestId: call.toolCallId })
}
},
}
}
@@ -0,0 +1,360 @@
import { chmod, mkdir, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { sleep } from '@sim/utils/helpers'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
/** How many journal loads still fail, like a read that throws. */
const journalFaults = vi.hoisted(() => ({ loads: 0 }))
vi.mock('@/main/desktop-executor/journal', async () => {
const actual = await vi.importActual<typeof import('@/main/desktop-executor/journal')>(
'@/main/desktop-executor/journal'
)
return {
...actual,
createExecutorJournal: (...args: Parameters<typeof actual.createExecutorJournal>) => {
const journal = actual.createExecutorJournal(...args)
return {
...journal,
load: () => {
if (journalFaults.loads <= 0) return journal.load()
journalFaults.loads -= 1
return Promise.reject(new Error('The disk went away.'))
},
}
},
}
})
import { net } from 'electron'
import { DesktopExecutor } from '@/main/desktop-executor/executor'
import { createExecutorJournal } from '@/main/desktop-executor/journal'
import { createDesktopExecutorService, deviceName } from '@/main/desktop-executor/service'
/** Sim's device routes, with registration answers held until the test releases them. */
function fakeSim(protocolVersion = 1) {
const requests: string[] = []
/** Calls the inbox offers; a claim answers for the first one. */
const offered: string[] = []
/**
* Answers to pending registrations: enabled or not, an HTTP status Sim fails with, or
* `'offline'` for a request that never reached Sim.
*/
const registrations: Array<(answer: boolean | number | 'offline') => void> = []
const fetch = vi.fn(async (url: string, init: RequestInit): Promise<Response> => {
const path = new URL(url).pathname
requests.push(`${init.method} ${path}`)
if (path === '/api/desktop/devices') {
const answer = await new Promise<boolean | number | 'offline'>((resolve) =>
registrations.push(resolve)
)
if (answer === 'offline') throw new TypeError('fetch failed')
if (typeof answer === 'number') {
return Response.json({ error: 'Not found' }, { status: answer })
}
const enabled = answer
return Response.json({
enabled,
protocolVersion,
leaseMs: 60_000,
leaseRenewMs: 20_000,
reconcileMs: 10_000,
})
}
if (path === '/api/desktop/inbox') {
return Response.json({
items: offered.map((toolCallId) => ({
kind: 'call',
toolCallId,
toolName: 'terminal',
chatId: 'chat-a',
workspaceId: 'ws-1',
createdAt: new Date().toISOString(),
})),
})
}
if (path === '/api/desktop/tool/claim') {
const toolCallId = offered.shift()
if (!toolCallId) return Response.json({ error: 'gone' }, { status: 404 })
return Response.json({
toolName: 'terminal',
args: { operation: 'run', args: { command: 'sleep 600' } },
chatId: 'chat-a',
workspaceId: 'ws-1',
executionToken: `token-${toolCallId}`,
})
}
if (path === '/api/desktop/tool/lease') return Response.json({ renewed: true })
if (path === '/api/desktop/tool/complete') return Response.json({ outcome: 'recorded' })
return new Promise<Response>((_resolve, reject) =>
init.signal?.addEventListener('abort', () => reject(new Error('aborted')))
)
})
return { fetch, requests, registrations, offered }
}
async function service(protocolVersion = 1, userDataPath?: string) {
const sim = fakeSim(protocolVersion)
/** What the sleep blocker was told, in order. */
const busy: boolean[] = []
const desktopExecutor = createDesktopExecutorService({
userDataPath: userDataPath ?? (await mkdtemp(join(tmpdir(), 'sim-executor-service-'))),
origin: () => 'https://sim.test',
appSession: () => ({ fetch: sim.fetch }),
preferences: () => ({ browserEnabled: true, terminalEnabled: true }),
accountDataAvailable: () => true,
// A command that runs until it is stopped.
runner: {
run: (_call, signal) =>
new Promise((resolve) =>
signal.addEventListener('abort', () =>
resolve({ status: 'cancelled', message: 'Stopped.' })
)
),
cancel: async () => {},
},
onBusyChange: (value) => busy.push(value),
})
return { sim, desktopExecutor, busy }
}
describe('results recovery will hand to the model', () => {
it('names the calls whose real result the journal holds for recovery to send', async () => {
const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-'))
const journal = createExecutorJournal(join(userData, 'desktop-executor-journal.json'))
await journal.put({ toolCallId: 'claimed', state: 'claimed', executionToken: 't1' })
await journal.put({ toolCallId: 'started', state: 'started', executionToken: 't2' })
await journal.put({
toolCallId: 'unknown',
state: 'result',
executionToken: 't3',
completion: { status: 'error', message: 'x', data: { outcomeUnknown: true } },
})
await journal.put({
toolCallId: 'not-started',
state: 'result',
executionToken: 't4',
completion: { status: 'error', message: 'x', data: { notStarted: true } },
})
await journal.put({
toolCallId: 'stopped',
state: 'result',
executionToken: 't6',
completion: { status: 'cancelled', message: 'Stopped.' },
})
await journal.put({
toolCallId: 'too-large',
state: 'result',
executionToken: 't7',
completion: { status: 'error', message: 'x', data: { resultOmitted: true } },
})
await journal.put({
toolCallId: 'handed-back',
state: 'result',
executionToken: 't5',
completion: { status: 'success', message: 'running', data: { status: 'running' } },
})
const { desktopExecutor } = await service(1, userData)
expect([...(await desktopExecutor.pendingResults())]).toEqual(['handed-back'])
})
it('counts an unreadable journal as holding none', async () => {
const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-'))
// A directory where the journal file should be: every read of it fails.
await mkdir(join(userData, 'desktop-executor-journal.json'))
const { desktopExecutor } = await service(1, userData)
expect([...(await desktopExecutor.pendingResults())]).toEqual([])
})
it('names what the journal held before recovery sent it, even when asked after', async () => {
const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-'))
const path = join(userData, 'desktop-executor-journal.json')
await createExecutorJournal(path).put({
toolCallId: 'handed-back',
state: 'result',
executionToken: 't1',
completion: { status: 'success', message: 'running', data: { status: 'running' } },
})
// Recovery returns only once Sim has the result and the journal no longer holds it, so a
// snapshot taken any time after recovery started would come back empty.
const recover = DesktopExecutor.prototype.recover
const recovered = vi
.spyOn(DesktopExecutor.prototype, 'recover')
.mockImplementation(async function (this: DesktopExecutor) {
await recover.call(this)
await vi.waitFor(async () => expect(await createExecutorJournal(path).load()).toEqual([]))
})
try {
const { sim, desktopExecutor } = await service(1, userData)
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(true)
await vi.waitFor(() => expect(sim.requests).toContain('POST /api/desktop/tool/complete'))
await vi.waitFor(async () => expect(await createExecutorJournal(path).load()).toEqual([]))
expect([...(await desktopExecutor.pendingResults())]).toEqual(['handed-back'])
await desktopExecutor.signOut()
} finally {
recovered.mockRestore()
}
})
it('counts a journal that cannot be loaded at all as holding none, and still starts', async () => {
journalFaults.loads = 1
const { sim, desktopExecutor } = await service()
expect([...(await desktopExecutor.pendingResults())]).toEqual([])
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(true)
await vi.waitFor(() => expect(sim.requests).toContain('GET /api/desktop/inbox'))
await desktopExecutor.signOut()
})
})
describe('desktop executor registration', () => {
it('offers the device for binding once Sim enables it', async () => {
const { sim, desktopExecutor } = await service()
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(true)
await vi.waitFor(() => expect(desktopExecutor.getDevice()).not.toBeNull())
await vi.waitFor(() => expect(sim.requests).toContain('GET /api/desktop/inbox'))
})
it('never resumes for a registration that Sim answers after sign-out', async () => {
const { sim, desktopExecutor } = await service()
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
await desktopExecutor.signOut()
sim.registrations[0]?.(true)
await sleep(50)
expect(desktopExecutor.getDevice()).toBeNull()
expect(sim.requests).not.toContain('GET /api/desktop/inbox')
expect(sim.requests).not.toContain('GET /api/desktop/inbox/stream')
})
it('offers no binding to a Sim that speaks another protocol version', async () => {
const { sim, desktopExecutor } = await service(2)
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(true)
await sleep(100)
expect(desktopExecutor.getDevice()).toBeNull()
expect(sim.requests.filter((request) => request.includes('/api/desktop/inbox'))).toEqual([])
})
it('stays dormant while Sim has the executor off: no inbox and no doorbell', async () => {
const { sim, desktopExecutor } = await service()
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(false)
await sleep(100)
expect(desktopExecutor.getDevice()).toBeNull()
expect(sim.requests.filter((request) => request.includes('/api/desktop/inbox'))).toEqual([])
})
it('stays dormant against a Sim without the executor routes, checking back only slowly', async () => {
const { sim, desktopExecutor } = await service()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
try {
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(404)
await vi.advanceTimersByTimeAsync(60_000)
expect(sim.registrations).toHaveLength(1)
expect(sim.requests.filter((request) => request.includes('/api/desktop/inbox'))).toEqual([])
await vi.advanceTimersByTimeAsync(15 * 60_000)
expect(sim.registrations).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('registers again as soon as the network returns, not after its backoff', async () => {
const { sim, desktopExecutor } = await service()
vi.mocked(net.isOnline).mockReturnValue(false)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
try {
desktopExecutor.start()
// Four failures with no network leave the next attempt at least 12.8 s away.
for (let failure = 0; failure < 4; failure += 1) {
await vi.waitFor(() => expect(sim.registrations).toHaveLength(failure + 1))
sim.registrations[failure]?.('offline')
await vi.advanceTimersByTimeAsync(failure < 3 ? 10_000 : 0)
}
expect(sim.registrations).toHaveLength(4)
vi.mocked(net.isOnline).mockReturnValue(true)
await vi.advanceTimersByTimeAsync(2_500)
expect(sim.registrations).toHaveLength(5)
} finally {
vi.useRealTimers()
vi.mocked(net.isOnline).mockReturnValue(true)
}
})
it('registers with no install id it could not save', async () => {
const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-'))
await chmod(userData, 0o500)
try {
const { sim, desktopExecutor } = await service(1, userData)
desktopExecutor.start()
await sleep(200)
expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual([])
} finally {
await chmod(userData, 0o700)
}
})
it('keeps its install id through a read failure instead of minting a new one', async () => {
const userData = await mkdtemp(join(tmpdir(), 'sim-executor-service-'))
// A path that exists but cannot be read as a file: not "no id yet".
await mkdir(join(userData, 'desktop-executor-device.json'))
const { sim, desktopExecutor } = await service(1, userData)
desktopExecutor.start()
await sleep(200)
expect(sim.requests.filter((request) => request.includes('/api/desktop/devices'))).toEqual([])
})
it('releases the sleep blocker at sign-out while a call is still running', async () => {
const { sim, desktopExecutor, busy } = await service()
sim.offered.push('call-1')
desktopExecutor.start()
await vi.waitFor(() => expect(sim.registrations).toHaveLength(1))
sim.registrations[0]?.(true)
await vi.waitFor(() => expect(busy).toEqual([true]))
await desktopExecutor.signOut()
expect(busy).toEqual([true, false])
})
})
describe('device name', () => {
it('fits a long hostname within what Sim accepts at registration', () => {
expect(deviceName(`${'studio-'.repeat(40)}.local`).length).toBeLessThanOrEqual(128)
expect(deviceName('Studio-Mac.local')).toBe('Studio-Mac')
})
})
@@ -0,0 +1,504 @@
/**
* Runs the background executor for as long as the app is signed in: registers this install as a
* device, keeps the inbox doorbell open, reads the inbox on every ring and on Sim's reconcile
* timer, and follows sleep, wake and network changes. One per process, which the single-instance
* lock makes one per machine.
*/
import { hostname } from 'node:os'
import { join } from 'node:path'
import type { DesktopExecutorDevice } from '@sim/desktop-bridge'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { randomFloat } from '@sim/utils/random'
import { backoffWithJitter } from '@sim/utils/retry'
import { truncate } from '@sim/utils/string'
import type { Session } from 'electron'
import { app, net, powerMonitor } from 'electron'
import {
readFileWithinLimit,
removeFileIfPresent,
writeJsonFileAtomically,
} from '@/main/atomic-json-file'
import {
createDesktopExecutorClient,
type DesktopExecutorClient,
DeviceRequestError,
} from '@/main/desktop-executor/client'
import { InboxDoorbell } from '@/main/desktop-executor/doorbell'
import {
type DesktopApprovalItem,
DesktopExecutor,
type DesktopToolRunner,
isDeliveredResult,
} from '@/main/desktop-executor/executor'
import { createExecutorJournal } from '@/main/desktop-executor/journal'
import {
DESKTOP_EXECUTOR_PROTOCOL_VERSION,
type DesktopExecutorTiming,
type DesktopImportEntryRequest,
type DesktopImportedEntry,
} from '@/main/desktop-executor/protocol'
const logger = createLogger('DesktopExecutorService')
/** Batches the burst of cookie and preference events a sign-in produces into one registration. */
const REGISTRATION_DEBOUNCE_MS = 1_000
const REGISTRATION_RETRY_MAX_MS = 5 * 60_000
/** How often a device Sim has not enabled checks whether that changed. */
const DORMANT_RECHECK_MS = 15 * 60_000
const ONLINE_POLL_MS = 2_000
/** Spreads reconcile reads so devices that woke together do not read together. */
const RECONCILE_JITTER = 0.2
export interface DesktopExecutorServiceDeps {
userDataPath: string
origin: () => string
appSession: () => Pick<Session, 'fetch'>
preferences: () => { browserEnabled: boolean; terminalEnabled: boolean }
accountDataAvailable: () => boolean
runner: DesktopToolRunner
onApprovals?: (items: DesktopApprovalItem[]) => void
/** Whether any chat has desktop work claimed on this machine changed. */
onBusyChange?: (busy: boolean) => void
/** Sim has taken a call's real result as the call's own, so the model has it. */
onResultDelivered?: (toolCallId: string) => void
/** Sim is done with a call whose real result the model never got. */
onResultNotDelivered?: (toolCallId: string) => void
}
export interface DesktopExecutorService {
start(): void
/** Re-registers after a sign-in, a session change, or a change to what this device can run. */
refreshRegistration(): void
getDevice(): DesktopExecutorDevice | null
/**
* The calls whose real result ({@link isDeliveredResult}) is in the journal and not yet
* acknowledged, so recovery will hand it to the model. Read once, before recovery can change the
* journal, whenever it is asked; empty when the journal cannot be read.
*/
pendingResults(): Promise<Set<string>>
/** Stores one entry of a claimed import, as this device's registered session. */
importEntry(
request: DesktopImportEntryRequest,
signal: AbortSignal
): Promise<DesktopImportedEntry>
/** Sign-out: stops every action, forgets every call, and retires this install id. */
signOut(): Promise<void>
}
/** The install id, kept in userData; a new one is minted after sign-out or a conflict. */
async function readInstallId(filePath: string): Promise<string | null> {
let raw: string
try {
raw = (await readFileWithinLimit(filePath, 4096)).toString('utf8')
} catch (error) {
// Only a missing file means no id yet. Any other failure could be passing, and minting a new
// id over it would orphan the calls bound to this one, so it is left to the next attempt.
if (isRecordLike(error) && error.code === 'ENOENT') return null
throw error
}
try {
const parsed: unknown = JSON.parse(raw)
return isRecordLike(parsed) && typeof parsed.deviceId === 'string' ? parsed.deviceId : null
} catch {
// Unreadable contents can never yield the id; a fresh one replaces them.
return null
}
}
/** Sim refuses a longer device name at registration. */
const DEVICE_NAME_MAX_CHARS = 128
/** The machine's name as the user knows it, within what Sim accepts. */
export function deviceName(host = hostname()): string {
const name = host.replace(/\.local$/, '').trim() || 'Sim desktop'
return truncate(name, DEVICE_NAME_MAX_CHARS - 3)
}
export function createDesktopExecutorService(
deps: DesktopExecutorServiceDeps
): DesktopExecutorService {
const identityPath = join(deps.userDataPath, 'desktop-executor-device.json')
const journal = createExecutorJournal(join(deps.userDataPath, 'desktop-executor-journal.json'))
let deviceId: string | null = null
let device: DesktopExecutorDevice | null = null
let timing: DesktopExecutorTiming | null = null
let client: DesktopExecutorClient | null = null
let executor: DesktopExecutor | null = null
let doorbell: InboxDoorbell | null = null
let reconcileTimer: ReturnType<typeof setTimeout> | null = null
let registrationTimer: ReturnType<typeof setTimeout> | null = null
let registering = false
let registerAgain = false
let registrationAttempt = 0
/** Logged once per run of missing routes, not on every recheck. */
let routesMissingNoted = false
/** The last registration failed with no answer from Sim at all. */
let registrationFailedOffline = false
let suspended = false
let started = false
/** The journal's pending real results as they stood before this process's recovery. */
let pendingSnapshot: Promise<Set<string>> | null = null
const pendingResultsSnapshot = (): Promise<Set<string>> => {
// An unreadable journal loads as empty, so it holds none.
pendingSnapshot ??= journal
.load()
.then(
(entries) =>
new Set(
entries.flatMap((entry) =>
entry.state === 'result' && isDeliveredResult(entry.completion)
? [entry.toolCallId]
: []
)
)
)
// The journal reads an unreadable file as empty, so this is for a load that throws anyway:
// it holds none, and the executor still starts.
.catch((error: unknown) => {
logger.warn('Could not read the executor journal for pending results', {
error: getErrorMessage(error),
})
return new Set<string>()
})
return pendingSnapshot
}
/** Bumped on sign-out, so work started for the previous session cannot resume it. */
let generation = 0
let executorDeviceId: string | null = null
async function installId(): Promise<string> {
if (deviceId) return deviceId
deviceId = (await readInstallId(identityPath)) ?? (await rotateInstallId())
return deviceId
}
/**
* Mints a new install id and makes it durable before anything uses it: an id the next launch
* would not find again could never resume the calls bound to it. Fails if it cannot be saved.
*/
async function rotateInstallId(): Promise<string> {
const next = generateId()
deviceId = null
await writeJsonFileAtomically(identityPath, { deviceId: next })
deviceId = next
return next
}
/**
* Retires the current install id for good: a new one replaces it, or, if that cannot be saved,
* the old one is removed so no later launch or sign-in can pick it up again. False when neither
* worked and the old id is still on disk.
*/
async function retireInstallId(): Promise<boolean> {
try {
await rotateInstallId()
return true
} catch (error) {
logger.warn('Could not save a new desktop install id', { error: getErrorMessage(error) })
}
try {
await removeFileIfPresent(identityPath)
return true
} catch (error) {
logger.warn('Could not remove the retired desktop install id', {
error: getErrorMessage(error),
})
return false
}
}
function fetchWithAppSession(url: string, init: RequestInit): Promise<Response> {
return deps.appSession().fetch(url, init)
}
function scheduleReconcile(): void {
if (reconcileTimer) clearTimeout(reconcileTimer)
// Stopped loops (a dormant or unrecognized device) stay stopped until registration restarts them.
if (!timing || !executor || !doorbell) return
const delay = timing.reconcileMs * (1 - RECONCILE_JITTER + randomFloat() * RECONCILE_JITTER)
reconcileTimer = setTimeout(() => {
reconcileTimer = null
void executor?.reconcile().finally(scheduleReconcile)
}, delay)
}
function ring(): void {
if (suspended) return
void executor?.reconcile()
}
/**
* Sim stopped recognizing this device. With the executor on, the session changed under it, so
* it registers again at once. With it off, Sim never recorded the device, so it stays quiet and
* checks back later in case the executor is switched on.
*/
function handleUnrecognized(): void {
if (device) {
scheduleRegistration()
return
}
stopLoops()
// Every refused request lands here; re-arming each time would push the recheck out forever.
if (!registrationTimer) scheduleRegistration(DORMANT_RECHECK_MS)
}
function stopLoops(): void {
doorbell?.stop()
doorbell = null
if (reconcileTimer) clearTimeout(reconcileTimer)
reconcileTimer = null
}
/** Builds the executor for this install the first time Sim recognizes it. */
async function startExecutor(
id: string,
nextTiming: DesktopExecutorTiming,
registrationGeneration: number
): Promise<void> {
if (executor && executorDeviceId !== id) await resetExecutor()
timing = nextTiming
if (!executor || !client) {
executorDeviceId = id
client = createDesktopExecutorClient({
origin: deps.origin,
fetch: fetchWithAppSession,
deviceId: id,
})
executor = new DesktopExecutor({
client,
journal,
runner: deps.runner,
leaseRenewMs: nextTiming.leaseRenewMs,
onUnregistered: handleUnrecognized,
...(deps.onApprovals ? { onApprovals: deps.onApprovals } : {}),
...(deps.onBusyChange ? { onBusyChange: deps.onBusyChange } : {}),
...(deps.onResultDelivered ? { onResultDelivered: deps.onResultDelivered } : {}),
...(deps.onResultNotDelivered ? { onResultNotDelivered: deps.onResultNotDelivered } : {}),
})
// Recovery rewrites the journal, so what it held before is read first.
await pendingResultsSnapshot()
await executor.recover()
// Signed out while recovering: sign-out already disposed this executor.
if (registrationGeneration !== generation || !executor) return
} else {
executor.resumeParked()
}
if (!doorbell) {
doorbell = new InboxDoorbell({
client,
onRing: ring,
onUnregistered: handleUnrecognized,
})
if (!suspended) doorbell.start()
}
if (!suspended) void executor.reconcile()
scheduleReconcile()
}
async function register(): Promise<void> {
if (!deps.accountDataAvailable()) return
const registrationGeneration = generation
let id: string
try {
id = await installId()
} catch (error) {
registrationAttempt += 1
logger.warn('Could not read the desktop install id', { error: getErrorMessage(error) })
scheduleRegistration(
backoffWithJitter(registrationAttempt, null, {
baseMs: 2_000,
maxMs: REGISTRATION_RETRY_MAX_MS,
})
)
return
}
const preferences = deps.preferences()
const registrationClient = createDesktopExecutorClient({
origin: deps.origin,
fetch: fetchWithAppSession,
deviceId: id,
})
try {
const nextTiming = await registrationClient.register({
deviceId: id,
name: deviceName(),
appVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`,
capabilities: {
executor: DESKTOP_EXECUTOR_PROTOCOL_VERSION,
browser: preferences.browserEnabled,
terminal: preferences.terminalEnabled,
localFiles: deps.accountDataAvailable(),
},
})
if (registrationGeneration !== generation || id !== deviceId) return
registrationAttempt = 0
registrationFailedOffline = false
routesMissingNoted = false
// A Sim that speaks another protocol version gets no new turns bound to this device.
device =
nextTiming.enabled && nextTiming.protocolVersion === DESKTOP_EXECUTOR_PROTOCOL_VERSION
? { deviceId: id, protocolVersion: DESKTOP_EXECUTOR_PROTOCOL_VERSION }
: null
logger.info('Desktop executor registered', { enabled: nextTiming.enabled })
// Off on this Sim, and nothing here to finish: stay dormant. No inbox, no doorbell; only
// a slow recheck, so switching the executor on in Sim reaches this device without a relaunch.
if (!device && !executor && (await journal.load()).length === 0) {
if (registrationGeneration !== generation) return
scheduleRegistration(DORMANT_RECHECK_MS)
return
}
// Off on this Sim mid-session, or results from a previous run to deliver: a turn already
// bound here still finishes, so the executor serves the inbox; no new turn binds.
await startExecutor(id, nextTiming, registrationGeneration)
} catch (error) {
if (registrationGeneration !== generation) return
device = null
if (error instanceof DeviceRequestError && error.status === 409) {
// The id belongs to another account (a copied profile); this install takes a new one.
logger.warn('Desktop install id is registered to another account; minting a new one')
await resetExecutor()
if (await retireInstallId()) {
scheduleRegistration(0)
} else {
// The conflicting id is still on disk: retrying at once would only conflict again.
registrationAttempt += 1
scheduleRegistration(
backoffWithJitter(registrationAttempt, null, {
baseMs: 2_000,
maxMs: REGISTRATION_RETRY_MAX_MS,
})
)
}
return
}
if (error instanceof DeviceRequestError && error.unregistered) {
// Signed out: the next sign-in's session change registers again.
stopLoops()
return
}
if (error instanceof DeviceRequestError && (error.status === 404 || error.status === 405)) {
// A Sim without the executor routes (older, or self-hosted): dormant, with only the slow
// recheck, so an upgrade of Sim reaches this device without a relaunch.
stopLoops()
if (!routesMissingNoted) {
routesMissingNoted = true
logger.info('Sim does not offer the desktop background executor; staying dormant')
}
scheduleRegistration(DORMANT_RECHECK_MS)
return
}
registrationFailedOffline = error instanceof DeviceRequestError && error.status === 0
registrationAttempt += 1
logger.warn('Desktop executor registration failed', {
attempt: registrationAttempt,
error: getErrorMessage(error),
})
scheduleRegistration(
backoffWithJitter(registrationAttempt, null, {
baseMs: 2_000,
maxMs: REGISTRATION_RETRY_MAX_MS,
})
)
}
}
function scheduleRegistration(delayMs = REGISTRATION_DEBOUNCE_MS): void {
if (!started) return
if (registrationTimer) clearTimeout(registrationTimer)
registrationTimer = setTimeout(() => {
registrationTimer = null
void runRegistration()
}, delayMs)
}
/** One registration at a time; a trigger that lands mid-flight registers once more after it. */
async function runRegistration(): Promise<void> {
if (registering) {
registerAgain = true
return
}
registering = true
try {
do {
registerAgain = false
await register()
} while (registerAgain)
} finally {
registering = false
}
}
async function resetExecutor(): Promise<void> {
stopLoops()
const current = executor
executor = null
executorDeviceId = null
client = null
timing = null
device = null
await current?.dispose()
}
function wake(): void {
suspended = false
executor?.setPaused(false)
doorbell?.wake()
void executor?.reconcile()
scheduleReconcile()
}
function watchPowerAndNetwork(): void {
powerMonitor.on('suspend', () => {
suspended = true
executor?.setPaused(true)
doorbell?.stop()
})
powerMonitor.on('resume', wake)
let online = net.isOnline()
setInterval(() => {
const now = net.isOnline()
if (now && !online) {
wake()
// A registration that failed for want of a network need not wait out its backoff.
if (registrationFailedOffline) scheduleRegistration(0)
}
online = now
}, ONLINE_POLL_MS).unref?.()
}
return {
start() {
if (started) return
started = true
watchPowerAndNetwork()
scheduleRegistration(0)
},
refreshRegistration() {
scheduleRegistration()
},
getDevice() {
return device
},
pendingResults() {
return pendingResultsSnapshot()
},
importEntry(request, signal) {
if (!client) throw new Error('The Sim desktop app is not signed in to Sim.')
return client.importEntry(request, signal)
},
async signOut() {
// A registration in flight now answers for a session that is gone; it must not restart.
generation += 1
if (registrationTimer) clearTimeout(registrationTimer)
registrationTimer = null
await resetExecutor()
await journal.clear()
await retireInstallId()
},
}
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { createSleepBlocker } from '@/main/desktop-executor/sleep-blocker'
function harness(enabled = true) {
let nextId = 1
/** The blockers the OS holds now, by id, with the kind each one prevents. */
const active = new Map<number, string>()
const powerSaveBlocker = {
start: (type: 'prevent-app-suspension') => {
const id = nextId++
active.set(id, type)
return id
},
stop: (id: number) => {
active.delete(id)
},
isStarted: (id: number) => active.has(id),
}
let preference = enabled
const blocker = createSleepBlocker({ enabled: () => preference, powerSaveBlocker })
return {
blocker,
active,
setPreference: (value: boolean) => {
preference = value
blocker.refresh()
},
}
}
describe('keeping the machine awake for background work', () => {
it('holds one blocker only while a chat has work running', () => {
const { blocker, active } = harness()
blocker.setBusy(true)
blocker.setBusy(true)
expect([...active.values()]).toEqual(['prevent-app-suspension'])
blocker.setBusy(false)
expect(active.size).toBe(0)
})
it('never holds one while the user has switched it off, and follows the switch live', () => {
const { blocker, active, setPreference } = harness(false)
blocker.setBusy(true)
expect(active.size).toBe(0)
setPreference(true)
expect(active.size).toBe(1)
setPreference(false)
expect(active.size).toBe(0)
})
it('does not start one for an idle machine when the switch turns on', () => {
const { active, setPreference } = harness(false)
setPreference(true)
expect(active.size).toBe(0)
})
})
@@ -0,0 +1,50 @@
/**
* Keeps the machine from idle-sleeping while a chat has desktop work running in the background,
* and only then. It cannot keep a closed laptop awake: the OS sleeps it anyway, and the work
* resumes reporting when it wakes.
*/
interface PowerSaveBlocker {
start(type: 'prevent-app-suspension'): number
stop(id: number): void
isStarted(id: number): boolean
}
interface SleepBlockerDeps {
/** The user's "prevent sleep while a chat is running" switch. */
enabled: () => boolean
powerSaveBlocker: PowerSaveBlocker
}
/**
* Holds one `prevent-app-suspension` power blocker while the executor is busy and the switch is
* on, and releases it as soon as either stops being true. `setBusy` follows the executor;
* `refresh` follows the switch.
*/
export function createSleepBlocker(deps: SleepBlockerDeps) {
let busy = false
let blockerId: number | null = null
const apply = () => {
const wanted = busy && deps.enabled()
const held = blockerId !== null && deps.powerSaveBlocker.isStarted(blockerId)
if (wanted && !held) {
blockerId = deps.powerSaveBlocker.start('prevent-app-suspension')
} else if (!wanted && blockerId !== null) {
if (held) deps.powerSaveBlocker.stop(blockerId)
blockerId = null
}
}
return {
/** Whether any chat has desktop work claimed on this machine. */
setBusy(next: boolean): void {
busy = next
apply()
},
/** Re-applies the user's switch to the work running now. */
refresh(): void {
apply()
},
}
}
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
import type { BrowserWindow } from 'electron'
import { createDesktopSettingsService } from '@/main/desktop-settings'
import { Notification } from '@/test/electron-mock'
function service(window: { url: string; focused: boolean } | null) {
const config = new Map<string, unknown>()
return createDesktopSettingsService({
config: {
get: (key: string) => config.get(key),
set: (key: string, value: unknown) => config.set(key, value),
flush: () => true,
} as never,
getMainWindow: () =>
window
? ({
isFocused: () => window.focused,
webContents: { getURL: () => window.url },
} as unknown as BrowserWindow)
: null,
openMainWindowAt: vi.fn(),
setAutoDownloadUpdates: vi.fn(),
setTrayEnabled: vi.fn(),
setBrowserEnabled: vi.fn(),
setTerminalEnabled: vi.fn(),
setBrowserTheme: vi.fn(),
setBrowserDefaultZoom: vi.fn(),
setTerminalDefaultZoom: vi.fn(),
setPreventSleepWhileRunning: vi.fn(),
getDefaultBrowserDownloadDirectory: () => '/tmp',
chooseBrowserDownloadDirectory: async () => null,
})
}
const BACKGROUND_CHAT = '/workspace/ws-1/chat/chat-b'
describe('desktop notifications in the default "background only" mode', () => {
beforeEach(() => {
Notification.instances.length = 0
})
it('announces a chat in the background while the user works in another one', () => {
const shown = service({
url: 'https://sim.ai/workspace/ws-1/chat/chat-c',
focused: true,
}).notify({
title: 'Fix CI',
body: 'Sim finished responding.',
route: BACKGROUND_CHAT,
background: true,
})
expect(shown).toBe(true)
expect(Notification.instances).toHaveLength(1)
})
it('stays quiet about the chat the focused window shows', () => {
const shown = service({ url: `https://sim.ai${BACKGROUND_CHAT}`, focused: true }).notify({
title: 'Fix CI',
body: 'Sim finished responding.',
route: BACKGROUND_CHAT,
background: true,
})
expect(shown).toBe(false)
expect(Notification.instances).toHaveLength(0)
})
it('stays quiet about the app as a whole while it is focused', () => {
const shown = service({ url: 'https://sim.ai/workspace/ws-1/home', focused: true }).notify({
title: 'Update ready',
body: 'Restart to update.',
})
expect(shown).toBe(false)
})
it('holds back an ordinary notification whenever the window is focused, wherever it is', () => {
// The workflow panel's chat completes while the user watches it on the workflow page.
const shown = service({ url: 'https://sim.ai/workspace/ws-1/w/wf-1', focused: true }).notify({
title: 'Task complete',
body: 'Sim finished responding.',
route: BACKGROUND_CHAT,
})
expect(shown).toBe(false)
expect(Notification.instances).toHaveLength(0)
})
})
+32 -1
View File
@@ -37,6 +37,7 @@ export interface DesktopSettingsService {
getPreferences(): DesktopPreferences
setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences
setBrowserSearchSuggestionsEnabled(enabled: boolean): DesktopPreferences
setPreventSleepWhileRunning(enabled: boolean): DesktopPreferences
setAppearancePreference(
key: DesktopAppearanceSettingKey,
value: DesktopAppearanceTheme
@@ -60,6 +61,8 @@ interface DesktopSettingsServiceDeps {
setBrowserEnabled: (enabled: boolean) => void
/** Ends every open agent shell when the surface is turned off. */
setTerminalEnabled: (enabled: boolean) => void
/** Starts or stops keeping the machine awake for background work already running. */
setPreventSleepWhileRunning: (enabled: boolean) => void
/** Repaints current browser tabs when their persisted appearance changes. */
setBrowserTheme: (theme: DesktopAppearanceTheme) => void
/** Applies a new default zoom to current and future browser tabs. */
@@ -93,6 +96,7 @@ function readPreferences(
browserEnabled: config.get('browserEnabled') ?? true,
browserSearchSuggestionsEnabled: config.get('browserSearchSuggestionsEnabled') ?? true,
terminalEnabled: config.get('terminalEnabled') ?? true,
preventSleepWhileRunning: config.get('preventSleepWhileRunning') ?? true,
browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app',
browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100,
browserDownloadDirectory:
@@ -105,6 +109,21 @@ function readPreferences(
}
}
/**
* Whether a focused window already shows what a notification is about. One without a route is
* about the app as a whole, which a focused window always shows; one about a chat is news only
* while that chat is in the background.
*/
function showsRoute(window: BrowserWindow, route: string | undefined): boolean {
if (!route) return true
try {
const shown = new URL(window.webContents.getURL()).pathname
return shown === new URL(route, 'https://sim.invalid').pathname
} catch {
return false
}
}
/**
* Owns device preferences and their native side effects. Renderer code can
* request a change, but only this main-process service touches login items,
@@ -160,6 +179,12 @@ export function createDesktopSettingsService(
deps.config.flush()
return read()
},
setPreventSleepWhileRunning(enabled) {
deps.config.set('preventSleepWhileRunning', enabled)
deps.config.flush()
deps.setPreventSleepWhileRunning(enabled)
return read()
},
setAppearancePreference(key, value) {
const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined
deps.config.set(key, value)
@@ -203,7 +228,13 @@ export function createDesktopSettingsService(
return false
}
const window = deps.getMainWindow()
if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) {
// A focused window holds back every notification, as it always has. Only a background
// chat's completion is shown anyway when the focused window is somewhere else.
if (
preferences.notificationsOnlyWhenUnfocused &&
window?.isFocused() &&
(!payload.background || showsRoute(window, payload.route))
) {
return false
}
+142 -13
View File
@@ -2,7 +2,17 @@ import { join } from 'node:path'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { OpenDialogOptions, Session, WebContents } from 'electron'
import { app, BrowserWindow, crashReporter, dialog, net, session, shell } from 'electron'
import {
app,
BrowserWindow,
crashReporter,
dialog,
Notification,
net,
powerSaveBlocker,
session,
shell,
} from 'electron'
import {
beginAccountDataTeardown,
completeDeploymentScopedTeardown,
@@ -17,9 +27,13 @@ import {
import { newChatRoute, settingsRoute } from '@/main/app-routes'
import {
activateBrowserScope as activateAgentBrowserScope,
cancelTool as cancelAgentBrowserTool,
clearBrowserProfile as clearAgentBrowserProfile,
closeBrowserSession as closeAgentBrowserSession,
executeTool as executeAgentBrowserTool,
hasBrowserScopeSession,
initDriver as initBrowserAgentDriver,
restoreBrowserScope as restoreAgentBrowserScope,
} from '@/main/browser-agent/driver'
import {
canReportPanelBounds,
@@ -47,6 +61,10 @@ import {
import { attachContextMenu } from '@/main/context-menu'
import { attachCspFallback } from '@/main/csp'
import { DesktopChatSessionStore } from '@/main/desktop-chat-session-store'
import { createApprovalNotifier } from '@/main/desktop-executor/approval-notifier'
import { createDesktopToolRunner } from '@/main/desktop-executor/runner'
import { createDesktopExecutorService } from '@/main/desktop-executor/service'
import { createSleepBlocker } from '@/main/desktop-executor/sleep-blocker'
import { createDesktopSettingsService } from '@/main/desktop-settings'
import { attachDownloadHandling } from '@/main/downloads'
import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff'
@@ -56,7 +74,8 @@ import {
} from '@/main/help-search'
import { registerIpcHandlers } from '@/main/ipc'
import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health'
import { LocalFilesystemService } from '@/main/local-filesystem'
import { executeLocalFileRequest } from '@/main/local-files'
import { LocalFilesystemService, mountVfsRoot } from '@/main/local-filesystem'
import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store'
import {
attachLocalPageProtocol,
@@ -75,12 +94,14 @@ import {
createSessionLifecycleCoordinator,
decideStartRoute,
handleConnectIntercept,
isSessionCookieName,
readSessionUserId,
resolveStartRoute,
} from '@/main/session-lifecycle'
import { setShellTheme } from '@/main/shell-theme'
import { attachTelemetryPolicy } from '@/main/telemetry-policy'
import { TerminalRegistry } from '@/main/terminal/registry'
import { createRunLedger } from '@/main/terminal/run-ledger'
import { installTray, type TrayHandle } from '@/main/tray'
import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater'
import { installBrowserUserAgent } from '@/main/user-agent'
@@ -147,15 +168,20 @@ function main(): void {
),
})
const scopeEvents = new ScopedEventRouter()
const terminal = new TerminalRegistry({
load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined,
save: (scopeId, snapshot) => desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot),
migrate: (fromScopeId, toScopeId) =>
desktopChatSessions.migrateTerminal(processOrigin, fromScopeId, toScopeId),
disposeScope: (scopeId) => {
desktopChatSessions.deleteScope(processOrigin, scopeId)
const terminal = new TerminalRegistry(
{
load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined,
save: (scopeId, snapshot) =>
desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot),
migrate: (fromScopeId, toScopeId) =>
desktopChatSessions.migrateTerminal(processOrigin, fromScopeId, toScopeId),
disposeScope: (scopeId) => {
desktopChatSessions.deleteScope(processOrigin, scopeId)
},
},
})
undefined,
createRunLedger(join(userDataPath, 'terminal-runs'))
)
const preloadPath = join(__dirname, 'preload.cjs')
const windows = new Set<BrowserWindow>()
@@ -306,7 +332,22 @@ function main(): void {
// Shells are account-scoped runtime state. Leaving them alive across
// sign-out would stream the previous account's output into the next
// renderer and keep its local processes running invisibly.
{ label: 'terminal sessions', clear: () => terminal.dispose() },
{
label: 'background executor',
clear: async () => {
approvalNotifier.clear()
await desktopExecutor.signOut()
},
},
{
label: 'terminal sessions',
// Agent commands are stopped by their own process groups first: closing a shell only
// hangs up its foreground, and a tmux run outlives the Sim terminal entirely.
clear: async () => {
await terminal.stopAgentCommands()
terminal.dispose()
},
},
{ label: 'task resource state', clear: clearDesktopChatSessions },
{ label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() },
]
@@ -501,10 +542,19 @@ function main(): void {
// pinned strip survive, so switching back on resumes rather than restarts.
setBrowserEnabled: (enabled) => {
if (!enabled) closeAgentBrowserSession()
desktopExecutor.refreshRegistration()
},
setTerminalEnabled: (enabled) => {
if (!enabled) terminal.dispose()
// Agent commands are stopped by their process groups first; a tmux run outlives its shell.
if (!enabled) {
void terminal.stopAgentCommands().then(() => {
// Switched back on while the commands stopped: the shells opened since are the user's.
if (!desktopSettings.getPreferences().terminalEnabled) terminal.dispose()
})
}
desktopExecutor.refreshRegistration()
},
setPreventSleepWhileRunning: () => sleepBlocker.refresh(),
setBrowserTheme: setAgentBrowserTheme,
setBrowserDefaultZoom: setAgentBrowserDefaultZoom,
setTerminalDefaultZoom: (zoom) => {
@@ -531,6 +581,65 @@ function main(): void {
},
})
const sleepBlocker = createSleepBlocker({
enabled: () => desktopSettings.getPreferences().preventSleepWhileRunning ?? true,
powerSaveBlocker,
})
const approvalNotifier = createApprovalNotifier({
preferences: () => desktopSettings.getPreferences(),
focusedChatId: () => {
const win = focusedAppWindow()
if (!win) return null
const route = routeFromAppUrl(win.webContents.getURL())
return route ? (/\/chat\/([^/?#]+)/.exec(route)?.[1] ?? null) : null
},
openRoute: (route) => void openMainWindowAt(route),
createNotification: (options) =>
Notification.isSupported() ? new Notification(options) : null,
})
const desktopExecutor = createDesktopExecutorService({
userDataPath,
origin: appOrigin,
appSession: ensureAppSession,
preferences: () => desktopSettings.getPreferences(),
accountDataAvailable,
onApprovals: (items) => approvalNotifier.update(items),
onBusyChange: (busy) => sleepBlocker.setBusy(busy),
// A result the model has makes a tmux run it handed back as still going collectable across a
// restart.
onResultDelivered: (toolCallId) => terminal.markRunDelivered(toolCallId),
// A result the model never got leaves such a run with no one to come back to it, so it is
// stopped, as the launch sweep stops a previous process's.
onResultNotDelivered: (toolCallId) => void terminal.stopUndeliveredRun(toolCallId),
runner: createDesktopToolRunner({
preferences: () => desktopSettings.getPreferences(),
accountDataAvailable,
browser: {
executeTool: (scopeId, tool, params, toolCallId) =>
executeAgentBrowserTool(scopeId, tool, params, toolCallId, undefined, {
background: true,
}),
cancelTool: cancelAgentBrowserTool,
hasSession: hasBrowserScopeSession,
restoreScope: restoreAgentBrowserScope,
},
terminal,
localFiles: {
request: (call, request) =>
executeLocalFileRequest(request, { toolName: call.toolName, args: call.args }),
},
imports: {
importEntry: (request, signal) => desktopExecutor.importEntry(request, signal),
},
localFilesystem: {
handle: (request) => localFilesystem.handle(request),
vfsRoot: mountVfsRoot,
},
}),
})
const serverWindow = createServerWindow({
config,
defaultOrigin: DEFAULT_ORIGIN,
@@ -674,7 +783,14 @@ function main(): void {
...(kind === 'account' && origin
? [
{ label: 'sign-in handoff state', clear: () => handoff.clear() },
{ label: 'terminal sessions', clear: () => terminal.dispose() },
{ label: 'background executor', clear: () => desktopExecutor.signOut() },
{
label: 'terminal sessions',
clear: async () => {
await terminal.stopAgentCommands()
terminal.dispose()
},
},
{ label: 'task resource state', clear: clearDesktopChatSessions },
{
label: 'app session storage',
@@ -700,6 +816,11 @@ function main(): void {
}
}
// The same user's tmux runs from a previous process: one whose pane the model has, or will get
// from recovery, is left to the model, which may come back to it; any other has nothing left
// to collect what it does, so it is stopped, while its pane still carries its tag.
void desktopExecutor.pendingResults().then((pending) => terminal.stopUncollectableRuns(pending))
if (!accountDataAvailable()) {
logger.warn(
'Account-bearing browser, terminal, and local filesystem APIs are unavailable until local recovery succeeds'
@@ -842,7 +963,15 @@ function main(): void {
getConfiguration: () => serverWindow.getConfiguration(),
setOrigin: (origin) => serverWindow.setOrigin(origin),
},
getExecutorDevice: () => desktopExecutor.getDevice(),
})
if (accountDataAvailable()) {
// A sign-in, or a session rotation, binds the device to the new session.
ensureAppSession().cookies.on('changed', (_event, cookie, _cause, removed) => {
if (!removed && isSessionCookieName(cookie.name)) desktopExecutor.refreshRegistration()
})
desktopExecutor.start()
}
await ensureMainWindow()
installApplicationMenu({
config,
+4 -2
View File
@@ -289,6 +289,7 @@ describe('registerIpcHandlers', () => {
mockCoordinator.fillCredential.mockClear()
deps = {
appOrigin: () => APP,
getExecutorDevice: () => null,
allowHttpLocalhost: () => false,
accountDataAvailable: () => true,
isLocalPageUrl,
@@ -310,6 +311,7 @@ describe('registerIpcHandlers', () => {
getPreferences: vi.fn(() => DEFAULT_DESKTOP_PREFERENCES),
setPreference: vi.fn(),
setBrowserSearchSuggestionsEnabled: vi.fn(),
setPreventSleepWhileRunning: vi.fn(),
setAppearancePreference: vi.fn(),
setBrowserDefaultZoom: vi.fn(),
setTerminalDefaultZoom: vi.fn(),
@@ -485,7 +487,7 @@ describe('registerIpcHandlers', () => {
expect(mounts).not.toHaveBeenCalled()
expect(fetchAuthorization).toHaveBeenCalledWith(
`${APP}/api/desktop/tool/authorize`,
expect.objectContaining({ body: JSON.stringify({ toolCallId: 'tool-native' }) })
expect.objectContaining({ body: JSON.stringify({ toolCallId: 'tool-native', claim: true }) })
)
expect(
await handler?.(evilEvent, { operation: 'read', toolCallId: 'tool-native' })
@@ -569,7 +571,7 @@ describe('registerIpcHandlers', () => {
expect.objectContaining({
method: 'POST',
credentials: 'include',
body: JSON.stringify({ toolCallId: 'tool-1' }),
body: JSON.stringify({ toolCallId: 'tool-1', claim: true }),
})
)
})
+31 -4
View File
@@ -9,6 +9,7 @@ import {
isCurrentBrowserToolName,
} from '@sim/browser-protocol'
import {
type DesktopExecutorDevice,
type DesktopNotificationPayload,
type DesktopServerChangeResult,
type DesktopServerConfiguration,
@@ -304,10 +305,11 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
if (typeof raw !== 'object' || raw === null) {
return null
}
const { title, body, route } = raw as {
const { title, body, route, background } = raw as {
title?: unknown
body?: unknown
route?: unknown
background?: unknown
}
if (
typeof title !== 'string' ||
@@ -322,7 +324,12 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
if (route !== undefined && (typeof route !== 'string' || !isSafeInternalPath(route))) {
return null
}
return { title, body, ...(route !== undefined ? { route } : {}) }
return {
title,
body,
...(route !== undefined ? { route } : {}),
...(background === true ? { background: true } : {}),
}
}
export interface IpcDeps {
@@ -374,6 +381,8 @@ export interface IpcDeps {
getConfiguration: () => DesktopServerConfiguration
setOrigin: (origin: string) => Promise<DesktopServerChangeResult>
}
/** The device a new chat turn may bind to, or null while Sim has not enabled one. */
getExecutorDevice: () => DesktopExecutorDevice | null
}
/**
@@ -578,10 +587,13 @@ async function authorizeLocalFilesystemTool(
request: unknown
): Promise<boolean> {
if (typeof request !== 'object' || request === null) return false
// Claimed like an import: the server sees the read picked up, and refuses one it already
// failed as never started.
const authorization = await fetchDesktopToolAuthorization(
event,
deps,
(request as { requestId?: unknown }).requestId
(request as { requestId?: unknown }).requestId,
true
)
return authorization
? deps.localFilesystem.isAuthorizedClientToolRequest(request, authorization)
@@ -768,6 +780,12 @@ export function registerIpcHandlers(deps: IpcDeps): void {
},
handler: (request) => deps.localFilesystem.handle(request),
},
'desktop-executor:get-device': {
kind: 'invoke',
gate: 'app-origin',
denied: null,
handler: () => deps.getExecutorDevice(),
},
'desktop:settings:get': {
kind: 'invoke',
gate: 'app-origin',
@@ -793,6 +811,15 @@ export function registerIpcHandlers(deps: IpcDeps): void {
? deps.settings.setBrowserSearchSuggestionsEnabled(enabled)
: deps.settings.getPreferences(),
},
'desktop:settings:set-prevent-sleep': {
kind: 'invoke',
gate: 'app-origin',
denied: null,
handler: (enabled) =>
typeof enabled === 'boolean'
? deps.settings.setPreventSleepWhileRunning(enabled)
: deps.settings.getPreferences(),
},
'desktop:settings:set-appearance': {
kind: 'invoke',
gate: 'app-origin',
@@ -2113,7 +2140,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
event,
deps,
request.toolCallId,
request.operation === 'manifest',
request.operation === 'manifest' || request.operation === 'read',
(status) => {
failureStatus = status
}
@@ -107,6 +107,27 @@ describe('LocalFilesystemService', () => {
})
})
it('trims long runs of slashes in grep and glob paths in linear time', async () => {
const granted = await mount(service)
const slashes = `${'/'.repeat(200_000)}x`
const startedAt = performance.now()
expect(
service.isAuthorizedClientToolRequest(
{ operation: 'grep', uri: granted.uri, pattern: 'TODO', requestId: 'grep-tool' },
{ toolName: 'grep', args: { path: slashes, pattern: 'TODO' } }
)
).toBe(false)
await service.handle({
operation: 'glob',
uri: granted.uri,
pattern: '**/*.ts',
pathPrefix: slashes,
})
expect(performance.now() - startedAt).toBeLessThan(1000)
})
it('binds privileged client reads and searches to server-persisted tool args', async () => {
const granted = await mount(service)
const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}`
@@ -236,6 +257,27 @@ describe('LocalFilesystemService', () => {
).toBe(false)
})
it('hands back nothing from a folder forgotten while the read ran', async () => {
const granted = await mount(service)
const reading = service.handle({ operation: 'read', uri: `${granted.uri}README.md` })
const searching = service.handle({ operation: 'grep', uri: granted.uri, pattern: 'hello' })
await service.handle({ operation: 'forget_mount', uri: granted.uri })
await expect(reading).resolves.toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' })
await expect(searching).resolves.toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' })
})
it('keeps a read whose folder the user selected again while it ran', async () => {
const granted = await mount(service)
const reading = service.handle({ operation: 'read', uri: `${granted.uri}README.md` })
const again = await mount(service)
expect(again.uri).toBe(granted.uri)
await expect(reading).resolves.toMatchObject({ ok: true })
})
it('rejects unknown mounts and symlinks that escape the selected directory', async () => {
const granted = await mount(service)
const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-'))
+29 -10
View File
@@ -19,7 +19,7 @@ import {
} from '@sim/desktop-bridge/local-filesystem-limits'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { escapeRegExp, truncate } from '@sim/utils/string'
import { escapeRegExp, stripTrailingSlashes, truncate } from '@sim/utils/string'
import { app, dialog, shell } from 'electron'
import micromatch from 'micromatch'
import safeRegex from 'safe-regex2'
@@ -75,6 +75,22 @@ class LocalFilesystemError extends Error {
}
}
function mountNotFound(): LocalFilesystemError {
return new LocalFilesystemError(
'MOUNT_NOT_FOUND',
'That local folder is no longer available. Select it again.'
)
}
/** Operations that read inside one granted folder, named by the request's `uri`. */
const GRANT_SCOPED_OPERATIONS: ReadonlySet<string> = new Set([
'list',
'glob',
'read',
'grep',
'stat',
])
interface GrantedMount extends LocalFilesystemMount {
rootPath: string
bookmark?: string
@@ -157,7 +173,8 @@ function normalizeVfsDisplaySegment(segment: string): string {
.replace(/\s+/g, ' ')
}
function mountVfsRoot(mount: GrantedMount): string {
/** The `user-local/<name>--<id>` directory a granted folder appears under in the model's VFS. */
export function mountVfsRoot(mount: Pick<GrantedMount, 'id' | 'name'>): string {
return `user-local/${encodeURIComponent(normalizeVfsDisplaySegment(mount.name))}--${mount.id}`
}
@@ -420,7 +437,13 @@ export class LocalFilesystemService {
}
let data: LocalFilesystemData
// Reads and searches answer only while their folder is still granted: one forgotten while
// they ran must not hand back what they found in it.
let grant: GrantedMount | null = null
try {
if (GRANT_SCOPED_OPERATIONS.has(request.operation)) {
grant = this.parseUri(this.requiredUri(request)).mount
}
switch (request.operation) {
case 'mount_directory':
data = await this.mountDirectory()
@@ -470,6 +493,7 @@ export class LocalFilesystemService {
this.activeRequests.delete(requestId)
}
}
if (grant && this.mounts.get(grant.id)?.rootPath !== grant.rootPath) throw mountNotFound()
return { ok: true, data }
} catch (error) {
const safe = safeError(error)
@@ -532,7 +556,7 @@ export class LocalFilesystemService {
// request carrying them is the renderer searching for something the
// model did not ask for, or hiding results it believes are complete.
if (request.query !== undefined || request.include !== undefined) return false
const rawPath = typeof args.path === 'string' ? args.path.replace(/\/+$/, '') : ''
const rawPath = typeof args.path === 'string' ? stripTrailingSlashes(args.path) : ''
const uriAllowed =
rawPath === 'user-local'
? [...this.mounts.values()].some((mount) => request.uri === mount.uri)
@@ -889,12 +913,7 @@ export class LocalFilesystemService {
}
const mount = this.mounts.get(parsed.hostname)
if (!mount) {
throw new LocalFilesystemError(
'MOUNT_NOT_FOUND',
'That local folder is no longer available. Select it again.'
)
}
if (!mount) throw mountNotFound()
const encodedSegments = parsed.pathname.split('/').filter(Boolean)
const segments = encodedSegments.map((segment) => {
@@ -989,7 +1008,7 @@ export class LocalFilesystemService {
if (rawPathPrefix !== undefined && typeof rawPathPrefix !== 'string') {
throw new LocalFilesystemError('INVALID_REQUEST', 'pathPrefix must be a string.')
}
const pathPrefix = typeof rawPathPrefix === 'string' ? rawPathPrefix.replace(/\/+$/, '') : ''
const pathPrefix = typeof rawPathPrefix === 'string' ? stripTrailingSlashes(rawPathPrefix) : ''
const matcher = compileGlob(pattern)
const resolvedPath = await this.resolveUri(uri)
const baseStat = await stat(resolvedPath.realPath)
+343 -64
View File
@@ -14,11 +14,10 @@ import { homedir } from 'node:os'
import type { TerminalShortcutCommand } from '@sim/desktop-bridge'
import { createLogger } from '@sim/logger'
import {
DEFAULT_RUN_WAIT_MS,
isTerminalControlKey,
MAX_INPUT_KEYS,
MAX_RUN_WAIT_MS,
MAX_TOOL_OUTPUT_CHARS,
resolveRunWaitMs,
type TerminalCommandEvent,
type TerminalControlKey,
type TerminalCwdResult,
@@ -38,20 +37,26 @@ import {
isResourceTabSelectionShortcut,
resourceTabTargetIndex,
} from '@/main/resource-shortcuts'
import { elide, TerminalSession } from '@/main/terminal/session'
import { readForegroundProcessGroup, signalProcessGroup } from '@/main/terminal/process-group'
import type { RunLedger } from '@/main/terminal/run-ledger'
import { elide, type ShellStartupBounds, TerminalSession } from '@/main/terminal/session'
import {
activePane,
awaitRun,
capturePane,
closeRunWindow,
closeRunPane,
isRunComplete,
isTmuxUnavailable,
killPane,
listPanes,
pollRun,
type RecordedRun,
resolveAttachment,
runPaneState,
sendKey,
sendText,
startRun,
stopRun,
TMUX_KEY_NAMES,
type TmuxAttachment,
type TmuxRunHandle,
@@ -60,11 +65,15 @@ import {
const logger = createLogger('DesktopTerminal')
/**
* How long to let a just-spawned shell finish its startup files before
* concluding it has no integration. Generous because a heavy `.zshrc`
* (nvm, pyenv, starship) can take a while on a cold start.
* How long a just-spawned shell gets to reach its first prompt. It begins our startup files
* before anything of the user's runs, so one that has not within seconds was never instrumented.
* The user's own files get far longer, since a heavy `.zshrc` (oh-my-zsh, nvm, pyenv) is slow on
* a cold start and slower on a busy machine; past that, it is waiting on something.
*/
const SHELL_INTEGRATION_TIMEOUT_MS = 8_000
const SHELL_STARTUP_BOUNDS: ShellStartupBounds = { unstartedMs: 8_000, startingMs: 30_000 }
/** Enough of a stalled startup's screen to show what it is waiting on. */
const STARTUP_SCREEN_LINES = 20
/** Grace for a program to react to input before its screen is worth reading. */
const INPUT_ECHO_MS = 250
@@ -109,14 +118,6 @@ const HANDOFF_MAX_MS = 12 * 60 * 60 * 1000
*/
const HANDOFF_SETTLE_MS = 5_000
/** How long to hold the turn before handing a still-running command back. */
function resolveWaitMs(waitSeconds: number | undefined): number {
const requested = Number(waitSeconds)
return Number.isFinite(requested) && requested > 0
? Math.min(requested * 1000, MAX_RUN_WAIT_MS)
: DEFAULT_RUN_WAIT_MS
}
function elideOutput(value: string): { text: string; truncated: boolean } {
return elide(value, MAX_TOOL_OUTPUT_CHARS)
}
@@ -159,8 +160,61 @@ export interface TerminalServiceOptions {
*/
loadCwd?(): string | undefined
canSpawn?(): boolean
/** Reads and signals a terminal's foreground process group; the OS's by default. */
processGroups?: TerminalProcessGroups
/** Where tagged tmux runs are recorded, so a later process can still stop them. */
runLedger?: RunLedger
}
interface TerminalProcessGroups {
foreground(shellPid: number): Promise<number | null>
signal(pgid: number, signal: NodeJS.Signals): void
}
const OS_PROCESS_GROUPS: TerminalProcessGroups = {
foreground: readForegroundProcessGroup,
signal: signalProcessGroup,
}
/**
* One tool call's Stop: remembered if it arrives before the call's command starts, and handed to
* whatever is running once it does.
*/
interface StopLatch {
/** Aborts on Stop; work not yet started checks it, and input stops between keystrokes. */
readonly signal: AbortSignal
stopRunning: (() => Promise<void>) | null
}
function stoppedBeforeStart(): TerminalError {
return new TerminalError(
'CANCELLED',
'Stopped before the command started, so nothing ran in the terminal.'
)
}
function stoppedPartWay(): TerminalError {
return new TerminalError(
'CANCELLED',
'Stopped part way through the input; some of it may already have reached the terminal.'
)
}
/** Operations that change a terminal; a Stop that lands before one starts means it never does. */
const TERMINAL_CHANGING_OPERATIONS: ReadonlySet<TerminalOperation> = new Set([
'close',
'handoff',
'input',
'kill',
'run',
])
/**
* How long a stopped command gets to exit after each escalation: Ctrl-C first, as the user would
* press it, then SIGTERM and finally SIGKILL to its process group.
*/
const STOP_ESCALATION_MS = 2_000
export class TerminalService {
/** Insertion-ordered, which is also the tab order the user sees. */
private readonly sessions = new Map<string, TerminalSession>()
@@ -202,6 +256,17 @@ export class TerminalService {
* Held here so the terminal's own lifecycle can reclaim them.
*/
private readonly pendingRuns = new Map<string, TmuxRunHandle[]>()
/**
* Agent runs still going in tmux after their Sim terminal closed: the tmux session outlives the
* tab, but sign-out must still stop them.
*/
private readonly orphanedRuns = new Map<TmuxRunHandle, NodeJS.ProcessEnv>()
/** Runs a `run` call is still waiting on; their files are read when the wait ends. */
private readonly awaitedRuns = new Set<TmuxRunHandle>()
/** Awaited runs released meanwhile (their terminal closed); their files go once the wait ends. */
private readonly releasedAwaitedRuns = new Set<TmuxRunHandle>()
/** How to stop each tool call still in flight, so Stop interrupts exactly what it started. */
private readonly toolStops = new Map<string, () => Promise<void>>()
constructor(private readonly options: TerminalServiceOptions = {}) {}
@@ -421,33 +486,76 @@ export class TerminalService {
}
/**
* Removes the temp directories of tracked runs that have since finished.
* Removes the temp directories of tracked runs that have since finished, or whose pane is gone
* (the user closed it, or tmux restarted): nothing will ever write their status, and their ids
* may already belong to the user's own panes.
*
* Called when a new run starts on the same terminal, which is the one moment
* the service is already doing run bookkeeping — a dedicated reaper timer
* would be a subsystem to own for something this cheap. A run still going is
* left alone: its `tee` is still appending to that directory.
*/
private reapFinishedRuns(terminalId: string): void {
const pending = this.pendingRuns.get(terminalId)
if (!pending) return
const stillRunning: TmuxRunHandle[] = []
for (const handle of pending) {
if (isRunComplete(handle)) handle.dispose()
else stillRunning.push(handle)
private async reapFinishedRuns(terminalId: string, env: NodeJS.ProcessEnv): Promise<void> {
// A closed tab's run whose pane has since gone (its command ended) needs no stopping.
for (const [handle, orphanEnv] of this.orphanedRuns) {
if ((await runPaneState(handle, orphanEnv)) === 'gone') {
this.orphanedRuns.delete(handle)
this.forgetRun(handle)
}
}
if (stillRunning.length === 0) this.pendingRuns.delete(terminalId)
else this.pendingRuns.set(terminalId, stillRunning)
for (const handle of this.pendingRuns.get(terminalId) ?? []) {
if (this.awaitedRuns.has(handle)) continue
const complete = isRunComplete(handle)
if (complete || (await runPaneState(handle, env)) === 'gone') {
// A pane kept open after its command ended (`remain-on-exit`) closes with its run.
if (complete) await closeRunPane(handle, env)
this.untrackRun(terminalId, handle)
this.forgetRun(handle)
handle.dispose()
}
}
}
/** Drops a run's record once nothing of it is left for any process to stop. */
private forgetRun(handle: TmuxRunHandle): void {
if (handle.runId) this.options.runLedger?.forget(handle.runId)
}
/** Removes a run's files now, or once the call still reading them is done with them. */
private releaseRun(handle: TmuxRunHandle): void {
if (this.awaitedRuns.has(handle)) this.releasedAwaitedRuns.add(handle)
else handle.dispose()
}
private untrackRun(terminalId: string, handle: TmuxRunHandle): void {
const remaining = (this.pendingRuns.get(terminalId) ?? []).filter((entry) => entry !== handle)
if (remaining.length === 0) this.pendingRuns.delete(terminalId)
else this.pendingRuns.set(terminalId, remaining)
}
/**
* Releases every tracked run for a terminal, finished or not. The terminal is
* going away, so nothing will ever read these files again.
*/
private releasePendingRuns(terminalId: string): void {
private releasePendingRuns(terminalId: string, env?: NodeJS.ProcessEnv): void {
const pending = this.pendingRuns.get(terminalId)
if (!pending) return
for (const handle of pending) handle.dispose()
for (const handle of pending) {
// A finished run's pane may still be open (`remain-on-exit`): it is closed, while still the
// run's, before the record goes, and its files go only after that check, which an untracked
// run needs them for. Without the shell's environment the record stays, and the next sweep
// closes it. An untracked run is never stopped, so it is not kept either.
if (isRunComplete(handle) && env) {
void closeRunPane(handle, env)
.then(async () => {
if ((await runPaneState(handle, env)) === 'gone') this.forgetRun(handle)
})
.finally(() => this.releaseRun(handle))
continue
}
if (env && handle.runId !== null && !isRunComplete(handle)) this.orphanedRuns.set(handle, env)
this.releaseRun(handle)
}
this.pendingRuns.delete(terminalId)
}
@@ -461,10 +569,11 @@ export class TerminalService {
const closedCwd = session.currentCwd
const order = [...this.sessions.keys()]
const index = order.indexOf(terminalId)
const env = session.env
session.dispose()
this.sessions.delete(terminalId)
this.tmuxCache.delete(terminalId)
this.releasePendingRuns(terminalId)
this.releasePendingRuns(terminalId, env)
this.rememberClosed(closedCwd)
// Nothing is left for the user to hold on to; the next shell the agent
@@ -758,7 +867,7 @@ export class TerminalService {
this.sessions.clear()
this.tmuxCache.clear()
for (const handles of this.pendingRuns.values()) {
for (const handle of handles) handle.dispose()
for (const handle of handles) this.releaseRun(handle)
}
this.pendingRuns.clear()
this.activeId = null
@@ -775,8 +884,17 @@ export class TerminalService {
operation: TerminalOperation,
args: TerminalToolArgs
): Promise<TerminalToolResponse> {
// A Stop can arrive before the command exists (while the shell or tmux is still being
// resolved); the latch carries it to the moment the command would start.
const halt = new AbortController()
const latch: StopLatch = { signal: halt.signal, stopRunning: null }
const stop = async () => {
halt.abort()
await latch.stopRunning?.()
}
this.toolStops.set(toolCallId, stop)
try {
const result = await this.dispatch(toolCallId, operation, args ?? {})
const result = await this.dispatch(toolCallId, operation, args ?? {}, latch)
return { ok: true, result }
} catch (error) {
if (error instanceof TerminalError) {
@@ -786,13 +904,95 @@ export class TerminalService {
const message = (error as Error).message
logger.error('Terminal operation failed', { toolCallId, operation, error: message })
return { ok: false, error: message }
} finally {
if (this.toolStops.get(toolCallId) === stop) this.toolStops.delete(toolCallId)
}
}
/**
* Stops a tool call still in flight: interrupts the command a `run` started (escalating to its
* process group if Ctrl-C does not end it) or ends a handoff. The call then returns its result
* as usual. False when this service is not running that call.
*/
async cancelTool(toolCallId: string): Promise<boolean> {
const stop = this.toolStops.get(toolCallId)
if (!stop) return false
await stop()
return true
}
/**
* Stops every command the agent started that is still running, for sign-out: a plain shell's
* agent command by its own process group, as Stop does, and every tmux run window still going.
* A command the user started is not the agent's and is left alone.
*/
async stopAgentCommands(): Promise<void> {
const stops: Promise<void>[] = []
for (const session of this.sessions.values()) {
const toolCallId = session.agentCommandToolCallId
if (toolCallId) stops.push(this.stopCommand(session, toolCallId))
for (const handle of this.pendingRuns.get(session.terminalId) ?? []) {
if (!isRunComplete(handle)) stops.push(stopRun(handle, session.env, STOP_ESCALATION_MS))
}
}
for (const [handle, env] of this.orphanedRuns) {
stops.push(stopRun(handle, env, STOP_ESCALATION_MS))
}
this.orphanedRuns.clear()
await Promise.allSettled(stops)
}
/** Stops the command a plain shell's `run` handed back as still going, if it still is. */
async stopAgentCommand(toolCallId: string): Promise<void> {
await Promise.allSettled(
[...this.sessions.values()].map((session) => this.stopCommand(session, toolCallId))
)
}
/** Waits for the command a run started to end, up to `ms`. */
private async commandEnds(
session: TerminalSession,
toolCallId: string,
ms: number
): Promise<boolean> {
const deadline = Date.now() + ms
while (session.agentCommandToolCallId === toolCallId) {
if (Date.now() >= deadline || !session.alive) {
return session.agentCommandToolCallId !== toolCallId
}
await sleep(50)
}
return true
}
/**
* Interrupts the command a run started. Escalation is bound to that command's own process
* group, read while it still holds the foreground, and each signal is sent only while the same
* call and the same group still hold it, so a command the user starts meanwhile is never hit.
*/
private async stopCommand(session: TerminalSession, toolCallId: string): Promise<void> {
if (session.agentCommandToolCallId !== toolCallId) return
const groups = this.options.processGroups ?? OS_PROCESS_GROUPS
const pgid = await groups.foreground(session.pid)
if (session.agentCommandToolCallId !== toolCallId) return
session.kill('SIGINT')
for (const escalation of ['SIGTERM', 'SIGKILL'] as const) {
if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return
if (pgid === null || (await groups.foreground(session.pid)) !== pgid) return
if (session.agentCommandToolCallId !== toolCallId) return
logger.info('Stopped command ignored the previous signal; escalating', {
toolCallId,
signal: escalation,
})
groups.signal(pgid, escalation)
}
}
private async dispatch(
toolCallId: string,
operation: TerminalOperation,
args: TerminalToolArgs
args: TerminalToolArgs,
latch: StopLatch
): Promise<unknown> {
switch (operation) {
case 'list':
@@ -816,6 +1016,10 @@ export class TerminalService {
// A tab either has tmux attached or it does not, and every operation below
// behaves differently depending on which.
const tmux = await this.resolveTmux(session)
// A Stop that landed while the session resolved: nothing that changes the terminal starts.
if (latch.signal.aborted && TERMINAL_CHANGING_OPERATIONS.has(operation)) {
throw stoppedBeforeStart()
}
switch (operation) {
case 'cwd':
@@ -833,6 +1037,7 @@ export class TerminalService {
)
}
const target = await this.resolvePane(tmux.session, args, session)
if (latch.signal.aborted) throw stoppedBeforeStart()
const killed = await killPane(target, session.env)
if (!killed.ok) {
throw new TerminalError(
@@ -847,6 +1052,8 @@ export class TerminalService {
}
}
case 'handoff':
if (latch.signal.aborted) throw stoppedBeforeStart()
latch.stopRunning = async () => this.finishHandoff(session.terminalId)
return this.handoff(session, args)
case 'panes': {
if (!tmux) {
@@ -863,8 +1070,8 @@ export class TerminalService {
}
case 'run':
return tmux
? this.runInTmux(session, tmux.session, args)
: this.run(toolCallId, session, args)
? this.runInTmux(toolCallId, session, tmux.session, args, latch)
: this.run(toolCallId, session, args, latch)
case 'read': {
const requested = Number(args.lines)
const lines = Number.isFinite(requested) && requested > 0 ? requested : 200
@@ -888,8 +1095,8 @@ export class TerminalService {
}
case 'input':
return tmux
? this.inputToTmux(session, tmux.session, args)
: this.inputToShell(session, args)
? this.inputToTmux(session, tmux.session, args, latch.signal)
: this.inputToShell(session, args, latch.signal)
case 'kill': {
const signal =
args.signal === 'SIGTERM' || args.signal === 'SIGKILL' || args.signal === 'SIGINT'
@@ -900,6 +1107,7 @@ export class TerminalService {
// whole session rather than stopping the one thing they asked about.
if (tmux) {
const target = await this.resolvePane(tmux.session, args, session)
if (latch.signal.aborted) throw stoppedBeforeStart()
await sendKey(target, signal === 'SIGKILL' ? 'C-\\' : 'C-c', session.env)
return { signal, terminalId: session.terminalId, pane: target }
}
@@ -1015,15 +1223,18 @@ export class TerminalService {
private async inputToTmux(
terminal: TerminalSession,
session: string,
args: TerminalToolArgs
args: TerminalToolArgs,
signal: AbortSignal
): Promise<unknown> {
const target = await this.resolvePane(session, args, terminal)
if (signal.aborted) throw stoppedBeforeStart()
const keys = requestedKeys(args)
if (keys.length > 0) {
for (let index = 0; index < keys.length; index += 1) {
// Paced like the pty path: a pane redraws between presses, so a batch
// lands where the same keys pressed by hand would.
if (index > 0) await sleep(TMUX_KEY_GAP_MS)
if (signal.aborted) throw stoppedPartWay()
await sendKey(target, TMUX_KEY_NAMES[keys[index]] ?? keys[index], terminal.env)
}
} else if (typeof args.text === 'string') {
@@ -1031,6 +1242,7 @@ export class TerminalService {
// Enter is a separate send-keys for the same reason it is a separate pty
// write: a program reading one chunk treats text plus a carriage return
// as text, and the message sits unsubmitted.
if (signal.aborted) throw stoppedPartWay()
if (/[\r\n]$/.test(args.text)) await sendKey(target, 'Enter', terminal.env)
} else {
throw new TerminalError('INVALID_REQUEST', 'input needs `text`, `key`, or `keys`.')
@@ -1046,7 +1258,11 @@ export class TerminalService {
}
}
private async inputToShell(session: TerminalSession, args: TerminalToolArgs): Promise<unknown> {
private async inputToShell(
session: TerminalSession,
args: TerminalToolArgs,
signal: AbortSignal
): Promise<unknown> {
// Input is only ever delivered to a program that already holds the
// foreground. At a bare shell prompt these bytes would be a command
// line, and running commands that way would bypass the capture and
@@ -1061,14 +1277,17 @@ export class TerminalService {
// lets the model assume its message went through and start waiting on
// a reply to text still sitting unsubmitted in a composer; the screen
// is the evidence of what the program actually did with the input.
if (signal.aborted) throw stoppedBeforeStart()
const keys = requestedKeys(args)
if (keys.length > 0) {
await session.pressKeys(keys)
await session.pressKeys(keys, signal)
if (signal.aborted) throw stoppedPartWay()
await sleep(INPUT_ECHO_MS)
return { sent: keys.join(', '), ...(await session.readScrollback(INPUT_SCREEN_LINES)) }
}
if (typeof args.text === 'string') {
await session.type(args.text)
await session.type(args.text, signal)
if (signal.aborted) throw stoppedPartWay()
await sleep(INPUT_ECHO_MS)
return { sent: args.text, ...(await session.readScrollback(INPUT_SCREEN_LINES)) }
}
@@ -1085,30 +1304,68 @@ export class TerminalService {
* see through tmux.
*/
private async runInTmux(
toolCallId: string,
terminal: TerminalSession,
session: string,
args: TerminalToolArgs
args: TerminalToolArgs,
latch: StopLatch
): Promise<unknown> {
const command = typeof args.command === 'string' ? args.command.trim() : ''
if (!command) throw new TerminalError('INVALID_REQUEST', 'run needs a `command`.')
if (latch.signal.aborted) throw stoppedBeforeStart()
const started = Date.now()
this.reapFinishedRuns(terminal.terminalId)
const handle = await startRun(session, command, terminal.currentCwd, terminal.env)
await this.reapFinishedRuns(terminal.terminalId, terminal.env)
const ledger = this.options.runLedger
const handle = await startRun(session, command, terminal.currentCwd, terminal.env, {
...(ledger
? {
beforeStart: (run: RecordedRun) =>
ledger.record({ ...run, callId: toolCallId, state: 'started' }),
}
: {}),
})
if ('error' in handle) throw new TerminalError('SPAWN_FAILED', handle.error)
// Tracked from the moment its window exists, so sign-out can stop it even mid-wait.
const pending = this.pendingRuns.get(terminal.terminalId)
if (pending) pending.push(handle)
else this.pendingRuns.set(terminal.terminalId, [handle])
this.awaitedRuns.add(handle)
const waitMs = resolveWaitMs(args.waitSeconds)
const outcome = await awaitRun(handle, waitMs)
if (outcome.done) {
await closeRunWindow(handle, terminal.env)
handle.dispose()
} else {
// Still going, and nothing polls the status file again — `read` captures
// the pane instead.
const pending = this.pendingRuns.get(terminal.terminalId)
if (pending) pending.push(handle)
else this.pendingRuns.set(terminal.terminalId, [handle])
const waitMs = resolveRunWaitMs(args.waitSeconds)
// Inside tmux a stop arrives as Ctrl-C in the run's own window; closing that window hangs up
// anything that ignored it. The wait ends with the stop, since a closed window never writes
// the run's exit status.
let endWait: () => void = () => {}
const stopped = new Promise<void>((resolve) => {
endWait = resolve
})
latch.stopRunning = async () => {
await stopRun(handle, terminal.env, STOP_ESCALATION_MS)
endWait()
}
// A Stop that landed while the run window opened applies now.
if (latch.signal.aborted) void latch.stopRunning()
const outcome = await Promise.race([
awaitRun(handle, waitMs),
// A stopped run's closed pane never writes its status. An untracked run is never stopped,
// so it is still going unless its status says otherwise.
stopped.then(() =>
handle.runId === null ? pollRun(handle) : { ...pollRun(handle), done: true }
),
]).finally(() => {
this.awaitedRuns.delete(handle)
if (this.releasedAwaitedRuns.delete(handle)) handle.dispose()
})
if (outcome.done) {
await closeRunPane(handle, terminal.env)
this.untrackRun(terminal.terminalId, handle)
this.forgetRun(handle)
handle.dispose()
}
// Still going, it stays tracked, and nothing polls the status file again: `read` captures
// the pane instead. Its record is marked handed back only once that result reaches the model;
// see `TerminalRegistry.markRunDelivered`.
const { text, truncated } = elideOutput(outcome.output)
return {
@@ -1119,7 +1376,7 @@ export class TerminalService {
durationMs: Date.now() - started,
cwd: terminal.currentCwd,
terminalId: terminal.terminalId,
pane: handle.window,
pane: handle.pane,
truncated,
}
}
@@ -1127,20 +1384,15 @@ export class TerminalService {
private async run(
toolCallId: string,
session: TerminalSession,
args: TerminalToolArgs
args: TerminalToolArgs,
latch: StopLatch
): Promise<unknown> {
const command = typeof args.command === 'string' ? args.command.trim() : ''
if (!command) {
throw new TerminalError('INVALID_REQUEST', 'run needs a `command`.')
}
if (!session.hasShellIntegration) {
await session.waitForShellIntegration(SHELL_INTEGRATION_TIMEOUT_MS)
}
if (!session.hasShellIntegration) {
throw new TerminalError(
'NO_SHELL_INTEGRATION',
'This shell did not load Sim shell integration, so command boundaries and exit codes cannot be determined. Ask the user to run the command themselves, or use a bash/zsh session.'
)
await this.awaitShellStartup(session, latch)
}
if (session.isBusy) {
throw new TerminalError(
@@ -1149,7 +1401,34 @@ export class TerminalService {
)
}
return session.runCommand(command, toolCallId, resolveWaitMs(args.waitSeconds))
if (latch.signal.aborted) throw stoppedBeforeStart()
latch.stopRunning = () => this.stopCommand(session, toolCallId)
return session.runCommand(command, toolCallId, resolveRunWaitMs(args.waitSeconds))
}
/** Waits for a shell's first prompt, refusing the run with what it is doing if none comes. */
private async awaitShellStartup(session: TerminalSession, latch: StopLatch): Promise<void> {
const readiness = await session.waitForShellIntegration(SHELL_STARTUP_BOUNDS, latch.signal)
switch (readiness) {
case 'ready':
return
case 'stopped':
throw stoppedBeforeStart()
case 'exited':
throw new TerminalError('SESSION_CLOSED', 'The shell exited before it reached a prompt.')
case 'starting': {
const screen = (await session.readScrollback(STARTUP_SCREEN_LINES)).output.trim()
throw new TerminalError(
'NO_SHELL_INTEGRATION',
`The shell began its startup files over ${SHELL_STARTUP_BOUNDS.startingMs / 1000} s ago but never reached a prompt Sim can track, so nothing was run. Its screen:\n${screen || '(empty)'}\nIf a startup file is waiting for an answer, ask the user to answer it in that terminal (terminalId ${session.terminalId}), then run the command again. If the screen shows a prompt, a startup file replaced the shell (such as exec tmux or exec fish), so ask the user to run the command themselves.`
)
}
case 'not-instrumented':
throw new TerminalError(
'NO_SHELL_INTEGRATION',
'This shell did not load Sim shell integration, so command boundaries and exit codes cannot be determined. Ask the user to run the command themselves, or use a bash/zsh session.'
)
}
}
private spawn(
@@ -20,17 +20,21 @@ vi.mock('@/main/terminal/tmux', () => ({
exitCode: tmuxStub.done ? 0 : null,
})),
capturePane: vi.fn(async () => ({ ok: true, stdout: '', stderr: '' })),
closeRunWindow: vi.fn(async () => undefined),
closeRunPane: vi.fn(async () => undefined),
isRunComplete: vi.fn((handle: { window: string }) => tmuxStub.complete.has(handle.window)),
runPaneState: vi.fn(async () => 'ours'),
isTmuxUnavailable: vi.fn(() => false),
killPane: vi.fn(async () => ({ ok: true, stdout: '', stderr: '' })),
listPanes: vi.fn(async () => []),
resolveAttachment: vi.fn(async () => ({ session: 'sess' })),
sendKey: vi.fn(async () => ({ ok: true, stdout: '', stderr: '' })),
sendText: vi.fn(async () => ({ ok: true, stdout: '', stderr: '' })),
stopRun: vi.fn(async () => undefined),
startRun: vi.fn(async () => {
const handle = {
window: `sess:${tmuxStub.handles.length}`,
pane: `%${tmuxStub.handles.length}`,
runId: `run-${tmuxStub.handles.length}`,
outPath: '/tmp/fake/out',
statusPath: '/tmp/fake/status',
dispose: vi.fn(),
@@ -0,0 +1,62 @@
/**
* The process group a terminal's tty currently delivers keyboard signals to: the whole pipeline
* the user would stop with Ctrl-C, including its children, and never the shell itself.
*/
import { spawn } from 'node:child_process'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
const logger = createLogger('DesktopTerminalProcessGroup')
const LOOKUP_TIMEOUT_MS = 2_000
/**
* The tty's foreground process group, read from `ps`. Null when it cannot be read, or when the
* shell itself holds the foreground (it is sitting at its prompt).
*/
export function readForegroundProcessGroup(shellPid: number): Promise<number | null> {
if (!Number.isInteger(shellPid) || shellPid <= 0) return Promise.resolve(null)
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>
try {
child = spawn('ps', ['-o', 'tpgid=', '-p', String(shellPid)], {
stdio: ['ignore', 'pipe', 'ignore'],
})
} catch {
resolve(null)
return
}
let stdout = ''
let settled = false
const finish = (value: number | null) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(null)
}, LOOKUP_TIMEOUT_MS)
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString()
})
child.on('error', () => finish(null))
child.on('close', () => {
const pgid = Number.parseInt(stdout.trim(), 10)
finish(Number.isInteger(pgid) && pgid > 0 && pgid !== shellPid ? pgid : null)
})
})
}
/** Sends `signal` to every process in one group. */
export function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void {
try {
process.kill(-pgid, signal)
} catch (error) {
logger.warn('Could not signal a terminal process group', {
signal,
error: getErrorMessage(error),
})
}
}
@@ -0,0 +1,171 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => import('@/test/electron-mock'))
/**
* Each recorded run's pane as tmux has it: running, dead after its command ended
* (`remain-on-exit`), stopped by Sim, already gone, or one tmux cannot answer for. A pane not
* listed is running.
*/
const { panes } = vi.hoisted(() => ({
panes: new Map<string, 'running' | 'dead' | 'stopped' | 'gone' | 'unknown'>(),
}))
vi.mock('@/main/terminal/tmux', async () => {
const actual =
await vi.importActual<typeof import('@/main/terminal/tmux')>('@/main/terminal/tmux')
const state = (runId: string) => panes.get(runId) ?? 'running'
return {
...actual,
recordedRunState: async (run: { runId: string }) => {
const pane = state(run.runId)
if (pane === 'running') return 'ours'
if (pane === 'dead') return 'finished'
return pane === 'unknown' ? 'unknown' : 'gone'
},
stopRecordedRun: async (run: { runId: string }) => {
if (state(run.runId) === 'unknown') return 'unknown'
if (state(run.runId) === 'running' || state(run.runId) === 'dead') {
panes.set(run.runId, 'stopped')
}
return 'gone'
},
}
})
import { TerminalRegistry } from '@/main/terminal/registry'
import { createRunLedger, type RunState } from '@/main/terminal/run-ledger'
const dirs: string[] = []
function ledgerDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'sim-registry-ledger-'))
dirs.push(dir)
return join(dir, 'terminal-runs')
}
function run(runId: string, pane: string, state: RunState = 'started') {
return { runId, pane, socket: '/tmp/tmux-501/default', callId: `call-${runId}`, state }
}
afterEach(() => {
panes.clear()
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
describe('stopping recorded tmux runs', () => {
it('forgets each run once nothing of it is left, and keeps one tmux could not answer for', async () => {
const dir = ledgerDir()
const previous = createRunLedger(dir)
previous.record(run('stopped', '%1'))
previous.record(run('unanswered', '%2'))
panes.set('unanswered', 'unknown')
// Signing out ends a run whose result the model has, too.
const ledger = createRunLedger(dir)
await new TerminalRegistry(undefined, undefined, ledger).stopAgentCommands()
expect(ledger.list().map((record) => record.runId)).toEqual(['unanswered'])
})
it("at launch, leaves the same user's runs the model can come back to, and stops the rest", async () => {
const dir = ledgerDir()
const previous = createRunLedger(dir)
// Sim took its result: the model has the pane.
previous.record(run('handed-back', '%1', 'delivered'))
previous.record(run('never-handed-back', '%2'))
// Its result is in the journal, unacknowledged: recovery will hand the pane to the model.
previous.record(run('on-its-way', '%3'))
previous.record(run('handed-back-and-gone', '%4', 'delivered'))
panes.set('handed-back-and-gone', 'gone')
// Handed back, but its command has since ended: only its dead pane is left.
previous.record(run('handed-back-and-done', '%5', 'delivered'))
panes.set('handed-back-and-done', 'dead')
const ledger = createRunLedger(dir)
await new TerminalRegistry(undefined, undefined, ledger).stopUncollectableRuns(
new Set(['call-on-its-way'])
)
expect(Object.fromEntries(panes)).toEqual({
'never-handed-back': 'stopped',
'handed-back-and-gone': 'gone',
'handed-back-and-done': 'stopped',
})
expect(
ledger
.list()
.map((record) => record.runId)
.sort()
).toEqual(['handed-back', 'on-its-way'])
})
it('keeps meaning to stop a run a stop for everything could not confirm', async () => {
const dir = ledgerDir()
createRunLedger(dir).record(run('unconfirmed', '%1', 'delivered'))
panes.set('unconfirmed', 'unknown')
const ledger = createRunLedger(dir)
// Sign-out could not confirm the run ended.
await new TerminalRegistry(undefined, undefined, ledger).stopAgentCommands()
panes.set('unconfirmed', 'running')
// A later launch for the same user, with the journal cleared, still stops it.
await new TerminalRegistry(undefined, undefined, ledger).stopUncollectableRuns(new Set())
expect(panes.get('unconfirmed')).toBe('stopped')
expect(ledger.list()).toEqual([])
})
it("stops only the run of a call whose result never reached the model, this process's or a previous one's", async () => {
const dir = ledgerDir()
createRunLedger(dir).record(run('previous', '%1'))
const ledger = createRunLedger(dir)
ledger.record(run('current', '%2'))
ledger.record(run('other', '%3'))
// Recorded as handed back: the model has its pane.
ledger.record(run('handed-back', '%4', 'delivered'))
const registry = new TerminalRegistry(undefined, undefined, ledger)
await registry.stopUndeliveredRun('call-current')
await registry.stopUndeliveredRun('call-previous')
await registry.stopUndeliveredRun('call-handed-back')
expect(panes.get('current')).toBe('stopped')
expect(panes.get('previous')).toBe('stopped')
expect(panes.get('other')).toBeUndefined()
expect(panes.get('handed-back')).toBeUndefined()
expect(
ledger
.list()
.map((record) => record.runId)
.sort()
).toEqual(['handed-back', 'other'])
})
it('notes the run of a call whose result reached the model as handed back', () => {
const dir = ledgerDir()
const ledger = createRunLedger(dir)
ledger.record(run('watched', '%1'))
ledger.record(run('other', '%2'))
new TerminalRegistry(undefined, undefined, ledger).markRunDelivered('call-watched')
expect(Object.fromEntries(ledger.list().map((record) => [record.runId, record.state]))).toEqual(
{ watched: 'delivered', other: 'started' }
)
})
it("at launch, stops the previous process's runs and none this one has started", async () => {
const dir = ledgerDir()
createRunLedger(dir).record(run('previous', '%1'))
const ledger = createRunLedger(dir)
ledger.record(run('current', '%2'))
await new TerminalRegistry(undefined, undefined, ledger).stopRecordedRuns({ excludeLive: true })
expect(ledger.list().map((record) => record.runId)).toEqual(['current'])
})
})
@@ -1,4 +1,5 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { TerminalCommandEvent } from '@sim/terminal-protocol'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -89,11 +90,13 @@ vi.mock('@/main/terminal/session', () => ({
},
}))
import { TerminalService, type TerminalServiceOptions } from '@/main/terminal'
import {
type ScopedTerminalSink,
TerminalRegistry,
type TerminalScopePersistence,
} from '@/main/terminal/registry'
import { createRunLedger } from '@/main/terminal/run-ledger'
function registry(): TerminalRegistry {
return new TerminalRegistry()
@@ -186,6 +189,32 @@ describe('TerminalRegistry', () => {
terminals.dispose()
})
it('gives a service rebuilt after a failed restore the run ledger too', () => {
const ledger = createRunLedger(join(tmpdir(), `sim-registry-ledger-${process.pid}`))
const built: Array<TerminalServiceOptions['runLedger']> = []
const persistence: TerminalScopePersistence = {
load: () => ({ v: 1 as const, tabs: [{ cwd: tmpdir() }, { cwd: tmpdir() }], activeIndex: 0 }),
save: () => true,
migrate: () => true,
disposeScope: () => {},
}
const terminals = new TerminalRegistry(
persistence,
(_scope, options) => {
built.push(options.runLedger)
return new TerminalService(options)
},
ledger
)
createControl.failAt = 2
expect(() => terminals.restoreScope('chat-A')).toThrow('PTY spawn failed')
// The service that failed to restore, and the one built in its place.
expect(built).toHaveLength(2)
expect(built.every((runLedger) => runLedger === ledger)).toBe(true)
})
it('rolls back a partial restore before retrying the complete descriptor', () => {
const persistedTabs = [{ cwd: tmpdir() }, { cwd: process.cwd() }, { cwd: tmpdir() }]
const persistence: TerminalScopePersistence = {
+95 -1
View File
@@ -19,6 +19,11 @@ import {
type TerminalServiceOptions,
type TerminalSink,
} from '@/main/terminal'
import type { RunLedger, RunRecord } from '@/main/terminal/run-ledger'
import { recordedRunState, stopRecordedRun } from '@/main/terminal/tmux'
/** How long a recorded run gets to end on Ctrl-C before its pane is closed. */
const RECORDED_RUN_GRACE_MS = 2_000
/** Native PTYs and their headless xterm buffers are process-wide resources. */
export const MAX_TERMINALS_PER_PROCESS = 48
@@ -106,7 +111,8 @@ export class TerminalRegistry {
constructor(
private readonly persistence?: TerminalScopePersistence,
private readonly serviceFactory: TerminalServiceFactory = createTerminalService
private readonly serviceFactory: TerminalServiceFactory = createTerminalService,
private readonly runLedger?: RunLedger
) {}
setSink(sink: ScopedTerminalSink | null): void {
@@ -234,6 +240,11 @@ export class TerminalRegistry {
return entry.service.executeTool(toolCallId, operation, args)
}
/** Stops one in-flight tool call in a chat's terminals; false when none is running it. */
cancelTool(scope: string, toolCallId: string): Promise<boolean> {
return this.entries.get(scope)?.service.cancelTool(toolCallId) ?? Promise.resolve(false)
}
/**
* Moves a provisional chat's live service to its durable chat id.
*
@@ -364,6 +375,87 @@ export class TerminalRegistry {
return true
}
/**
* Stops every command the agent started in any chat's terminals; the user's own are untouched.
* That includes tmux runs no live terminal holds any more: a chat put away, or a previous
* process that quit or crashed while they ran.
*/
async stopAgentCommands(): Promise<void> {
await Promise.allSettled(
[...this.entries.values()].map((entry) => entry.service.stopAgentCommands())
)
await this.stopRecordedRuns()
}
/**
* At launch, for the same user: leaves a previous process's tmux run going only when the model
* has, or will get, the result that handed it back as still going: Sim acknowledged it
* (`delivered`), or the executor's journal holds it for recovery to send (`pendingResults`).
* Every other run is stopped, as is one a stop for everything could not confirm (`stop`).
*/
stopUncollectableRuns(pendingResults: ReadonlySet<string>): Promise<void> {
return this.stopRecordedRuns({
excludeLive: true,
keep: (run) =>
run.state === 'delivered' || (run.state === 'started' && pendingResults.has(run.callId)),
})
}
/**
* Notes that a call's result reached the model, so a tmux run it handed back as still going may
* be left to the model across a restart.
*/
markRunDelivered(callId: string): void {
const runId = this.runLedger?.runOf(callId)
if (runId) this.runLedger?.advance(runId, 'delivered')
}
/**
* Stops what a call's `run` handed back as still going once Sim is done with the call without
* its result reaching the model: a plain shell's command, or a tmux run, this process's or a
* previous one's. A run recorded as handed back is the model's, and is left going.
*/
async stopUndeliveredRun(callId: string): Promise<void> {
await Promise.allSettled(
[...this.entries.values()].map((entry) => entry.service.stopAgentCommand(callId))
)
await this.stopRecordedRuns({ callId, keep: (run) => run.state === 'delivered' })
}
/**
* Stops the recorded tmux runs, each only while its pane still carries its tag, and drops the
* records with nothing left to stop. `excludeLive` skips the runs this process has started;
* `callId` limits it to the run that call started; `keep` names runs to leave going, such as a
* previous process's runs whose results the model already has and may come back to.
*/
async stopRecordedRuns(
options: {
excludeLive?: boolean
callId?: string
/** Runs to leave going; their records are only dropped once their panes are gone. */
keep?: (run: RunRecord) => boolean
} = {}
): Promise<void> {
const ledger = this.runLedger
if (!ledger) return
const runs = ledger
.list({ excludeLive: options.excludeLive })
.filter((run) => options.callId === undefined || run.callId === options.callId)
await Promise.allSettled(
runs.map(async (run) => {
let keeping = options.keep?.(run) ?? false
let state = keeping ? await recordedRunState(run, process.env) : 'ours'
// A kept run whose command already ended leaves only its dead pane (`remain-on-exit`).
if (state === 'finished') keeping = false
if (!keeping) state = await stopRecordedRun(run, process.env, RECORDED_RUN_GRACE_MS)
if (state === 'gone') ledger.forget(run.runId)
// A run this sweep meant to stop but could not confirm stays meant to stop, so no later
// sweep keeps it.
else if (!keeping) ledger.advance(run.runId, 'stop')
})
)
}
/** Tears down every shell owned by every chat scope. */
dispose(): void {
const entries = [...this.entries.values()]
@@ -393,6 +485,7 @@ export class TerminalRegistry {
service: this.serviceFactory(scope, {
loadCwd: () => this.entries.get(scope)?.persisted?.tabs[0]?.cwd,
canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS,
runLedger: this.runLedger,
}),
persisted,
restoreApplied: false,
@@ -445,6 +538,7 @@ export class TerminalRegistry {
replacement = this.serviceFactory(entry.scope, {
loadCwd: () => entry.persisted?.tabs[0]?.cwd,
canSpawn: () => this.liveTerminalCount() < MAX_TERMINALS_PER_PROCESS,
runLedger: this.runLedger,
})
} catch {
this.entries.delete(entry.scope)
@@ -0,0 +1,165 @@
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createRunLedger } from '@/main/terminal/run-ledger'
const dirs: string[] = []
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), 'sim-run-ledger-'))
dirs.push(dir)
return join(dir, 'terminal-runs')
}
const RUN = {
runId: 'run-1',
pane: '%3',
socket: '/tmp/tmux-501/default',
callId: 'call-1',
state: 'started' as const,
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
describe('the tmux run ledger', () => {
it('keeps a run recorded until it is forgotten, for the next process too', () => {
const dir = scratch()
createRunLedger(dir).record(RUN)
const nextProcess = createRunLedger(dir)
expect(nextProcess.list()).toEqual([RUN])
nextProcess.forget(RUN.runId)
expect(createRunLedger(dir).list()).toEqual([])
})
it("leaves out this process's own runs when asked", () => {
const dir = scratch()
createRunLedger(dir).record(RUN)
const ledger = createRunLedger(dir)
ledger.record({ ...RUN, runId: 'run-2', pane: '%4' })
expect(ledger.list({ excludeLive: true })).toEqual([RUN])
expect(ledger.list()).toHaveLength(2)
})
it('drops files nothing could act on safely', () => {
const dir = scratch()
const ledger = createRunLedger(dir)
ledger.record(RUN)
writeFileSync(join(dir, 'garbled.json'), '{not json')
writeFileSync(join(dir, 'run-9.json'), JSON.stringify({ ...RUN, socket: 'relative.sock' }))
// A record under another run's name could stop the wrong run.
writeFileSync(join(dir, 'run-8.json'), JSON.stringify({ ...RUN, runId: 'run-7' }))
// A state no version wrote: nothing could tell what to do with the run.
writeFileSync(
join(dir, 'run-5.json'),
JSON.stringify({ ...RUN, runId: 'run-5', state: 'done' })
)
// Only a pane id names one pane; a target like this one names whatever pane is active there.
writeFileSync(
join(dir, 'run-6.json'),
JSON.stringify({ ...RUN, runId: 'run-6', pane: 'work:0.0' })
)
expect(ledger.list()).toEqual([RUN])
expect(readdirSync(dir)).toEqual(['run-1.json'])
})
it('cleans up after a write that never finished, keeping the saved record', () => {
const dir = scratch()
const ledger = createRunLedger(dir)
ledger.record(RUN)
writeFileSync(join(dir, 'run-2.json.4242.1.tmp'), '{"runId":"run-2","pa')
expect(ledger.list()).toEqual([RUN])
expect(readdirSync(dir)).toEqual(['run-1.json'])
})
it('keeps sweeping, and lets a call finish, when an entry cannot be removed', () => {
const dir = scratch()
const ledger = createRunLedger(dir)
ledger.record(RUN)
// Not a file, so removing it fails.
mkdirSync(join(dir, 'run-3.json'))
mkdirSync(join(dir, 'run-4.json'))
expect(ledger.list()).toEqual([RUN])
expect(() => ledger.forget('run-4')).not.toThrow()
})
it('reports a record it could not save, so the run is not started', () => {
const dir = scratch()
// A file where the directory should be: nothing can be saved under it.
writeFileSync(join(dir, '..', 'blocked'), '')
const ledger = createRunLedger(join(dir, '..', 'blocked'))
expect(ledger.record(RUN)).toBe(false)
})
it('moves a run forward only, for the next process too', () => {
const dir = scratch()
const ledger = createRunLedger(dir)
ledger.record(RUN)
ledger.advance(RUN.runId, 'delivered')
expect(createRunLedger(dir).list()).toEqual([{ ...RUN, state: 'delivered' }])
ledger.advance(RUN.runId, 'stop')
// A late acknowledgement never undoes a stop.
ledger.advance(RUN.runId, 'delivered')
expect(createRunLedger(dir).list()).toEqual([{ ...RUN, state: 'stop' }])
})
it('reads a record saved before runs had a state as the state it meant', () => {
const dir = scratch()
mkdirSync(dir, { recursive: true })
const { state: _state, ...saved } = RUN
const legacy = {
'run-started': { delivered: false },
'run-delivered': { delivered: true },
'run-stop': { delivered: false, mustStop: true },
// A stop wins over an acknowledgement.
'run-stop-delivered': { delivered: true, mustStop: true },
}
for (const [runId, fields] of Object.entries(legacy)) {
writeFileSync(join(dir, `${runId}.json`), JSON.stringify({ ...saved, runId, ...fields }))
}
const states = Object.fromEntries(
createRunLedger(dir)
.list()
.map((record) => [record.runId, record.state])
)
expect(states).toEqual({
'run-started': 'started',
'run-delivered': 'delivered',
'run-stop': 'stop',
'run-stop-delivered': 'stop',
})
})
it('finds the run a call started, a previous process recorded it or this one', () => {
const dir = scratch()
// Recovery may hand the model the result of a previous process's call.
createRunLedger(dir).record(RUN)
const ledger = createRunLedger(dir)
ledger.record({ ...RUN, runId: 'run-2', callId: 'call-2' })
expect(ledger.runOf(RUN.callId)).toBe(RUN.runId)
expect(ledger.runOf('call-2')).toBe('run-2')
ledger.forget(RUN.runId)
expect(ledger.runOf(RUN.callId)).toBeUndefined()
})
it('records nothing for a run tag that is not a plain id', () => {
const dir = scratch()
const ledger = createRunLedger(dir)
ledger.record({ ...RUN, runId: '../escape' })
expect(ledger.list()).toEqual([])
})
})
@@ -0,0 +1,192 @@
/**
* A durable record of the agent's tagged tmux runs, so a later process can still stop them.
*
* A tmux run outlives the app: quitting, crashing or signing out mid-teardown leaves its command
* going in the user's tmux server, and the next launch would otherwise know nothing about it.
* Each record names the run's tag, its pane and its tmux server's socket, and nothing else: no
* command line and no output, since it lives outside the account's encrypted data. A record is
* saved before the run's command may start, and removed once the run has ended, its pane is gone,
* or Sim has stopped it.
*/
import { readdirSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
import type { RecordedRun } from '@/main/terminal/tmux'
const logger = createLogger('DesktopTerminalRunLedger')
/**
* Where a recorded run stands, moving only forward:
* - `started`: its call has not handed its result to the model;
* - `delivered`: the model has the result that handed the run back as still going (with its
* pane), so a restart must leave the run be;
* - `stop`: a stop for everything (sign-out, Terminal off) could not confirm the run ended, so
* it is stopped whatever comes after, a late `delivered` included.
*/
export type RunState = 'started' | 'delivered' | 'stop'
const RUN_STATES: readonly RunState[] = ['started', 'delivered', 'stop']
/** A recorded run, with the call it belongs to and where it stands. */
export interface RunRecord extends RecordedRun {
/** The tool call that started the run, to match it against the executor's journal. */
callId: string
state: RunState
}
export interface RunLedger {
/** Saves a run's record; false when it could not be saved, so the run must not start. */
record(run: RunRecord): boolean
/** Moves a run forward to `state`; a run already there or past it stays as it is. */
advance(runId: string, state: RunState): void
/** The recorded run a tool call started, if the ledger still holds one. */
runOf(callId: string): string | undefined
forget(runId: string): void
/** Every recorded run; `excludeLive` leaves out runs this process recorded. */
list(options?: { excludeLive?: boolean }): RunRecord[]
}
/** Run tags are generated ids; anything else in the directory is not a record. */
const RUN_ID = /^[A-Za-z0-9_-]{1,128}$/
/** A record as saved: the current shape, or the earlier one with `delivered` and `mustStop`. */
type SavedRecord = Partial<RunRecord> & { delivered?: unknown; mustStop?: unknown }
/**
* The record's state. A record from before `state` existed (a released build wrote them) is read
* as the state it meant: `mustStop` as `stop`, which wins, then `delivered` as `delivered`.
*/
function stateOf(saved: SavedRecord): RunState | null {
if (RUN_STATES.includes(saved.state as RunState)) return saved.state as RunState
if (typeof saved.delivered !== 'boolean') return null
if (saved.mustStop === true) return 'stop'
return saved.delivered ? 'delivered' : 'started'
}
function parseRecord(text: string): RunRecord | null {
try {
const parsed = JSON.parse(text) as SavedRecord
const state = stateOf(parsed)
if (
typeof parsed.runId === 'string' &&
RUN_ID.test(parsed.runId) &&
typeof parsed.pane === 'string' &&
/^%\d+$/.test(parsed.pane) &&
typeof parsed.callId === 'string' &&
state &&
typeof parsed.socket === 'string' &&
parsed.socket.startsWith('/')
) {
return {
runId: parsed.runId,
pane: parsed.pane,
socket: parsed.socket,
callId: parsed.callId,
state,
}
}
} catch {
// Unreadable: treated as no record below.
}
return null
}
/** Removes a file the ledger no longer needs; a failure is logged, never thrown at a caller. */
function remove(path: string): void {
try {
rmSync(path, { force: true })
} catch (error) {
logger.warn('Could not remove a tmux run record', { error: getErrorMessage(error) })
}
}
export function createRunLedger(dir: string): RunLedger {
/** Runs recorded by this process, still going as far as it knows. */
const live = new Set<string>()
/** Each recorded run by the call that started it: read from the directory once, then kept. */
let byCall: Map<string, string> | null = null
const pathFor = (runId: string) => join(dir, `${runId}.json`)
/** Every saved record; anything else in the directory is removed. */
const readAll = (): RunRecord[] => {
let names: string[]
try {
names = readdirSync(dir)
} catch {
return []
}
const runs: RunRecord[] = []
for (const name of names) {
// A write that never finished leaves only its temporary file behind.
if (name.endsWith('.tmp')) {
remove(join(dir, name))
continue
}
if (!name.endsWith('.json')) continue
let record: RunRecord | null = null
try {
record = parseRecord(readFileSync(join(dir, name), 'utf8'))
} catch {
record = null
}
if (!record || `${record.runId}.json` !== name) {
// Nothing could act on it safely; it only takes up space.
remove(join(dir, name))
continue
}
runs.push(record)
}
return runs
}
const index = (): Map<string, string> => {
byCall ??= new Map(readAll().map((run) => [run.callId, run.runId]))
return byCall
}
return {
record(run) {
if (!RUN_ID.test(run.runId)) return false
try {
writeJsonFileAtomicallySync(pathFor(run.runId), run)
live.add(run.runId)
index().set(run.callId, run.runId)
return true
} catch (error) {
logger.warn('Could not record a tmux run', { error: getErrorMessage(error) })
return false
}
},
advance(runId, state) {
if (!RUN_ID.test(runId)) return
let record: RunRecord | null = null
try {
record = parseRecord(readFileSync(pathFor(runId), 'utf8'))
} catch {
record = null
}
if (!record || RUN_STATES.indexOf(state) <= RUN_STATES.indexOf(record.state)) return
try {
writeJsonFileAtomicallySync(pathFor(runId), { ...record, state })
} catch (error) {
// Left behind, a restart stops the run: the conservative side.
logger.warn('Could not update a tmux run record', { error: getErrorMessage(error) })
}
},
runOf(callId) {
return index().get(callId)
},
forget(runId) {
live.delete(runId)
for (const [callId, indexed] of byCall ?? []) if (indexed === runId) byCall?.delete(callId)
if (RUN_ID.test(runId)) remove(pathFor(runId))
},
list(options = {}) {
const runs = readAll()
return options.excludeLive ? runs.filter((run) => !live.has(run.runId)) : runs
},
}
}
+618 -2
View File
@@ -1,11 +1,116 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { sleep } from '@sim/utils/helpers'
import { describe, expect, it, vi } from 'vitest'
import { TerminalService } from '@/main/terminal'
import { createRunLedger } from '@/main/terminal/run-ledger'
/**
* A tmux attachment the service sees only when a test turns it on. Runs get real status files;
* stopping a run ends its command the way Ctrl-C in its pane would. Which panes tmux still holds
* as the run's, and the identity checks behind that, are covered against a fake tmux binary in
* `tmux.test.ts`; here the service's bookkeeping is what is under test.
*/
const tmuxFake = vi.hoisted(() => ({
on: false,
/** Panes a run was stopped in, in order. */
stopped: [] as string[],
/** Panes no longer the run's (closed by the user, or reused after a tmux restart). */
gone: new Set<string>(),
/** Runs start untracked, as on a tmux too old to tag their panes. */
untracked: false,
/** Run panes tmux still shows, kept open after their command ends (`remain-on-exit`). */
open: new Set<string>(),
/** tmux stops answering: no pane can be confirmed, so none is closed. */
unanswered: false,
statusPaths: new Map<string, string>(),
}))
vi.mock('@/main/terminal/tmux', async () => {
const actual =
await vi.importActual<typeof import('@/main/terminal/tmux')>('@/main/terminal/tmux')
let nextPane = 1
return {
...actual,
isTmuxUnavailable: () => (tmuxFake.on ? false : actual.isTmuxUnavailable()),
resolveAttachment: async (pid: number, env: NodeJS.ProcessEnv) =>
tmuxFake.on
? { session: 'agent', clientTty: '/dev/ttys001' }
: actual.resolveAttachment(pid, env),
startRun: async (...args: Parameters<typeof actual.startRun>) => {
if (!tmuxFake.on) return actual.startRun(...args)
const options = args[4]
const pane = `%${nextPane++}`
const runId = tmuxFake.untracked ? null : `run-${pane.slice(1)}`
const socket = tmuxFake.untracked ? null : '/tmp/tmux-fake/default'
// Like the real one, a tagged run starts only once its record is saved.
if (
runId &&
socket &&
options?.beforeStart &&
!options.beforeStart({ runId, pane, socket })
) {
return { error: 'The command could not be recorded for a later stop, so it was not run.' }
}
const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-fake-'))
const statusPath = join(dir, 'status')
writeFileSync(join(dir, 'out'), 'partial output')
tmuxFake.statusPaths.set(pane, statusPath)
tmuxFake.open.add(pane)
return {
window: `@${pane.slice(1)}`,
pane,
runId,
socket,
outPath: join(dir, 'out'),
statusPath,
dispose: () => rmSync(dir, { recursive: true, force: true }),
}
},
runPaneState: async (...args: Parameters<typeof actual.runPaneState>) => {
if (!tmuxFake.on) return actual.runPaneState(...args)
if (tmuxFake.unanswered) return 'unknown'
if (tmuxFake.gone.has(args[0].pane)) return 'gone'
return args[0].runId === null ? 'unknown' : 'ours'
},
stopRun: async (...args: Parameters<typeof actual.stopRun>) => {
if (!tmuxFake.on) return actual.stopRun(...args)
const [handle] = args
if (tmuxFake.gone.has(handle.pane) || handle.runId === null) return
tmuxFake.stopped.push(handle.pane)
writeFileSync(handle.statusPath, '130')
},
closeRunPane: async (...args: Parameters<typeof actual.closeRunPane>) => {
if (!tmuxFake.on) return actual.closeRunPane(...args)
if (tmuxFake.unanswered) return
// An untracked run's pane is proven its own only from the run's files, read after tmux
// has answered, as the real check does.
await sleep(10)
if (args[0].runId === null && !existsSync(args[0].statusPath)) return
tmuxFake.open.delete(args[0].pane)
tmuxFake.gone.add(args[0].pane)
},
}
})
/** Stub sessions by terminal id, populated by the mock below. */
const { stubSessions } = vi.hoisted(() => ({
stubSessions: new Map<
string,
{ setBusy(busy: boolean): void; exit(): void; clearScrollback(): void }
{
setBusy(busy: boolean): void
exit(): void
clearScrollback(): void
/** Whether Ctrl-C ends the running command, as it does for most programs. */
setInterruptible(interruptible: boolean): void
/** Ends the running command the way its process exiting would. */
finishRun(exitCode: number): void
/** Returns the run as still going, its command left running, as a wait that ran out does. */
handBack(): void
kill: ReturnType<typeof vi.fn>
readonly runningToolCallId: string | null
}
>(),
}))
@@ -32,11 +137,50 @@ vi.mock('@/main/terminal/session', async () => {
terminalId: string
callbacks: { onExit(terminalId: string): void }
}) => {
const state = { cwd, disposed: false, busy: false }
const state = {
cwd,
disposed: false,
busy: false,
interruptible: true,
toolCallId: null as string | null,
resolveRun: null as ((result: Record<string, unknown>) => void) | null,
}
const finishRun = (exitCode: number) => {
const resolve = state.resolveRun
state.busy = false
state.toolCallId = null
state.resolveRun = null
resolve?.({ status: 'completed', exitCode, terminalId })
}
const stub = {
setBusy: (busy: boolean) => {
state.busy = busy
},
setInterruptible: (interruptible: boolean) => {
state.interruptible = interruptible
},
finishRun,
handBack: () => {
const resolve = state.resolveRun
state.resolveRun = null
resolve?.({ status: 'running', exitCode: null, terminalId })
},
runCommand: (_command: string, toolCallId: string) =>
new Promise((resolve) => {
state.busy = true
state.toolCallId = toolCallId
state.resolveRun = resolve
}),
get runningToolCallId() {
return state.toolCallId
},
get agentCommandToolCallId() {
return state.toolCallId
},
kill: vi.fn((signal: string) => {
if (signal === 'SIGINT' && state.interruptible) finishRun(130)
}),
waitForShellIntegration: async () => {},
/** Stands in for the user running `exit` or pressing Ctrl-D. */
exit: () => {
state.disposed = true
@@ -332,3 +476,475 @@ describe('a shell that ends by itself', () => {
expect(after.activeTerminalId).toBeNull()
})
})
describe('stopping a tool call', () => {
const COMMAND_GROUP = 4242
function cancellableService() {
/** The tty's foreground group as the OS would report it at each read. */
let foreground: number | null = COMMAND_GROUP
const processGroups = {
foreground: vi.fn(async (_shellPid: number) => foreground),
signal: vi.fn((_pgid: number, _signal: NodeJS.Signals) => {}),
}
const terminal = new TerminalService({ loadCwd: () => '/tmp', processGroups })
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
const session = stubSessions.get(activeTerminalId as string)
if (!session) throw new Error('No stub session')
return {
terminal,
session,
processGroups,
setForeground: (pgid: number | null) => {
foreground = pgid
},
}
}
/** Starts a run and waits until its command holds the terminal's foreground. */
async function startRun(
terminal: TerminalService,
session: { runningToolCallId: string | null },
toolCallId: string
) {
const running = terminal.executeTool(toolCallId, 'run', { command: 'sleep 60' })
await vi.waitFor(() => expect(session.runningToolCallId).toBe(toolCallId))
return { running }
}
it('interrupts the command its run started, and the run reports how it ended', async () => {
const { terminal, session, processGroups } = cancellableService()
const { running } = await startRun(terminal, session, 'call-run')
await expect(terminal.cancelTool('call-run')).resolves.toBe(true)
expect(session.kill).toHaveBeenCalledWith('SIGINT')
await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } })
expect(processGroups.signal).not.toHaveBeenCalled()
})
it("terminates the command's own process group when it ignores Ctrl-C", async () => {
const { terminal, session, processGroups } = cancellableService()
session.setInterruptible(false)
processGroups.signal.mockImplementation(() => session.finishRun(143))
const { running } = await startRun(terminal, session, 'call-stubborn')
await terminal.cancelTool('call-stubborn')
expect(processGroups.signal).toHaveBeenCalledWith(COMMAND_GROUP, 'SIGTERM')
await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 143 } })
})
it('never signals a different command that took the foreground meanwhile', async () => {
const { terminal, session, processGroups, setForeground } = cancellableService()
session.setInterruptible(false)
const { running } = await startRun(terminal, session, 'call-replaced')
session.kill.mockImplementation(() => setForeground(9999))
await terminal.cancelTool('call-replaced')
expect(processGroups.signal).not.toHaveBeenCalled()
session.finishRun(0)
await running
})
it('runs nothing when the Stop arrives before the command starts', async () => {
const { terminal, session } = cancellableService()
const running = terminal.executeTool('call-early', 'run', { command: 'rm -rf build' })
await terminal.cancelTool('call-early')
await expect(running).resolves.toMatchObject({ ok: false, code: 'CANCELLED' })
expect(session.runningToolCallId).toBeNull()
})
it('sends no signal or keys for a kill or input stopped before it starts', async () => {
const { terminal, session } = cancellableService()
session.setBusy(true)
const killing = terminal.executeTool('call-kill', 'kill', { signal: 'SIGTERM' })
const typing = terminal.executeTool('call-input', 'input', { text: 'yes\n' })
await Promise.all([terminal.cancelTool('call-kill'), terminal.cancelTool('call-input')])
await expect(killing).resolves.toMatchObject({ ok: false, code: 'CANCELLED' })
await expect(typing).resolves.toMatchObject({ ok: false, code: 'CANCELLED' })
expect(session.kill).not.toHaveBeenCalled()
})
it("stops the agent's running command at sign-out", async () => {
const { terminal, session } = cancellableService()
const { running } = await startRun(terminal, session, 'call-left-running')
await terminal.stopAgentCommands()
expect(session.kill).toHaveBeenCalledWith('SIGINT')
await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } })
})
it('stops the command a run handed back as still going, once asked for that call', async () => {
const { terminal, session } = cancellableService()
const { running } = await startRun(terminal, session, 'call-handed-back')
session.handBack()
await expect(running).resolves.toMatchObject({ ok: true, result: { status: 'running' } })
// The call is over, so a Stop for it in flight finds nothing.
await expect(terminal.cancelTool('call-handed-back')).resolves.toBe(false)
await terminal.stopAgentCommand('call-other')
expect(session.kill).not.toHaveBeenCalled()
await terminal.stopAgentCommand('call-handed-back')
expect(session.kill).toHaveBeenCalledWith('SIGINT')
expect(session.runningToolCallId).toBeNull()
})
it('leaves a command the user started alone at sign-out', async () => {
const { terminal, session, processGroups } = cancellableService()
session.setBusy(true)
await terminal.stopAgentCommands()
expect(session.kill).not.toHaveBeenCalled()
expect(processGroups.signal).not.toHaveBeenCalled()
})
it('leaves the terminal alone for a call it is not running', async () => {
const { terminal, session, processGroups } = cancellableService()
const { running } = await startRun(terminal, session, 'call-other')
await expect(terminal.cancelTool('call-unknown')).resolves.toBe(false)
expect(session.kill).not.toHaveBeenCalled()
expect(processGroups.signal).not.toHaveBeenCalled()
session.finishRun(0)
await running
})
})
describe('agent commands in tmux', () => {
it('stops a tmux run at sign-out while its call still waits on it', async () => {
tmuxFake.on = true
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
terminal.start({ cols: 80, rows: 24 })
const running = terminal.executeTool('call-tmux', 'run', {
command: 'sleep 600',
waitSeconds: 60,
})
await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(1))
await terminal.stopAgentCommands()
expect(tmuxFake.stopped).toEqual(['%1'])
await expect(running).resolves.toMatchObject({
ok: true,
result: { status: 'completed', exitCode: 130 },
})
} finally {
tmuxFake.on = false
}
})
it('reports an untracked run it could not stop as still running, and keeps tracking it', async () => {
tmuxFake.on = true
tmuxFake.untracked = true
tmuxFake.statusPaths.clear()
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
terminal.start({ cols: 80, rows: 24 })
const running = terminal.executeTool('call-untracked', 'run', {
command: 'sleep 600',
waitSeconds: 60,
})
await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(1))
const [, statusPath = ''] = [...tmuxFake.statusPaths][0] ?? []
await terminal.cancelTool('call-untracked')
await expect(running).resolves.toMatchObject({ ok: true, result: { status: 'running' } })
expect(existsSync(join(statusPath, '..'))).toBe(true)
// Once it does finish, the next run's bookkeeping reaps it.
writeFileSync(statusPath, '0')
await terminal.executeTool('call-next', 'run', { command: 'ls', waitSeconds: 1 })
expect(existsSync(join(statusPath, '..'))).toBe(false)
} finally {
tmuxFake.on = false
tmuxFake.untracked = false
}
})
it('keeps a record of a tagged run exactly as long as the run goes on', async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
const ledgerDir = join(scratch, 'terminal-runs')
const ledger = createRunLedger(ledgerDir)
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp', runLedger: ledger })
terminal.start({ cols: 80, rows: 24 })
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
const [[pane = '', statusPath = ''] = []] = [...tmuxFake.statusPaths]
// Still going after its call returned: a later process must be able to find it.
expect(createRunLedger(ledgerDir).list()).toEqual([
{
runId: `run-${pane.slice(1)}`,
pane,
socket: '/tmp/tmux-fake/default',
callId: 'call-long',
// Handed back, but the model has it only once Sim acknowledges the result.
state: 'started',
},
])
writeFileSync(statusPath, '0')
await terminal.executeTool('call-next', 'run', { command: 'ls', waitSeconds: 1 })
// The first finished and is forgotten; the one still going is recorded in its place.
expect(
createRunLedger(ledgerDir)
.list()
.map((run) => run.pane)
).toEqual([[...tmuxFake.statusPaths.keys()][1]])
} finally {
tmuxFake.on = false
rmSync(scratch, { recursive: true, force: true })
}
})
it('closes and then forgets a run that finished before its terminal closed', async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
tmuxFake.open.clear()
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
const ledgerDir = join(scratch, 'terminal-runs')
try {
const terminal = new TerminalService({
loadCwd: () => '/tmp',
runLedger: createRunLedger(ledgerDir),
})
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
const [[, statusPath = ''] = []] = [...tmuxFake.statusPaths]
writeFileSync(statusPath, '0')
const [pane = ''] = [...tmuxFake.statusPaths.keys()]
// Its dead pane is still open, as with `remain-on-exit`.
expect(tmuxFake.open.has(pane)).toBe(true)
terminal.closeTerminal(activeTerminalId as string)
await vi.waitFor(() => expect(createRunLedger(ledgerDir).list()).toEqual([]))
expect(tmuxFake.open.has(pane)).toBe(false)
} finally {
tmuxFake.on = false
rmSync(scratch, { recursive: true, force: true })
}
})
it("forgets a run that finishes within its call, and a closed tab's run once its pane is gone", async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
const ledgerDir = join(scratch, 'terminal-runs')
try {
const terminal = new TerminalService({
loadCwd: () => '/tmp',
runLedger: createRunLedger(ledgerDir),
})
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
// Finishes while its call still waits on it.
const quick = terminal.executeTool('call-quick', 'run', { command: 'ls', waitSeconds: 30 })
await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(1))
writeFileSync([...tmuxFake.statusPaths.values()][0] ?? '', '0')
await quick
expect(createRunLedger(ledgerDir).list()).toEqual([])
// Still going when its tab closes; its pane goes later, and the next run's bookkeeping sees.
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
const [, longPane = ''] = [...tmuxFake.statusPaths.keys()]
terminal.closeTerminal(activeTerminalId as string)
expect(
createRunLedger(ledgerDir)
.list()
.map((run) => run.pane)
).toEqual([longPane])
tmuxFake.gone.add(longPane)
await terminal.executeTool('call-new', 'new', {})
await terminal.executeTool('call-next', 'run', { command: 'pwd', waitSeconds: 1 })
expect(
createRunLedger(ledgerDir)
.list()
.map((run) => run.pane)
).not.toContain(longPane)
} finally {
tmuxFake.on = false
tmuxFake.gone.clear()
rmSync(scratch, { recursive: true, force: true })
}
})
it("closes a finished untracked run's pane when its terminal closes", async () => {
tmuxFake.on = true
tmuxFake.untracked = true
tmuxFake.statusPaths.clear()
tmuxFake.open.clear()
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
await terminal.executeTool('call-old-tmux', 'run', { command: 'make build', waitSeconds: 1 })
const [[pane = '', statusPath = ''] = []] = [...tmuxFake.statusPaths]
writeFileSync(statusPath, '0')
terminal.closeTerminal(activeTerminalId as string)
await vi.waitFor(() => expect(tmuxFake.open.has(pane)).toBe(false))
} finally {
tmuxFake.on = false
tmuxFake.untracked = false
}
})
it('keeps the record of a finished run whose pane tmux could not confirm closing', async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
const ledgerDir = join(scratch, 'terminal-runs')
try {
const terminal = new TerminalService({
loadCwd: () => '/tmp',
runLedger: createRunLedger(ledgerDir),
})
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
writeFileSync([...tmuxFake.statusPaths.values()][0] ?? '', '0')
tmuxFake.unanswered = true
terminal.closeTerminal(activeTerminalId as string)
await sleep(200)
// The next sweep will close its pane.
expect(createRunLedger(ledgerDir).list()).toHaveLength(1)
} finally {
tmuxFake.on = false
tmuxFake.unanswered = false
rmSync(scratch, { recursive: true, force: true })
}
})
it('never starts a tagged run it could not record', async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
const scratch = mkdtempSync(join(tmpdir(), 'sim-ledger-'))
// A file where the ledger's directory should be: no record can be saved.
writeFileSync(join(scratch, 'terminal-runs'), '')
try {
const terminal = new TerminalService({
loadCwd: () => '/tmp',
runLedger: createRunLedger(join(scratch, 'terminal-runs')),
})
terminal.start({ cols: 80, rows: 24 })
await expect(
terminal.executeTool('call-unrecorded', 'run', { command: 'make build', waitSeconds: 1 })
).resolves.toMatchObject({ ok: false, code: 'SPAWN_FAILED' })
expect(tmuxFake.statusPaths.size).toBe(0)
} finally {
tmuxFake.on = false
rmSync(scratch, { recursive: true, force: true })
}
})
it("closes a run's pane when a later run reaps it after it finished", async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
tmuxFake.open.clear()
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
terminal.start({ cols: 80, rows: 24 })
await terminal.executeTool('call-long', 'run', { command: 'make build', waitSeconds: 1 })
const [[pane = '', statusPath = ''] = []] = [...tmuxFake.statusPaths]
// It finishes after its call returned, and its dead pane stays open.
writeFileSync(statusPath, '0')
expect(tmuxFake.open.has(pane)).toBe(true)
await terminal.executeTool('call-next', 'run', { command: 'ls', waitSeconds: 1 })
expect(tmuxFake.open.has(pane)).toBe(false)
} finally {
tmuxFake.on = false
}
})
it("keeps a run's output readable for its call when the terminal goes away mid-wait", async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
terminal.start({ cols: 80, rows: 24 })
const running = terminal.executeTool('call-tmux-closed', 'run', {
command: 'make build',
waitSeconds: 1,
})
await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(1))
terminal.dispose()
await expect(running).resolves.toMatchObject({
ok: true,
result: { status: 'running', output: 'partial output' },
})
} finally {
tmuxFake.on = false
}
})
it('releases a run whose pane is gone instead of ever stopping it', async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
tmuxFake.stopped.length = 0
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
terminal.start({ cols: 80, rows: 24 })
const first = await terminal.executeTool('call-first', 'run', {
command: 'sleep 600',
waitSeconds: 1,
})
expect(first).toMatchObject({ ok: true, result: { status: 'running' } })
const [pane, statusPath] = [...tmuxFake.statusPaths][0] ?? []
tmuxFake.gone.add(pane ?? '')
await terminal.stopAgentCommands()
expect(tmuxFake.stopped).toEqual([])
// The next run's bookkeeping drops it for good.
void terminal.executeTool('call-next', 'run', { command: 'ls', waitSeconds: 1 })
await vi.waitFor(() => expect(existsSync(statusPath ?? '')).toBe(false))
await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(2))
expect(existsSync(join(statusPath ?? '', '..'))).toBe(false)
} finally {
tmuxFake.on = false
tmuxFake.gone.clear()
}
})
it('still stops at sign-out a run whose Sim terminal was closed', async () => {
tmuxFake.on = true
tmuxFake.statusPaths.clear()
tmuxFake.stopped.length = 0
try {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
const first = await terminal.executeTool('call-orphan', 'run', {
command: 'sleep 600',
waitSeconds: 1,
})
expect(first).toMatchObject({ ok: true, result: { status: 'running' } })
terminal.closeTerminal(activeTerminalId as string)
await terminal.stopAgentCommands()
expect(tmuxFake.stopped).toEqual([[...tmuxFake.statusPaths.keys()][0]])
} finally {
tmuxFake.on = false
}
})
})
@@ -137,6 +137,9 @@ describe('TerminalSession command lifecycle', () => {
const resultPromise = session.runCommand('vim', 'tool-call-1', 10_000)
ptyStub.dataHandler?.('\u001b]633;C;test-nonce\u0007\u001b[?1049h')
expect(await resultPromise).toMatchObject({ status: 'interactive' })
// Detached from the tool call, the command is still the agent's until it exits.
expect(session.runningToolCallId).toBeNull()
expect(session.agentCommandToolCallId).toBe('tool-call-1')
expect(commandEvents.at(-1)).toMatchObject({
terminalId: 'terminal-1',
@@ -153,6 +156,7 @@ describe('TerminalSession command lifecycle', () => {
command: 'vim',
exitCode: 0,
})
expect(session.agentCommandToolCallId).toBeNull()
expect(commandEvents.at(-1)?.toolCallId).toBeUndefined()
} finally {
session.dispose()
@@ -160,4 +164,60 @@ describe('TerminalSession command lifecycle', () => {
else process.env.SHELL = originalShell
}
})
it('stops pressing a batch of keys once the call is stopped', async () => {
vi.useFakeTimers()
const session = TerminalSession.create({
terminalId: 'terminal-keys',
cwd: '/tmp',
cols: 80,
rows: 24,
callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} },
})
try {
const writesBefore = ptyStub.writes.length
const stop = new AbortController()
const pressing = session.pressKeys(['down', 'down', 'down', 'enter'], stop.signal)
expect(ptyStub.writes.length - writesBefore).toBe(1)
stop.abort()
await vi.runAllTimersAsync()
await pressing
expect(ptyStub.writes.length - writesBefore).toBe(1)
} finally {
session.dispose()
}
})
it('ends the wait between keystrokes on Stop while the program keeps redrawing', async () => {
vi.useFakeTimers()
const session = TerminalSession.create({
terminalId: 'terminal-redraw',
cwd: '/tmp',
cols: 80,
rows: 24,
callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} },
})
const redraw = setInterval(() => ptyStub.dataHandler?.('frame'), 20)
try {
const writesBefore = ptyStub.writes.length
const stop = new AbortController()
let settled = false
const typing = session.type('first\nsecond', stop.signal).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(300)
expect(settled).toBe(false)
stop.abort()
await vi.advanceTimersByTimeAsync(1)
expect(settled).toBe(true)
await typing
expect(ptyStub.writes.slice(writesBefore)).toEqual(['first'])
} finally {
clearInterval(redraw)
session.dispose()
}
})
})
+120 -36
View File
@@ -23,7 +23,7 @@ import {
type TerminalRunResult,
type TerminalTabState,
} from '@sim/terminal-protocol'
import { sleep } from '@sim/utils/helpers'
import { interruptibleSleep } from '@sim/utils/helpers'
import {
Terminal as HeadlessTerminal,
type IBuffer,
@@ -261,6 +261,21 @@ interface PendingCommand {
resolve(result: TerminalRunResult): void
}
/** How long a new shell gets to reach its first prompt. */
export interface ShellStartupBounds {
/** From spawn, to begin the startup files we generated; a shell that has not by then never will. */
unstartedMs: number
/** From the moment they began, for those startup files to reach a prompt. */
startingMs: number
}
/**
* How a wait for the shell's first prompt ended: `ready` with integration live; `not-instrumented`
* when our startup files never ran; `starting` when they are still running at the bound, often
* because something in them is waiting for input; `exited`; or `stopped` by the caller.
*/
export type ShellReadiness = 'ready' | 'not-instrumented' | 'starting' | 'exited' | 'stopped'
export interface TerminalSessionCallbacks {
onData(terminalId: string, data: string): void
onState(): void
@@ -313,13 +328,23 @@ export class TerminalSession {
private columns: number
private lines: number
private shellIntegration = false
/** When the shell began the startup files we generated, or null while it has not. */
private startupBegunAt: number | null = null
private readonly spawnedAt = Date.now()
private altScreen = false
private foregroundCommand: string | null = null
private foregroundToolCallId: string | null = null
/**
* The agent tool call whose command is still running, until that command really ends. Unlike
* {@link runningToolCallId} it survives an interactive command detaching from the tool call:
* the command is still the agent's, so Stop and sign-out can still end it.
*/
private agentToolCallId: string | null = null
private pendingCommand: PendingCommand | null = null
/** Command line reported by the shell but not yet bracketed by output-start. */
private announcedCommand: string | null = null
private integrationWaiters: Array<() => void> = []
/** Pending shell-readiness waits, rechecked whenever the shell's startup moves on. */
private readonly readinessWaiters = new Set<() => void>()
private constructor(
options: TerminalSessionOptions,
@@ -327,7 +352,8 @@ export class TerminalSession {
integrationDir: string,
nonce: string,
shellName: string,
shellEnv: NodeJS.ProcessEnv
shellEnv: NodeJS.ProcessEnv,
private readonly instrumented: boolean
) {
this.callbacks = options.callbacks
this.terminalId = options.terminalId
@@ -372,7 +398,15 @@ export class TerminalSession {
})
logger.info('Started terminal session', { shell: shellPath, instrumented: shell !== null })
return new TerminalSession(options, pty, integrationDir, nonce, shell ?? shellPath, shellEnv)
return new TerminalSession(
options,
pty,
integrationDir,
nonce,
shell ?? shellPath,
shellEnv,
shell !== null
)
}
/**
@@ -434,6 +468,16 @@ export class TerminalSession {
return this.foregroundCommand
}
/** The agent tool call whose command holds the foreground, if one does. */
get runningToolCallId(): string | null {
return this.foregroundToolCallId
}
/** The agent tool call whose command is still running in this shell, if one is. */
get agentCommandToolCallId(): string | null {
return this.agentToolCallId
}
/**
* Tab-strip view of this terminal. The label prefers the running command,
* which is what the user is actually waiting on, and falls back to the
@@ -470,27 +514,57 @@ export class TerminalSession {
}
/**
* Resolves once the shell has emitted its first integration marker, or when
* `timeoutMs` elapses. A shell takes a few hundred milliseconds to run its
* startup files, so a command issued immediately after spawn would otherwise
* be refused for having no integration when it is merely early.
* Resolves once the shell reaches its first prompt with integration live, or once it is clear
* it will not: it never began our startup files within `bounds.unstartedMs` of spawning, it is
* still running them `bounds.startingMs` after they began, it exited, or `signal` stopped the
* wait. A shell's startup files take a while, and longer on a busy machine, so a command issued
* soon after spawn would otherwise be refused when the shell is merely early.
*/
waitForShellIntegration(timeoutMs: number): Promise<boolean> {
if (this.shellIntegration) return Promise.resolve(true)
if (this.disposed) return Promise.resolve(false)
waitForShellIntegration(
bounds: ShellStartupBounds,
signal?: AbortSignal
): Promise<ShellReadiness> {
return new Promise((resolve) => {
const notify = () => {
clearTimeout(timer)
resolve(this.shellIntegration)
let timer: NodeJS.Timeout | null = null
const check = () => {
if (timer) clearTimeout(timer)
timer = null
const readiness = signal?.aborted ? 'stopped' : this.readiness(bounds)
if (readiness === null) {
const deadline =
this.startupBegunAt === null
? this.spawnedAt + bounds.unstartedMs
: this.startupBegunAt + bounds.startingMs
timer = setTimeout(check, Math.max(0, deadline - Date.now()))
return
}
this.readinessWaiters.delete(check)
signal?.removeEventListener('abort', check)
resolve(readiness)
}
const timer = setTimeout(() => {
this.integrationWaiters = this.integrationWaiters.filter((entry) => entry !== notify)
resolve(this.shellIntegration)
}, timeoutMs)
this.integrationWaiters.push(notify)
this.readinessWaiters.add(check)
signal?.addEventListener('abort', check)
check()
})
}
/** Where the shell's startup stands against `bounds`, or null while it may still get there. */
private readiness(bounds: ShellStartupBounds): ShellReadiness | null {
if (this.shellIntegration) return 'ready'
if (this.disposed) return 'exited'
const now = Date.now()
if (this.startupBegunAt === null) {
return !this.instrumented || now - this.spawnedAt >= bounds.unstartedMs
? 'not-instrumented'
: null
}
return now - this.startupBegunAt >= bounds.startingMs ? 'starting' : null
}
private notifyReadinessWaiters(): void {
for (const check of [...this.readinessWaiters]) check()
}
write(data: string): void {
if (this.disposed) return
this.pty.write(data)
@@ -508,10 +582,11 @@ export class TerminalSession {
* The pause also lets a menu redraw between presses, which is what makes a
* batch land on the row a person pressing the same keys would reach.
*/
async pressKeys(keys: TerminalControlKey[]): Promise<void> {
async pressKeys(keys: TerminalControlKey[], signal?: AbortSignal): Promise<void> {
for (let index = 0; index < keys.length; index += 1) {
if (this.disposed) return
if (index > 0) await this.settleBetweenKeystrokes()
if (this.disposed || signal?.aborted) return
if (index > 0) await this.settleBetweenKeystrokes(signal)
if (signal?.aborted) return
this.sendKey(keys[index])
}
}
@@ -522,24 +597,28 @@ export class TerminalSession {
* gets a chance to redraw between them. See {@link toInputChunks} for why
* sending it all at once leaves the text unsubmitted.
*/
async type(text: string): Promise<void> {
async type(text: string, signal?: AbortSignal): Promise<void> {
const chunks = toInputChunks(text)
for (let index = 0; index < chunks.length; index += 1) {
if (this.disposed) return
if (index > 0) await this.settleBetweenKeystrokes()
if (this.disposed || signal?.aborted) return
if (index > 0) await this.settleBetweenKeystrokes(signal)
if (signal?.aborted) return
this.write(chunks[index])
}
}
/** Holds a gap, then lets any resulting redraw finish before the next write. */
private async settleBetweenKeystrokes(): Promise<void> {
await sleep(KEYSTROKE_GAP_MS)
/**
* Holds a gap, then lets any resulting redraw finish before the next write.
* Ends early on Stop, so a program that redraws constantly cannot hold it.
*/
private async settleBetweenKeystrokes(signal?: AbortSignal): Promise<void> {
await interruptibleSleep(KEYSTROKE_GAP_MS, signal)
const deadline = Date.now() + KEYSTROKE_SETTLE_MAX_MS
while (!this.disposed) {
while (!this.disposed && !signal?.aborted) {
const quietFor = Date.now() - this.lastOutputAt
const remaining = Math.min(KEYSTROKE_GAP_MS - quietFor, deadline - Date.now())
if (remaining <= 0) return
await sleep(remaining)
await interruptibleSleep(remaining, signal)
}
}
@@ -597,6 +676,7 @@ export class TerminalSession {
}
this.foregroundCommand = command
this.foregroundToolCallId = toolCallId
this.agentToolCallId = toolCallId
this.emitState()
this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command, toolCallId })
@@ -717,6 +797,7 @@ export class TerminalSession {
if (this.flushTimer) clearTimeout(this.flushTimer)
this.flushTimer = null
this.finishCommand(null)
this.notifyReadinessWaiters()
try {
this.pty.kill()
} catch {
@@ -772,13 +853,17 @@ export class TerminalSession {
marker: ReturnType<ShellIntegrationParser['parse']>['markers'][number]
): void {
switch (marker.kind) {
case 'startup':
if (this.startupBegunAt === null) {
this.startupBegunAt = Date.now()
this.notifyReadinessWaiters()
}
break
case 'prompt-start':
if (!this.shellIntegration) {
this.shellIntegration = true
this.emitState()
const waiters = this.integrationWaiters
this.integrationWaiters = []
for (const notify of waiters) notify()
this.notifyReadinessWaiters()
}
break
case 'command-line':
@@ -976,6 +1061,7 @@ export class TerminalSession {
this.foregroundCommand = null
this.foregroundToolCallId = null
this.agentToolCallId = null
this.announcedCommand = null
this.altScreen = false
this.emitState()
@@ -1007,9 +1093,7 @@ export class TerminalSession {
private handleExit(): void {
if (this.disposed) return
this.disposed = true
const waiters = this.integrationWaiters
this.integrationWaiters = []
for (const notify of waiters) notify()
this.notifyReadinessWaiters()
if (this.flushTimer) clearTimeout(this.flushTimer)
this.flushTimer = null
this.flush()
@@ -1,5 +1,13 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { detectShell, ShellIntegrationParser } from '@/main/terminal/shell-integration'
import {
buildShellLaunch,
detectShell,
ShellIntegrationParser,
} from '@/main/terminal/shell-integration'
const NONCE = 'testnonce'
@@ -65,6 +73,14 @@ describe('ShellIntegrationParser', () => {
expect(markers).toEqual([{ kind: 'cwd', cwd: '/tmp/some dir' }])
})
it("recognises the startup marker sent before the user's startup files", () => {
const parser = new ShellIntegrationParser(NONCE)
const { text, markers } = parser.parse(`${osc(`SimStartup;${NONCE}`)}loading`)
expect(text).toBe('loading')
expect(markers).toEqual([{ kind: 'startup' }])
})
it('accepts ST as well as BEL as a terminator', () => {
const parser = new ShellIntegrationParser(NONCE)
const { text, markers } = parser.parse(`a\u001b]633;A;${NONCE}\u001b\\b`)
@@ -93,3 +109,79 @@ describe('detectShell', () => {
expect(detectShell('/bin/sh')).toBeNull()
})
})
/**
* The generated startup files run in a real shell, with the user's own files standing in as a
* temp home whose startup prints a line. The startup marker has to reach the terminal before that
* line, and only from the interactive shell the terminal runs.
*/
describe('startup marker', () => {
const STARTUP = `\u001b]633;SimStartup;${NONCE}\u0007`
function userHome(rcFile: string): string {
const home = mkdtempSync(join(tmpdir(), 'sim-shell-home-'))
writeFileSync(join(home, rcFile), 'echo user-startup\n')
return home
}
it.skipIf(!existsSync('/bin/zsh'))(
"zsh sends it before the user's files, interactively only",
() => {
const home = userHome('.zshrc')
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home }
const launch = buildShellLaunch('zsh', mkdtempSync(join(tmpdir(), 'sim-zsh-')), NONCE, env)
const run = (args: string[]) =>
spawnSync('/bin/zsh', args, { env: { ...env, ...launch.env }, encoding: 'utf8' }).stdout
const interactive = run([...launch.args, '-i', '-c', 'true'])
expect(interactive.indexOf(STARTUP)).toBeGreaterThanOrEqual(0)
expect(interactive.indexOf(STARTUP)).toBeLessThan(interactive.indexOf('user-startup'))
expect(run(['-c', 'echo script'])).toBe('script\n')
}
)
it.skipIf(!existsSync('/bin/zsh'))(
"zsh keeps integrating when the user's .zshenv moves ZDOTDIR, and runs all their files there",
() => {
const home = mkdtempSync(join(tmpdir(), 'sim-shell-home-'))
const userDir = join(home, '.config', 'zsh')
mkdirSync(userDir, { recursive: true })
writeFileSync(join(home, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n')
writeFileSync(join(userDir, '.zprofile'), 'USER_PROFILE_RAN=1\n')
writeFileSync(join(userDir, 'plugins.zsh'), 'USER_PLUGIN_RAN=1\n')
writeFileSync(join(userDir, '.zshrc'), 'USER_RC_RAN=1\nsource "$ZDOTDIR/plugins.zsh"\n')
writeFileSync(join(userDir, '.zlogin'), 'echo "login=$USER_RC_RAN"\n')
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home }
const launch = buildShellLaunch('zsh', mkdtempSync(join(tmpdir(), 'sim-zsh-')), NONCE, env)
const output = spawnSync(
'/bin/zsh',
[
...launch.args,
'-i',
'-c',
'echo "profile=$USER_PROFILE_RAN rc=$USER_RC_RAN plugin=$USER_PLUGIN_RAN zdotdir=$ZDOTDIR"; whence -w __sim_precmd',
],
{ env: { ...env, ...launch.env }, encoding: 'utf8' }
).stdout
expect(output).toContain(`profile=1 rc=1 plugin=1 zdotdir=${userDir}`)
expect(output).toContain('__sim_precmd: function')
expect(output).toContain('login=1')
}
)
it.skipIf(!existsSync('/bin/bash'))("bash sends it before the user's files", () => {
const home = userHome('.bashrc')
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home }
const launch = buildShellLaunch('bash', mkdtempSync(join(tmpdir(), 'sim-bash-')), NONCE, env)
const output = spawnSync('/bin/bash', launch.args, {
env: { ...env, ...launch.env },
input: 'exit\n',
encoding: 'utf8',
}).stdout
expect(output.indexOf(STARTUP)).toBeGreaterThanOrEqual(0)
expect(output.indexOf(STARTUP)).toBeLessThan(output.indexOf('user-startup'))
})
})
@@ -36,9 +36,13 @@ export function createNonce(): string {
/**
* Marker kinds we act on. `A` (prompt start) doubles as the "integration is
* live" signal; `C`/`D` bracket a command's output; `E` reports the exact
* command line; `P` tracks the working directory across `cd`.
* command line; `P` tracks the working directory across `cd`. `SimStartup` is
* ours, not VS Code's: the shell sends it before running the user's startup
* files, so a shell that is still starting can be told from one we never
* instrumented.
*/
export type ShellMarker =
| { kind: 'startup' }
| { kind: 'prompt-start' }
| { kind: 'command-line'; command: string }
| { kind: 'output-start' }
@@ -123,6 +127,8 @@ export class ShellIntegrationParser {
if (nonce !== this.nonce) return null
switch (kind) {
case 'SimStartup':
return { kind: 'startup' }
case 'A':
return { kind: 'prompt-start' }
case 'C':
@@ -160,12 +166,26 @@ function findTerminator(buffer: string, from: number): { index: number; length:
* prompt, aliases, and PATH win over ours.
*/
function writeZshFiles(dir: string, nonce: string, originalZdotdir: string): void {
const sourceOriginal = (file: string) =>
`[ -f "$SIM_ZDOTDIR_ORIG/${file}" ] && builtin source "$SIM_ZDOTDIR_ORIG/${file}"`
// The user's file runs with ZDOTDIR at their own directory, so it finds its siblings and
// plugins there. A file that moves ZDOTDIR (an XDG `.zshenv`) moves where the rest of theirs are
// read from; ZDOTDIR then points back here, so zsh reads our next file.
const sourceOriginal = (file: string) => `if [ -f "$SIM_ZDOTDIR_ORIG/${file}" ]; then
__sim_zdotdir="$ZDOTDIR"
ZDOTDIR="$SIM_ZDOTDIR_ORIG"
builtin source "$SIM_ZDOTDIR_ORIG/${file}"
SIM_ZDOTDIR_ORIG="\${ZDOTDIR:-$HOME}"
ZDOTDIR="$__sim_zdotdir"
builtin unset __sim_zdotdir
fi`
// `.zshenv` is the first file zsh reads, so the startup marker goes out before any of the user's
// files run. Only from an interactive shell: a script's output must not carry it.
writeFileSync(
join(dir, '.zshenv'),
`SIM_ZDOTDIR_ORIG="\${SIM_ZDOTDIR_ORIG:-${originalZdotdir}}"\n${sourceOriginal('.zshenv')}\n`
`[[ -o interactive ]] && builtin printf '\\e]633;SimStartup;%s\\a' '${nonce}'
SIM_ZDOTDIR_ORIG="\${SIM_ZDOTDIR_ORIG:-${originalZdotdir}}"
${sourceOriginal('.zshenv')}
`
)
writeFileSync(join(dir, '.zprofile'), `${sourceOriginal('.zprofile')}\n`)
writeFileSync(join(dir, '.zlogin'), `${sourceOriginal('.zlogin')}\n`)
@@ -225,7 +245,8 @@ function writeBashFile(dir: string, nonce: string): string {
const rcPath = join(dir, 'sim-bash-rc.sh')
writeFileSync(
rcPath,
`[ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc"
`builtin printf '\\e]633;SimStartup;%s\\a' '${nonce}'
[ -f "$HOME/.bashrc" ] && builtin source "$HOME/.bashrc"
__sim_nonce='${nonce}'
__sim_in_cmd=''
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TerminalService } from '@/main/terminal'
/**
* A new shell's first prompt, as the service waits for it before running the agent's command.
* node-pty is stubbed so each test plays the shell's side: the startup marker our generated files
* send before the user's run, whatever the user's files print, and the first prompt. The service
* and the session are real.
*/
const pty = vi.hoisted(() => ({
emit: null as ((data: string) => void) | null,
exit: null as (() => void) | null,
writes: [] as string[],
}))
vi.mock('@lydell/node-pty', () => ({
spawn: () => ({
pid: 4321,
onData: (handler: (data: string) => void) => {
pty.emit = handler
},
onExit: (handler: () => void) => {
pty.exit = handler
},
write: (data: string) => pty.writes.push(data),
resize: vi.fn(),
kill: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
}),
}))
vi.mock('@/main/terminal/shell-integration', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/main/terminal/shell-integration')>()
return { ...actual, createNonce: () => 'nonce' }
})
vi.mock('@/main/terminal/tmux', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/main/terminal/tmux')>()
return { ...actual, isTmuxUnavailable: () => true }
})
const marker = (body: string) => `\u001b]633;${body};nonce\u0007`
const STARTUP = marker('SimStartup')
const PROMPT = marker('A')
function shell(data: string): void {
pty.emit?.(data)
}
/** Plays a command the shell was asked to run through to its prompt, once it was typed. */
async function answerCommand(output: string): Promise<void> {
await vi.waitFor(() => expect(pty.writes.some((write) => write.endsWith('\r'))).toBe(true))
shell(`${marker('C')}${output}\r\n${marker('D;0')}${PROMPT}`)
}
/** Lets timers run until `promise` settles: the screen is read through an emulator that parses on them. */
async function settle<T>(promise: Promise<T>): Promise<T> {
let done = false
void promise.finally(() => {
done = true
})
for (let step = 0; step < 1_000 && !done; step++) await vi.advanceTimersByTimeAsync(10)
return promise
}
beforeEach(() => {
vi.stubEnv('SHELL', '/bin/zsh')
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'],
})
})
afterEach(() => {
vi.useRealTimers()
pty.emit = null
pty.exit = null
pty.writes.length = 0
vi.unstubAllEnvs()
})
describe('a shell that is still starting', () => {
it('runs the command once slow startup files reach a prompt', async () => {
// A heavy .zshrc on a busy machine: our files began at once, the user's took 20 s.
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const running = terminal.executeTool('call-slow', 'run', {
command: 'echo hi',
waitSeconds: 30,
})
shell(STARTUP)
await vi.advanceTimersByTimeAsync(20_000)
expect(pty.writes).toEqual([])
shell(PROMPT)
await answerCommand('hi')
const response = await running
expect(response).toMatchObject({ ok: true, result: { exitCode: 0 } })
terminal.dispose()
})
it('gives startup files their full bound from when they began, however late that was', async () => {
// A machine so busy our files began 7 s after spawn, and the user's then took 25 s more.
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const running = terminal.executeTool('call-late', 'run', { command: 'echo hi' })
await vi.advanceTimersByTimeAsync(7_000)
shell(STARTUP)
await vi.advanceTimersByTimeAsync(25_000)
expect(pty.writes).toEqual([])
shell(PROMPT)
await answerCommand('hi')
const response = await running
expect(response).toMatchObject({ ok: true, result: { exitCode: 0 } })
terminal.dispose()
})
it('refuses with the screen when startup files stall on a question', async () => {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const running = terminal.executeTool('call-stalled', 'run', { command: 'echo hi' })
shell(`${STARTUP}[oh-my-zsh] Would you like to update? [Y/n] `)
await vi.advanceTimersByTimeAsync(30_000)
const response = await settle(running)
expect(response).toMatchObject({ ok: false, code: 'NO_SHELL_INTEGRATION' })
expect(response.error).toContain('[oh-my-zsh] Would you like to update? [Y/n]')
expect(response.error).toContain('ask the user to answer it')
expect(response.error).toContain('a startup file replaced the shell')
expect(pty.writes).toEqual([])
terminal.dispose()
})
it('reports the session closed when the shell exits while its startup files run', async () => {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const running = terminal.executeTool('call-exit', 'run', { command: 'echo hi' })
shell(STARTUP)
await vi.advanceTimersByTimeAsync(2_000)
pty.exit?.()
const response = await running
expect(response).toMatchObject({ ok: false, code: 'SESSION_CLOSED' })
expect(pty.writes).toEqual([])
terminal.dispose()
})
it('refuses a shell that never began our startup files at the short bound', async () => {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const running = terminal.executeTool('call-plain', 'run', { command: 'echo hi' })
await vi.advanceTimersByTimeAsync(8_000)
const response = await running
expect(response).toMatchObject({ ok: false, code: 'NO_SHELL_INTEGRATION' })
expect(response.error).toContain('did not load Sim shell integration')
terminal.dispose()
})
it('ends the wait as soon as the call is stopped, without running anything', async () => {
const terminal = new TerminalService({ loadCwd: () => '/tmp' })
const running = terminal.executeTool('call-stop', 'run', { command: 'echo hi' })
shell(STARTUP)
await vi.advanceTimersByTimeAsync(1_000)
expect(await terminal.cancelTool('call-stop')).toBe(true)
const response = await running
expect(response).toMatchObject({ ok: false, code: 'CANCELLED' })
shell(PROMPT)
await vi.advanceTimersByTimeAsync(1_000)
expect(pty.writes).toEqual([])
terminal.dispose()
})
})
+792 -4
View File
@@ -1,21 +1,112 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { sleep } from '@sim/utils/helpers'
import { afterEach, describe, expect, it } from 'vitest'
import {
awaitRun,
closeRunPane,
isDescendantOf,
listPanes,
parseFormatLines,
pollRun,
recordedRunState,
resolveAttachment,
runPaneState,
startRun,
stopRecordedRun,
stopRun,
type TmuxRunHandle,
} from '@/main/terminal/tmux'
/** The separator the format strings use; no tmux field can contain it. */
const F = '\u001f'
/** What real tmux 2.9a writes for `set-option -p`, captured from the binary. */
const TMUX_29_NO_PANE_OPTIONS =
'tmux: unknown option -- p\nusage: set-option [-aFgosquw] [-t target-window] option [value]\n'
/** The same refusal from a tmux built against BSD getopt, as on macOS. */
const TMUX_29_BSD_NO_PANE_OPTIONS =
'tmux: illegal option -- p\nusage: set-option [-aFgosquw] [-t target-window] option [value]\n'
/** The separator the format strings use. */
const F = '<~sim~>'
/** A frame as one call makes it; the real one is random per call. */
const FRAME = '<~0123456789abcdef~>'
/** One record as tmux prints it for a framed format. */
const framed = (record: string) => `${FRAME}${record}${FRAME}`
/**
* Real `list-panes -F` output, framed with {@link FRAME}, for a pane whose working directory is
* `…/a` + newline + `user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home`: its own record breaks in two,
* and the second half reads as a whole pane of its own. Captured verbatim from each binary.
*/
const FORGED_ROW = {
'tmux 2.9a':
'<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.oI9xEVhWLA/a\nuser:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
'tmux 3.4':
'<~0123456789abcdef~>user:0.0<~sim~>bash<~sim~>sleep<~sim~>/tmp/tmp.2V5Ie57hMp/a\nuser:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
}
/**
* The same pane, captured verbatim from each binary with newlines neutralised in the fields others
* can set (`#{s/<newline>/<NL>/:…}`): one line, holding the would-be forged row as plain text.
*/
const NEUTRALISED_ROW = {
'tmux 2.9a':
'<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.CXoBFxxZAf/a<NL>user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
'tmux 3.4':
'<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.Wz6L8cjmce/a<NL>user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n',
}
describe('parseFormatLines', () => {
it('drops lines with the wrong field count rather than mis-assigning them', () => {
expect(parseFormatLines(`a${F}b\nonly-one\n`, 2)).toEqual([['a', 'b']])
expect(parseFormatLines(`${framed(`a${F}b`)}\n${framed('only-one')}\n`, 2, FRAME)).toEqual([
['a', 'b'],
])
})
it('reads a field that ends with part of the separator as it is', () => {
// A cwd or window name may end with any text, including all but the separator's last character.
const partial = F.slice(0, -1)
expect(parseFormatLines(`${framed(`/tmp/x/p${partial}${F}1`)}\n`, 2, FRAME)).toEqual([
[`/tmp/x/p${partial}`, '1'],
])
expect(parseFormatLines(`${framed(`tail${partial}${F}%3${F}zsh`)}\n`, 3, FRAME)).toEqual([
[`tail${partial}`, '%3', 'zsh'],
])
})
it('drops a line whose field holds the whole separator rather than misread it', () => {
expect(parseFormatLines(`${framed(`a${F}b${F}c`)}\n`, 2, FRAME)).toEqual([])
})
it.each(Object.entries(FORGED_ROW))(
'never reads a row a directory name forges with a newline (%s)',
(_version, stdout) => {
expect(parseFormatLines(stdout, 5, FRAME)).toEqual([])
}
)
it('drops a line framed at one end only, whatever its field count', () => {
const fields = `user:0.0${F}x${F}y${F}z${F}1`
// As long as a frame, so a check of one end alone would cut it off and find five fields.
const pad = 'J'.repeat(FRAME.length)
expect(parseFormatLines(`${pad}${fields}${FRAME}\n`, 5, FRAME)).toEqual([])
expect(parseFormatLines(`${FRAME}${fields}${pad}\n`, 5, FRAME)).toEqual([])
})
it.each(Object.entries(NEUTRALISED_ROW))(
'reads the forging pane as one line once newlines are neutralised (%s)',
(_version, stdout) => {
// One line: no forged row. The separator text in its path then drops it whole.
expect(stdout.trimEnd().split('\n')).toHaveLength(1)
expect(parseFormatLines(stdout, 5, FRAME)).toEqual([])
}
)
it('reads only records framed by this call', () => {
const other = '<~fedcba9876543210~>'
expect(parseFormatLines(`${other}a${F}b${other}\n${framed(`c${F}d`)}\n`, 2, FRAME)).toEqual([
['c', 'd'],
])
})
})
@@ -43,6 +134,9 @@ describe('run status files', () => {
const handleIn = (dir: string): TmuxRunHandle => ({
window: '@1',
pane: '%1',
runId: 'run-1',
socket: null,
outPath: join(dir, 'out'),
statusPath: join(dir, 'status'),
dispose: () => {},
@@ -84,3 +178,697 @@ describe('run status files', () => {
expect(outcome).toEqual({ output: 'still going\n', exitCode: null, done: false })
})
})
/**
* A stand-in `tmux` binary on PATH that keeps its panes in a JSON file, so the run helpers are
* exercised through the real `spawn` path. It knows the handful of commands runs use; a test
* reshapes the server directly (a split, a closed window, a restart) by rewriting that file.
*/
interface FakeTmuxState {
/** The server's socket; `-S` naming any other reaches no server. */
socket: string
nextWindow: number
nextPane: number
panes: Record<
string,
{
window: string
options: Record<string, string>
command?: string
/** Its command has ended, and `remain-on-exit` keeps the pane open. */
dead?: boolean
}
>
/** Every command that reached a pane: `send-keys %1 C-c`, `kill-pane %1`. */
log: string[]
/** Commands the fake fails, with the error tmux would print. */
fail?: Record<string, string>
/** Once a key is sent, tmux stops answering: every later command fails like a dying server. */
dieAfterKeys?: boolean
/** On this many-th display-message, the pane is retagged as another run's (a restart race). */
retagAtCheck?: number
/** display-message calls so far. */
checks?: number
/** tmux restarts and the user's pane takes the id just before the next guarded action. */
retagBeforeAction?: boolean
/** Attached clients, as `list-clients` reports them. */
clients?: Array<{ pid: string; tty: string; session: string }>
/** A session's panes as `list-panes` reports them, with fields others can set. */
listed?: Array<{ windowName: string; command: string; cwd: string }>
/** list-panes writes its output in two pieces, split inside a multi-byte character. */
splitWrites?: boolean
/** Every `-F` format the fake was asked for, in order. */
formats?: string[]
/** Commands the fake holds until the file named here exists, like a busy tmux server. */
hold?: Record<string, string>
/** Commands the fake is holding right now. */
held?: string[]
}
const FAKE_TMUX = `
const fs = require('node:fs')
const file = process.env.FAKE_TMUX_STATE
const state = JSON.parse(fs.readFileSync(file, 'utf8'))
let args = process.argv.slice(2)
const save = () => fs.writeFileSync(file, JSON.stringify(state))
// \`-S socket\` names the server; any server but this one does not exist.
if (args[0] === '-S') {
if (args[1] !== state.socket) {
process.stderr.write('no server running on ' + args[1])
process.exit(1)
}
args = args.slice(2)
}
const target = () => args[args.indexOf('-t') + 1]
const fail = (message) => { process.stderr.write(message); process.exit(1) }
// Prints a format's output as tmux 3.4 and 3.5 do: a backslash doubled, and every other control
// character as its octal escape, so a control-character separator would arrive as text.
const escaped = (text) =>
text
.replace(/\\\\/g, '\\\\\\\\')
.replace(/[\\x00-\\x1f]/g, (c) => '\\\\' + c.charCodeAt(0).toString(8).padStart(3, '0'))
if (state.hold && state.hold[args[0]]) {
state.held = [...(state.held ?? []), args[0]]
save()
while (!fs.existsSync(state.hold[args[0]])) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20)
}
state.held = state.held.filter((command) => command !== args[0])
}
// \`if-shell -F -t pane '#{==:#{option},value}' command\`: the check and the action in one command.
if (args[0] === 'if-shell') {
const pane = state.panes[target()]
if (pane && state.retagBeforeAction) {
pane.options['@sim-run-id'] = 'someone-else'
state.retagBeforeAction = false
save()
}
const check = /^#\\{==:#\\{([^}]+)\\},(.*)\\}$/.exec(args[args.length - 2])
if (!pane || !check || (pane.options[check[1]] ?? '') !== check[2]) process.exit(0)
args = args[args.length - 1].split(' ')
}
if (state.fail && state.fail[args[0]]) fail(state.fail[args[0]])
switch (args[0]) {
case 'new-window': {
const window = '@' + state.nextWindow++
const pane = '%' + state.nextPane++
state.panes[pane] = { window, options: {}, command: args[args.length - 1] }
save()
// Runs the pane's command for real, the way tmux would, when a test asks for it.
if (process.env.FAKE_TMUX_EXEC) {
require('node:child_process')
.spawn('sh', ['-c', args[args.length - 1]], { detached: true, stdio: 'ignore' })
.unref()
}
process.stdout.write(window + ' ' + pane + '\\n')
break
}
case 'set-option': {
const pane = state.panes[target()]
if (!pane) fail("can't find pane")
pane.options[args[args.length - 2]] = args[args.length - 1]
save()
break
}
case 'display-message': {
state.checks = (state.checks ?? 0) + 1
if (state.retagAtCheck === state.checks && state.panes[target()]) {
state.panes[target()].options['@sim-run-id'] = 'someone-else'
}
save()
// Like tmux 3.x, a pane that is gone answers with an empty line rather than an error.
const pane = state.panes[target()]
const field = (name) =>
name === 'pane_id'
? target()
: name === 'socket_path'
? state.socket
: name === 'pane_start_command'
? (pane.command ?? '')
: name === 'pane_dead'
? (pane.dead ? '1' : '0')
: (pane.options[name] ?? '')
const value = pane ? args[args.length - 1].replace(/#\\{([^}]+)\\}/g, (_, name) => field(name)) : ''
process.stdout.write(value + '\\n')
break
}
case 'list-panes': {
// Formats as tmux evaluates them: \`#{s/pattern/replacement/:field}\` substitutes, and every
// other field prints as is, a newline included.
const format = args[args.indexOf('-F') + 1]
state.formats = [...(state.formats ?? []), format]
save()
let output = ''
;(state.listed ?? []).forEach((pane, index) => {
const fields = {
session_name: 'work',
window_index: '0',
pane_index: String(index),
window_name: pane.windowName,
pane_current_command: pane.command,
pane_current_path: pane.cwd,
pane_active: index === 0 ? '1' : '0',
}
const line = format
.replace(/#\\{s\\/([^/]*)\\/([^/]*)\\/:([a-z_]+)\\}/g, (_, from, to, name) =>
fields[name].split(from).join(to)
)
.replace(/#\\{([a-z_]+)\\}/g, (_, name) => fields[name])
output += line + '\\n'
})
const bytes = Buffer.from(output)
if (!state.splitWrites) {
process.stdout.write(bytes)
break
}
// The first piece ends inside a character, and arrives well before the rest.
const cut = bytes.findIndex((byte) => byte >= 0x80) + 1
fs.writeSync(1, bytes.subarray(0, cut))
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
fs.writeSync(1, bytes.subarray(cut))
break
}
case 'list-clients': {
const format = args[args.indexOf('-F') + 1]
for (const client of state.clients ?? []) {
const line = format
.replace('#{client_pid}', client.pid)
.replace('#{client_tty}', client.tty)
.replace('#{client_session}', client.session)
process.stdout.write(escaped(line) + '\\n')
}
break
}
case 'send-keys':
case 'kill-pane': {
if (!state.panes[target()]) fail("can't find pane")
state.log.push(args[0] + ' ' + target() + (args[0] === 'send-keys' ? ' ' + args[args.length - 1] : ''))
if (args[0] === 'send-keys' && state.dieAfterKeys) {
state.fail = { 'display-message': 'server exited unexpectedly', 'kill-pane': 'server exited unexpectedly' }
}
if (args[0] === 'kill-pane') delete state.panes[target()]
save()
break
}
default:
fail('unsupported: ' + args[0])
}
`
function fakeTmux(options: { exec?: boolean } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'fake-tmux-'))
const stateFile = join(dir, 'state.json')
const binary = join(dir, 'tmux')
writeFileSync(binary, `#!${process.execPath}\n${FAKE_TMUX}`)
chmodSync(binary, 0o755)
const write = (state: FakeTmuxState) => writeFileSync(stateFile, JSON.stringify(state))
write({ nextWindow: 0, nextPane: 0, panes: {}, log: [], socket: join(dir, 'server.sock') })
const read = (): FakeTmuxState => JSON.parse(readFileSync(stateFile, 'utf8'))
return {
dir,
env: {
...process.env,
PATH: `${dir}:${process.env.PATH ?? ''}`,
FAKE_TMUX_STATE: stateFile,
...(options.exec ? { FAKE_TMUX_EXEC: '1' } : {}),
},
read,
write,
/** The user splits a run's window: a new pane of theirs beside the run's. */
split(window: string): string {
const state = read()
const pane = `%${state.nextPane++}`
state.panes[pane] = { window, options: {} }
write(state)
return pane
},
/** tmux restarts: every pane is gone and ids start over. */
restart() {
write({ ...read(), nextWindow: 0, nextPane: 0, panes: {} })
},
}
}
describe("listing a session's panes", () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
it('lists a pane whose directory, command or title holds a newline, as one pane', async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
tmux.write({
...tmux.read(),
listed: [
{ windowName: 'build\nlogs', command: 'make\nuser:0.9', cwd: '/tmp/a\nuser:0.0' },
{ windowName: 'zsh', command: 'zsh', cwd: '/tmp' },
],
})
expect(await listPanes('work', tmux.env)).toEqual([
{
target: 'work:0.0',
windowName: 'build<NL>logs',
command: 'make<NL>user:0.9',
cwd: '/tmp/a<NL>user:0.0',
active: true,
},
{ target: 'work:0.1', windowName: 'zsh', command: 'zsh', cwd: '/tmp', active: false },
])
})
it('reads a character tmux wrote in two pieces as one', async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
tmux.write({
...tmux.read(),
splitWrites: true,
listed: [{ windowName: 'café ✓', command: 'zsh', cwd: '/tmp' }],
})
expect((await listPanes('work', tmux.env))[0]?.windowName).toBe('café ✓')
})
it('frames each call with a marker of its own', async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
await listPanes('work', tmux.env)
await listPanes('work', tmux.env)
const frames = (tmux.read().formats ?? []).map((format) => /^<~([0-9a-f]+)~>/.exec(format)?.[1])
expect(frames).toHaveLength(2)
expect(frames[0]).toMatch(/^[0-9a-f]{16}$/)
expect(frames[1]).not.toBe(frames[0])
})
})
describe('finding the tmux session a shell runs', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
it('reads the clients tmux 3.4 and 3.5 list, which escape control characters', async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
// A client of this very process: its parent stands in for the shell the client runs in.
tmux.write({
...tmux.read(),
clients: [{ pid: String(process.pid), tty: '/dev/pts/3', session: 'work' }],
})
expect(await resolveAttachment(process.ppid, tmux.env)).toEqual({
session: 'work',
clientTty: '/dev/pts/3',
})
})
})
describe('stopping a run another process started, from its record', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
async function recorded(tmux: ReturnType<typeof fakeTmux>) {
dirs.push(tmux.dir)
const run = await startRun('agent', 'sleep 600', null, tmux.env)
if ('error' in run) throw new Error(run.error)
return { run, record: { runId: run.runId ?? '', pane: run.pane, socket: run.socket ?? '' } }
}
it('records the server a tagged run runs on', async () => {
const tmux = fakeTmux()
const { run } = await recorded(tmux)
expect(run.socket).toBe(tmux.read().socket)
})
it('interrupts and then closes the pane while it still carries the run tag', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
expect(await stopRecordedRun(record, tmux.env, 0)).toBe('gone')
expect(tmux.read().log).toEqual([`send-keys ${record.pane} C-c`, `kill-pane ${record.pane}`])
expect(tmux.read().panes).toEqual({})
})
it('reads a pane kept open after its command ended as finished, and closes it without keys', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
const state = tmux.read()
const pane = state.panes[record.pane]
if (pane) pane.dead = true
tmux.write(state)
expect(await recordedRunState(record, tmux.env)).toBe('finished')
expect(await stopRecordedRun(record, tmux.env, 0)).toBe('gone')
expect(tmux.read().log).toEqual([`kill-pane ${record.pane}`])
})
it('never touches a pane that took the recorded id after tmux restarted', async () => {
const tmux = fakeTmux()
const { run, record } = await recorded(tmux)
tmux.restart()
const state = tmux.read()
state.panes[run.pane] = { window: run.window, options: {}, command: 'zsh' }
tmux.write(state)
expect(await stopRecordedRun(record, tmux.env, 0)).toBe('gone')
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
})
it('never asks a different tmux server about the pane', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
const elsewhere = { ...record, socket: join(tmux.dir, 'another.sock') }
expect(await stopRecordedRun(elsewhere, tmux.env, 0)).toBe('gone')
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([record.pane])
})
it('checks the pane is still the run once more right before closing it', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
// The first check (before Ctrl-C) sees the run; the next, right before closing, does not.
tmux.write({ ...tmux.read(), checks: 0, retagAtCheck: 2 })
expect(await stopRecordedRun(record, tmux.env, 0)).toBe('gone')
expect(tmux.read().log).toEqual([`send-keys ${record.pane} C-c`])
expect(Object.keys(tmux.read().panes)).toEqual([record.pane])
})
it('never starts a tagged run whose server it cannot name for a record', async () => {
const tmux = fakeTmux({ exec: true })
dirs.push(tmux.dir)
tmux.write({ ...tmux.read(), socket: '' })
const marker = join(tmux.dir, 'ran')
const result = await startRun('agent', `touch ${JSON.stringify(marker)}`, null, tmux.env, {
beforeStart: () => true,
})
expect(result).toMatchObject({ error: expect.stringContaining('was not run') })
// The pane it opened and tagged is closed with it.
expect(tmux.read().panes).toEqual({})
await sleep(1_500)
expect(existsSync(marker)).toBe(false)
})
it('closes the tagged pane of a run that then could not start', async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
let runDir = ''
const result = await startRun('agent', 'sleep 600', null, tmux.env, {
beforeStart: (run) => {
// The run's directory turns read-only, so its go file cannot be written.
const command = tmux.read().panes[run.pane]?.command ?? ''
runDir = /"([^"]+)\/run\.sh"/.exec(command)?.[1] ?? ''
chmodSync(runDir, 0o500)
return true
},
})
if (runDir) chmodSync(runDir, 0o700)
if (runDir) dirs.push(runDir)
expect(result).toMatchObject({ error: expect.stringContaining('could not be started') })
expect(tmux.read().panes).toEqual({})
})
it('acts on no pane that took the recorded id between the last check and the action', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
tmux.write({ ...tmux.read(), retagBeforeAction: true })
await stopRecordedRun(record, tmux.env, 0)
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([record.pane])
})
it('keeps the record when tmux stops answering part-way through the stop', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
tmux.write({ ...tmux.read(), dieAfterKeys: true })
expect(await stopRecordedRun(record, tmux.env, 0)).toBe('unknown')
// Its pane could not be confirmed as the run's, so it was not closed.
expect(tmux.read().log).toEqual([`send-keys ${record.pane} C-c`])
})
it('saves the record before the command may start, and never starts one it could not save', async () => {
const tmux = fakeTmux({ exec: true })
dirs.push(tmux.dir)
const marker = join(tmux.dir, 'ran')
let ranBeforeRecord = true
const run = await startRun('agent', `touch ${JSON.stringify(marker)}`, null, tmux.env, {
beforeStart: () => {
// Time enough for a command already released to have run.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_000)
ranBeforeRecord = existsSync(marker)
return true
},
})
if ('error' in run) throw new Error(run.error)
expect(ranBeforeRecord).toBe(false)
await expect.poll(() => existsSync(marker), { timeout: 10_000 }).toBe(true)
const unsaved = fakeTmux({ exec: true })
dirs.push(unsaved.dir)
const unsavedMarker = join(unsaved.dir, 'ran')
const refused = await startRun(
'agent',
`touch ${JSON.stringify(unsavedMarker)}`,
null,
unsaved.env,
{
beforeStart: () => false,
}
)
expect(refused).toMatchObject({ error: expect.stringContaining('was not run') })
await sleep(1_500)
expect(existsSync(unsavedMarker)).toBe(false)
}, 20_000)
it('leaves the record for later while tmux cannot be asked', async () => {
const tmux = fakeTmux()
const { record } = await recorded(tmux)
tmux.write({ ...tmux.read(), fail: { 'display-message': 'server exited unexpectedly' } })
expect(await stopRecordedRun(record, tmux.env, 0)).toBe('unknown')
expect(tmux.read().log).toEqual([])
})
})
describe('stopping a tmux run touches only its own pane', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
async function started(tmux: ReturnType<typeof fakeTmux>): Promise<TmuxRunHandle> {
dirs.push(tmux.dir)
const handle = await startRun('agent', 'sleep 600', null, tmux.env)
if ('error' in handle) throw new Error(handle.error)
return handle
}
it('interrupts and then closes only the run pane, leaving a pane the user split beside it', async () => {
const tmux = fakeTmux()
const run = await started(tmux)
const users = tmux.split(run.window)
await stopRun(run, tmux.env, 0)
expect(tmux.read().log).toEqual([`send-keys ${run.pane} C-c`, `kill-pane ${run.pane}`])
expect(Object.keys(tmux.read().panes)).toEqual([users])
})
it('sends a live run nothing once its pane is retagged between the check and the action', async () => {
const tmux = fakeTmux()
const run = await started(tmux)
tmux.write({ ...tmux.read(), retagBeforeAction: true })
await stopRun(run, tmux.env, 0)
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
})
it('sends nothing to a pane that reused the run pane id after tmux restarted', async () => {
const tmux = fakeTmux()
const run = await started(tmux)
tmux.restart()
// The user's own new window gets the ids the run's pane had.
const state = tmux.read()
state.panes[run.pane] = { window: run.window, options: {} }
tmux.write(state)
expect(await runPaneState(run, tmux.env)).toBe('gone')
await stopRun(run, tmux.env, 0)
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
})
it('treats a run pane the user closed as gone', async () => {
const tmux = fakeTmux()
const run = await started(tmux)
const state = tmux.read()
delete state.panes[run.pane]
tmux.write(state)
expect(await runPaneState(run, tmux.env)).toBe('gone')
await stopRun(run, tmux.env, 0)
expect(tmux.read().log).toEqual([])
})
it.each([
['tmux 2.9a', TMUX_29_NO_PANE_OPTIONS],
['BSD getopt', TMUX_29_BSD_NO_PANE_OPTIONS],
])(
'starts a run untracked on a tmux without pane options (%s), and never stops it by a pane id',
async (_build, refusal) => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
// tmux before 3.0 has no pane options.
tmux.write({ ...tmux.read(), fail: { 'set-option': refusal } })
const run = await startRun('agent', 'sleep 600', null, tmux.env)
if ('error' in run) throw new Error(run.error)
expect(run.runId).toBeNull()
expect(existsSync(join(run.statusPath, '..', 'go'))).toBe(true)
expect(await runPaneState(run, tmux.env)).toBe('unknown')
await stopRun(run, tmux.env, 0)
// No pane is touched by an id that a restarted server might have handed to the user.
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
// Once its pane is gone, it can be let go.
const state = tmux.read()
delete state.panes[run.pane]
tmux.write(state)
expect(await runPaneState(run, tmux.env)).toBe('gone')
}
)
it.each(['tmux did not respond', 'server exited unexpectedly'])(
'refuses a run that a tmux able to tag panes did not tag (%s)',
async (failure) => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
tmux.write({ ...tmux.read(), fail: { 'set-option': failure } })
const result = await startRun('agent', 'sleep 600', null, tmux.env)
// Untagged on a tmux that tags, nothing could stop it later, so it never starts.
expect(result).toMatchObject({ error: expect.stringContaining('was not run') })
expect(tmux.read().log).toEqual([])
}
)
it("closes a finished untracked run's pane, and only once it has finished", async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
tmux.write({ ...tmux.read(), fail: { 'set-option': TMUX_29_NO_PANE_OPTIONS } })
const run = await startRun('agent', 'make build', null, tmux.env)
if ('error' in run) throw new Error(run.error)
await closeRunPane(run, tmux.env)
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
// Its command ended; with `remain-on-exit` its dead pane would otherwise stay open.
writeFileSync(run.statusPath, '0')
await closeRunPane(run, tmux.env)
expect(Object.keys(tmux.read().panes)).toEqual([])
})
it("never closes a pane that took a finished untracked run's id after tmux restarted", async () => {
const tmux = fakeTmux()
dirs.push(tmux.dir)
tmux.write({ ...tmux.read(), fail: { 'set-option': TMUX_29_NO_PANE_OPTIONS } })
const run = await startRun('agent', 'make build', null, tmux.env)
if ('error' in run) throw new Error(run.error)
writeFileSync(run.statusPath, '0')
tmux.restart()
// The user's own shell gets the ids the run's pane had.
const state = tmux.read()
state.panes[run.pane] = { window: run.window, options: {}, command: 'zsh' }
tmux.write(state)
await closeRunPane(run, tmux.env)
expect(tmux.read().log).toEqual([])
expect(Object.keys(tmux.read().panes)).toEqual([run.pane])
})
it('lets a tagged run start only once its pane is tagged', async () => {
const tmux = fakeTmux()
const run = await started(tmux)
expect(tmux.read().panes[run.pane]?.options['@sim-run-id']).toBe(run.runId)
expect(existsSync(join(run.statusPath, '..', 'go'))).toBe(true)
})
it('neither stops nor gives up on a run while tmux cannot be asked', async () => {
const tmux = fakeTmux()
const run = await started(tmux)
tmux.write({ ...tmux.read(), fail: { 'display-message': 'server exited unexpectedly' } })
expect(await runPaneState(run, tmux.env)).toBe('unknown')
await stopRun(run, tmux.env, 0)
expect(tmux.read().log).toEqual([])
})
it('runs a command for real whether or not tmux could tag its pane', async () => {
const tagged = fakeTmux({ exec: true })
dirs.push(tagged.dir)
const run = await startRun('agent', 'echo ran', null, tagged.env)
if ('error' in run) throw new Error(run.error)
await expect
.poll(() => pollRun(run), { timeout: 10_000 })
.toMatchObject({
done: true,
exitCode: 0,
})
const untagged = fakeTmux({ exec: true })
dirs.push(untagged.dir)
untagged.write({ ...untagged.read(), fail: { 'set-option': TMUX_29_NO_PANE_OPTIONS } })
const untracked = await startRun('agent', 'echo ran', null, untagged.env)
if ('error' in untracked) throw new Error(untracked.error)
await expect
.poll(() => pollRun(untracked), { timeout: 10_000 })
.toMatchObject({ done: true, exitCode: 0, output: 'ran\n' })
}, 20_000)
it('holds a command until tmux has finished tagging its pane', async () => {
const tmux = fakeTmux({ exec: true })
dirs.push(tmux.dir)
const release = join(tmux.dir, 'release')
tmux.write({ ...tmux.read(), hold: { 'set-option': release } })
const marker = join(tmux.dir, 'ran')
const starting = startRun('agent', `touch ${JSON.stringify(marker)}`, null, tmux.env)
// The pane is open and the tagging call is in flight, held by tmux.
await expect.poll(() => tmux.read().held ?? [], { timeout: 10_000 }).toEqual(['set-option'])
// Time enough for an ungated command to have run.
await sleep(1_000)
expect(existsSync(marker)).toBe(false)
writeFileSync(release, '')
const run = await starting
if ('error' in run) throw new Error(run.error)
await expect.poll(() => existsSync(marker), { timeout: 10_000 }).toBe(true)
}, 20_000)
})
+330 -38
View File
@@ -17,12 +17,15 @@
* of the shell that launched it.
*/
import { spawn } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { createLogger } from '@sim/logger'
import type { TerminalPaneState } from '@sim/terminal-protocol'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { generateRandomHex } from '@sim/utils/random'
const logger = createLogger('DesktopTmux')
@@ -36,8 +39,36 @@ const TMUX_TIMEOUT_MS = 5_000
/** How often the status file is checked while a tmux-run command is going. */
const RUN_POLL_INTERVAL_MS = 250
/** Field separator for `-F` output. Chosen because no tmux field contains it. */
const FIELD = '\u001f'
/**
* Field separator for `-F` output. Printable on purpose: tmux 3.4 and 3.5 print a control
* character as its octal escape, so a control-character separator arrived as the text `\037` and
* no line split. No tmux escapes these characters. Fields are untrusted text (a directory or
* window name can hold the separator, or a newline), so records are also framed per call: see
* {@link framedFormat}.
*/
const FIELD = '<~sim~>'
/**
* A field someone other than the user can set: a directory name (`pane_current_path`), a program's
* name (`pane_current_command`), or a window title a program sets (`window_name`). tmux prints a
* newline inside a field as is, and a newline would end the record early and let the rest read as
* a record of its own, so tmux replaces each newline with `<NL>` before printing.
*/
function untrusted(field: string): string {
return `#{s/\n/<NL>/:${field}}`
}
/**
* A `-F` format whose every record starts and ends with a marker made fresh for this call; only
* lines framed whole by it are read, so a line that is not a whole record is dropped rather than
* misread. The marker is a second line of defence, not a secret (another user can read a process's
* arguments on many systems), which is why newlines are neutralised at the source too
* ({@link untrusted}).
*/
function framedFormat(fields: string[]): { format: string; frame: string } {
const frame = `<~${generateRandomHex(16)}~>`
return { format: `${frame}${fields.join(FIELD)}${frame}`, frame }
}
export interface TmuxCommandResult {
ok: boolean
@@ -104,11 +135,14 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise<TmuxCom
finish({ ok: false, stdout, stderr: 'tmux did not respond' })
}, TMUX_TIMEOUT_MS)
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString()
// Decoded as streams, so a character split across two chunks arrives whole.
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout += chunk
})
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString()
child.stderr?.on('data', (chunk: string) => {
stderr += chunk
})
child.on('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') tmuxBinaryMissing = true
@@ -121,18 +155,20 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise<TmuxCom
}
/**
* Parses `list-clients`/`list-panes` output into records.
* Parses `list-clients`/`list-panes` output into records: only lines framed whole by this call's
* marker, each with exactly the fields asked for.
*
* Split on a control character rather than whitespace: window names and
* Split on a dedicated separator rather than whitespace: window names and
* working directories contain spaces, and a path with a space would otherwise
* shift every later field by one.
*/
export function parseFormatLines(stdout: string, fields: number): string[][] {
export function parseFormatLines(stdout: string, fields: number, frame: string): string[][] {
return stdout
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.length > 0)
.map((line) => line.split(FIELD))
.filter(
(line) => line.length >= frame.length * 2 && line.startsWith(frame) && line.endsWith(frame)
)
.map((line) => line.slice(frame.length, line.length - frame.length).split(FIELD))
.filter((parts) => parts.length === fields)
}
@@ -198,11 +234,11 @@ export async function resolveAttachment(
shellPid: number,
env: NodeJS.ProcessEnv
): Promise<TmuxAttachment | null> {
const format = ['#{client_pid}', '#{client_tty}', '#{client_session}'].join(FIELD)
const { format, frame } = framedFormat(['#{client_pid}', '#{client_tty}', '#{client_session}'])
const listed = await runTmux(['list-clients', '-F', format], env)
if (!listed.ok) return null
const clients = parseFormatLines(listed.stdout, 3)
const clients = parseFormatLines(listed.stdout, 3, frame)
if (clients.length === 0) return null
const parents = await listProcessParents()
@@ -218,28 +254,27 @@ export async function resolveAttachment(
/** The active pane of a session, as a target usable by every other call. */
export async function activePane(session: string, env: NodeJS.ProcessEnv): Promise<string | null> {
const result = await runTmux(
['display-message', '-p', '-t', session, '#{session_name}:#{window_index}.#{pane_index}'],
env
)
const target = result.stdout.trim()
return result.ok && target ? target : null
const { format, frame } = framedFormat(['#{session_name}:#{window_index}.#{pane_index}'])
const result = await runTmux(['display-message', '-p', '-t', session, format], env)
if (!result.ok) return null
const [target] = parseFormatLines(result.stdout, 1, frame)[0] ?? []
return target || null
}
export async function listPanes(
session: string,
env: NodeJS.ProcessEnv
): Promise<TerminalPaneState[]> {
const format = [
const { format, frame } = framedFormat([
'#{session_name}:#{window_index}.#{pane_index}',
'#{window_name}',
'#{pane_current_command}',
'#{pane_current_path}',
untrusted('window_name'),
untrusted('pane_current_command'),
untrusted('pane_current_path'),
'#{pane_active}',
].join(FIELD)
])
const result = await runTmux(['list-panes', '-s', '-t', session, '-F', format], env)
if (!result.ok) return []
return parseFormatLines(result.stdout, 5).map(
return parseFormatLines(result.stdout, 5, frame).map(
([target, windowName, command, cwd, active]): TerminalPaneState => ({
target,
windowName,
@@ -300,11 +335,39 @@ export const TMUX_KEY_NAMES: Record<string, string> = {
*/
export interface TmuxRunHandle {
window: string
/** The run's own pane: input and stops go here, never to whatever pane is active. */
pane: string
/**
* Tagged on the pane as the `@sim-run-id` user option. Window and pane ids restart from zero
* with the tmux server, so only the tag proves a pane is still this run's. Null when tmux could
* not tag the pane (tmux before 3.0 has no pane options): the run goes ahead untracked, and
* nothing ever stops it, since nothing could tell its pane from one of the user's.
*/
runId: string | null
/** The tmux server's socket, for a record a later process can act on; null if unknown. */
socket: string | null
outPath: string
statusPath: string
dispose(): void
}
/**
* How many 50 ms polls a run's command waits for its go file: longer than the tagging call can
* take before it times out, so a tag that succeeds always lands inside the window.
*/
const RUN_GATE_POLLS = Math.ceil((2 * TMUX_TIMEOUT_MS + 5_000) / 50)
/** The tmux user option that marks a pane as one run's own. */
const RUN_ID_OPTION = '@sim-run-id'
/**
* How tmux before 3.0, which has no pane options, refuses `set-option -p`. Its own getopt prints
* `unknown option -- p` (BSD getopt on macOS: `illegal option -- p`) followed by set-option's usage
* line; later wordings are kept for any build that phrases it so.
*/
const NO_PANE_OPTIONS =
/unknown option -- p|illegal option -- p|usage: set-option|unknown flag|invalid option/i
/**
* Starts a command in a dedicated tmux window.
*
@@ -321,11 +384,19 @@ export async function startRun(
session: string,
command: string,
cwd: string | null,
env: NodeJS.ProcessEnv
env: NodeJS.ProcessEnv,
options: {
/**
* Records a tagged run before its command may start; false keeps the command from starting,
* since a run no later process could find must not outlive this one.
*/
beforeStart?: (run: RecordedRun) => boolean
} = {}
): Promise<TmuxRunHandle | { error: string }> {
const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-run-'))
const outPath = join(dir, 'out')
const statusPath = join(dir, 'status')
const goPath = join(dir, 'go')
const dispose = () => {
try {
rmSync(dir, { recursive: true, force: true })
@@ -343,10 +414,40 @@ export async function startRun(
// writing to the unlinked inode — but an unredirected `printf` would fail
// into the pipeline and print `No such file or directory` into the user's own
// tmux window, minutes after they closed the tab.
const script = `${command}\nprintf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)} 2>/dev/null`
const wrapper = `bash -lc ${JSON.stringify(`{ ${script}; } 2>&1 | tee ${JSON.stringify(outPath)}`)}`
//
// The command waits for the tagging call to finish (the go file), so a tagged run's command
// never runs before a later stop could recognize its pane. If Sim never releases it (it quit
// mid-start), the script gives up once the tagging call has surely ended and its pane closes on
// its own; no one has to close a pane whose id might no longer be its own.
//
// The script is a file rather than a `bash -c` string: tmux hands its command to `sh -c`, which
// would expand `$` references meant for bash (the gate's counter, PIPESTATUS) before bash ran.
const scriptPath = join(dir, 'run.sh')
writeFileSync(
scriptPath,
[
'i=0',
`while [ ! -e ${JSON.stringify(goPath)} ] && [ "$i" -lt ${RUN_GATE_POLLS} ]; do sleep 0.05; i=$((i + 1)); done`,
`[ -e ${JSON.stringify(goPath)} ] || exit 0`,
`{ ${command}`,
`printf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)} 2>/dev/null; } 2>&1 | tee ${JSON.stringify(outPath)}`,
'',
].join('\n'),
{ mode: 0o600 }
)
const wrapper = `bash -l ${JSON.stringify(scriptPath)}`
const args = ['new-window', '-d', '-P', '-F', '#{window_id}', '-t', session, '-n', 'sim-run']
const args = [
'new-window',
'-d',
'-P',
'-F',
'#{window_id} #{pane_id}',
'-t',
session,
'-n',
'sim-run',
]
if (cwd) args.push('-c', cwd)
args.push(wrapper)
@@ -355,8 +456,170 @@ export async function startRun(
dispose()
return { error: created.stderr.trim() || 'tmux could not open a window for the command.' }
}
const [window = '', pane = ''] = created.stdout.trim().split(' ')
const tag = generateId()
// An untagged pane is never treated as the run's: without the tag a stop could not tell it from
// a pane the user opened later under the same id.
const tagged = await runTmux(['set-option', '-p', '-t', pane, RUN_ID_OPTION, tag], env)
if (!tagged.ok && !NO_PANE_OPTIONS.test(tagged.stderr)) {
// A tmux that can tag panes but did not (it timed out, or failed otherwise) gets no command
// that nothing could stop: without the go file the wrapper exits by itself.
dispose()
return {
error: `tmux could not mark the command's pane (${tagged.stderr.trim() || 'no detail'}), so the command was not run.`,
}
}
if (!tagged.ok) {
logger.warn('This tmux cannot tag a run pane; the run goes ahead untracked', {
error: tagged.stderr.trim(),
})
}
const runId = tagged.ok ? tag : null
// Which server the pane lives on, so a later process stops it there and nowhere else.
const shown = runId
? await runTmux(['display-message', '-p', '-t', pane, '#{socket_path}'], env)
: null
const socket = shown?.ok && shown.stdout.trim().startsWith('/') ? shown.stdout.trim() : null
if (runId && options.beforeStart && !(socket && options.beforeStart({ runId, pane, socket }))) {
// The pane was tagged a moment ago, so it is closed as the run's; without the go file its
// command never starts in any case.
await runTmux(ifTagged(pane, runId, `kill-pane -t ${pane}`), env)
dispose()
return { error: 'The command could not be recorded for a later stop, so it was not run.' }
}
try {
writeFileSync(goPath, '')
} catch (error) {
// Like a refused run: its tagged pane closes now, and the next sweep drops a saved record.
if (runId) await runTmux(ifTagged(pane, runId, `kill-pane -t ${pane}`), env)
dispose()
return { error: `The command could not be started: ${getErrorMessage(error)}` }
}
return { window: created.stdout.trim(), outPath, statusPath, dispose }
return { window, pane, runId, socket, outPath, statusPath, dispose }
}
/**
* Whether the run's pane is still the run's: `ours`, or `gone` when tmux has no such pane or the
* pane under that id is not tagged as this run's (the user closed it, or a restarted tmux server
* handed the id to one of the user's own panes). `unknown` when tmux could not be asked, or an
* untracked run's pane still exists, since nothing proves whose it is: such a pane is neither
* touched nor given up on.
*/
export async function runPaneState(
handle: TmuxRunHandle,
env: NodeJS.ProcessEnv
): Promise<'ours' | 'gone' | 'unknown'> {
if (!handle.pane) return 'gone'
// An untracked run's pane can still be found missing, with a format every tmux knows.
const format = handle.runId === null ? '#{pane_id}' : `#{${RUN_ID_OPTION}}`
const shown = await runTmux(['display-message', '-p', '-t', handle.pane, format], env)
// tmux 3.x answers for a missing pane with an empty line rather than an error.
if (shown.ok && handle.runId === null) {
return shown.stdout.trim() === handle.pane ? 'unknown' : 'gone'
}
if (shown.ok) return shown.stdout.trim() === handle.runId ? 'ours' : 'gone'
return /can't find|no server running/i.test(shown.stderr) ? 'gone' : 'unknown'
}
/**
* Arguments for one tmux command that runs `command` on a pane only while the pane carries the
* run's tag. tmux checks the tag and acts within the one command, so a restart between a check
* and an action can never hand the action to a pane that took the id.
*/
function ifTagged(pane: string, runId: string, command: string, socket?: string): string[] {
return [
...(socket ? ['-S', socket] : []),
'if-shell',
'-F',
'-t',
pane,
`#{==:#{${RUN_ID_OPTION}},${runId}}`,
command,
]
}
/**
* Stops a run: Ctrl-C in its own pane, then closing that pane if the command ignored it. Every
* step first checks the pane is still the run's, and only that pane is ever closed, so a pane the
* user split off beside it, or a window that reused its ids, is never touched.
*/
export async function stopRun(
handle: TmuxRunHandle,
env: NodeJS.ProcessEnv,
graceMs: number
): Promise<void> {
if ((await runPaneState(handle, env)) !== 'ours' || !handle.runId) return
await runTmux(ifTagged(handle.pane, handle.runId, `send-keys -t ${handle.pane} C-c`), env)
const deadline = Date.now() + graceMs
while (!isRunComplete(handle) && Date.now() < deadline) await sleep(100)
if (!isRunComplete(handle)) await closeRunPane(handle, env)
}
/** A recorded run as a later process finds it: what it takes to find its pane again. */
export interface RecordedRun {
/** The tag on the run's pane; only a pane carrying it is ever acted on. */
runId: string
pane: string
/** The tmux server's socket, so a different server is never asked about this pane. */
socket: string
}
/**
* Whether a recorded run's pane still carries its tag, asked of the run's own server only: `gone`
* when that server or pane no longer exists or the pane is not tagged as this run's, `unknown`
* when tmux could not be asked.
*/
export async function recordedRunState(
run: RecordedRun,
env: NodeJS.ProcessEnv
): Promise<'ours' | 'finished' | 'gone' | 'unknown'> {
const shown = await runTmux(
['-S', run.socket, 'display-message', '-p', '-t', run.pane, `#{${RUN_ID_OPTION}} #{pane_dead}`],
env
)
if (shown.ok) {
const [tag, dead] = shown.stdout.trim().split(' ')
if (tag !== run.runId) return 'gone'
// Kept open after its command ended (`remain-on-exit`): nothing in it is left to run.
return dead === '1' ? 'finished' : 'ours'
}
return /can't find|no server running|error connecting|no such file/i.test(shown.stderr)
? 'gone'
: 'unknown'
}
/**
* Stops a run another process started, from its record: Ctrl-C in its pane, then closing that pane
* if the command ignored it. Every step first checks, on the run's own server, that the pane still
* carries the run's tag, so a pane that took its id after a tmux restart is never touched.
* Resolves `gone` once nothing of the run is left to stop, and `unknown` when tmux could not say.
*/
export async function stopRecordedRun(
run: RecordedRun,
env: NodeJS.ProcessEnv,
graceMs: number
): Promise<'gone' | 'unknown'> {
const before = await recordedRunState(run, env)
if (before === 'gone' || before === 'unknown') return before
let state: 'ours' | 'finished' | 'gone' | 'unknown' = before
if (before === 'ours') {
await runTmux(ifTagged(run.pane, run.runId, `send-keys -t ${run.pane} C-c`, run.socket), env)
const deadline = Date.now() + graceMs
while (Date.now() < deadline) {
await sleep(100)
state = await recordedRunState(run, env)
if (state !== 'ours') break
}
}
// The pane is closed only while it is confirmed the run's; a pane tmux could not answer for is
// left alone, and its record kept for the next sweep.
if (state === 'ours' || state === 'finished') state = await recordedRunState(run, env)
if (state === 'ours' || state === 'finished') {
await runTmux(ifTagged(run.pane, run.runId, `kill-pane -t ${run.pane}`, run.socket), env)
state = await recordedRunState(run, env)
}
return state === 'gone' ? 'gone' : 'unknown'
}
function readIfPresent(path: string): string | null {
@@ -419,11 +682,40 @@ export async function killPane(target: string, env: NodeJS.ProcessEnv): Promise<
return runTmux(['kill-pane', '-t', target], env)
}
/** Closes a window opened by {@link startRun}. */
export async function closeRunWindow(handle: TmuxRunHandle, env: NodeJS.ProcessEnv): Promise<void> {
if (!handle.window) return
const killed = await runTmux(['kill-window', '-t', handle.window], env)
/**
* Whether the pane under an untracked run's id was started with that run's own script, whose path
* is unique to the run. Every tmux reports a pane's start command, so this holds where tags do not.
*/
async function startedByRun(handle: TmuxRunHandle, env: NodeJS.ProcessEnv): Promise<boolean> {
const script = join(dirname(handle.statusPath), 'run.sh')
const shown = await runTmux(
['display-message', '-p', '-t', handle.pane, '#{pane_start_command}'],
env
)
return shown.ok && shown.stdout.includes(script)
}
/**
* Closes the pane opened by {@link startRun}, and with it the window once that pane is the last
* one in it. Only the run's own pane, and only while it is still the run's. An untracked run's
* pane is closed only once the run has written its exit status, and only if the pane was started
* by the run's own script: a restarted tmux may have handed the id to one of the user's panes.
*/
export async function closeRunPane(handle: TmuxRunHandle, env: NodeJS.ProcessEnv): Promise<void> {
const state = await runPaneState(handle, env)
const finishedUntracked =
handle.runId === null &&
state === 'unknown' &&
isRunComplete(handle) &&
(await startedByRun(handle, env))
if (state !== 'ours' && !finishedUntracked) return
const killed = await runTmux(
handle.runId
? ifTagged(handle.pane, handle.runId, `kill-pane -t ${handle.pane}`)
: ['kill-pane', '-t', handle.pane],
env
)
if (!killed.ok) {
logger.warn('Could not close the tmux run window', { error: killed.stderr.trim() })
logger.warn('Could not close the tmux run pane', { error: killed.stderr.trim() })
}
}
+8
View File
@@ -28,6 +28,7 @@ import type {
BrowserToolbarCommand,
DesktopAppearanceTheme,
DesktopCommand,
DesktopExecutorDevice,
DesktopLocalFileRequest,
DesktopLocalFileResponse,
DesktopNotificationPayload,
@@ -156,6 +157,7 @@ const api: SimDesktopApi = {
ipcRenderer.invoke('desktop:local-filesystem', request),
localFiles: (request: DesktopLocalFileRequest): Promise<DesktopLocalFileResponse> =>
ipcRenderer.invoke('desktop:local-files', request),
localReadClaims: true,
onCommand: (callback: (command: DesktopCommand) => void): (() => void) => {
const listener = (_event: unknown, command: DesktopCommand) => callback(command)
ipcRenderer.on('desktop:command', listener)
@@ -177,6 +179,8 @@ const api: SimDesktopApi = {
getPreferences: (): Promise<DesktopPreferences> => ipcRenderer.invoke('desktop:settings:get'),
setPreference: (key: DesktopPreferenceKey, value: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set', key, value),
setPreventSleepWhileRunning: (enabled: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set-prevent-sleep', enabled),
setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set-browser-search-suggestions', enabled),
notify: (payload: DesktopNotificationPayload): Promise<boolean> =>
@@ -192,6 +196,10 @@ const api: SimDesktopApi = {
setTerminalDefaultZoom: (zoom: DesktopZoomPercent): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set-terminal-default-zoom', zoom),
},
desktopExecutor: {
getDevice: (): Promise<DesktopExecutorDevice | null> =>
ipcRenderer.invoke('desktop-executor:get-device'),
},
updates: {
getState: (): Promise<DesktopUpdateState> => ipcRenderer.invoke('desktop:updates:get-state'),
check: (): void => {
+5
View File
@@ -99,6 +99,10 @@ export const net = {
fetch: vi.fn(),
}
export const powerMonitor = {
on: vi.fn(),
}
export const session = {
fromPartition: vi.fn(),
}
@@ -393,6 +397,7 @@ export const electronMock = {
screen,
Menu,
net,
powerMonitor,
session,
protocol,
ipcMain,
+4
View File
@@ -41,3 +41,7 @@ next-env.d.ts
# fumadocs generates .source dirs anywhere a source.config sits
**/.source/
# Build-generated documentation asset
/public/llms-full.txt
/public/.llms-full-*/
+2 -6
View File
@@ -2,8 +2,6 @@ import type { CSSProperties } from 'react'
import { ImageResponse } from 'next/og'
import type { NextRequest } from 'next/server'
export const runtime = 'edge'
const TITLE_FONT_SIZE = {
large: 110,
medium: 96,
@@ -136,10 +134,8 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
* Loads Söhne Kräftig (weight 500), the typeface used on the reference cover
* template this OG image matches. Converted to a plain TTF from the
* last-shipped `soehne-kraftig.woff2` since Satori (`next/og`'s renderer)
* can't parse WOFF2 or variable fonts. Fetched over HTTP since the edge
* runtime has no filesystem access — served from `/static/fonts/` (not
* `/fonts/`) so it isn't intercepted by the site's proxy (`proxy.ts`),
* whose matcher excludes `static` but not `fonts`.
* can't parse WOFF2 or variable fonts. Fetched over HTTP for Next's revalidation
* cache, from `/static/fonts/` so the site's proxy matcher excludes it.
*/
async function loadTitleFont(baseUrl: string): Promise<ArrayBuffer> {
const response = await fetch(new URL('/static/fonts/Soehne-Kraftig.ttf', baseUrl), {
-27
View File
@@ -1,27 +0,0 @@
import { createLogger } from '@sim/logger'
import { getLLMText } from '@/lib/llms'
import { source } from '@/lib/source'
const logger = createLogger('DocsLlmsFullText')
export const revalidate = false
export async function GET() {
try {
const pages = source.getPages().filter((page) => Boolean(page?.data && page.url))
const scan = pages.map((page) => getLLMText(page))
const scanned = await Promise.all(scan)
const filtered = scanned.filter((text) => text && text.length > 0)
return new Response(filtered.join('\n\n---\n\n'), {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
})
} catch (error) {
logger.error('Error generating LLM full text:', error)
return new Response('Error generating full documentation text', { status: 500 })
}
}
+34 -2
View File
@@ -2811,7 +2811,7 @@ export function BufferIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 86.7 97.9'
viewBox='0 0 67 67'
fill='currentColor'
role='img'
xmlns='http://www.w3.org/2000/svg'
@@ -2819,7 +2819,7 @@ export function BufferIcon(props: SVGProps<SVGSVGElement>) {
<path
fillRule='evenodd'
clipRule='evenodd'
d='M0,22.3L43.1,0l43.6,22.3L43.1,44.8L0,22.3z M43.1,83.1l-29.4-16L0,74.5l43.1,23.4l43.6-23.4l-13.9-7.4 L43.1,83.1z M13.7,42l29.4,14.5L72.9,42l13.9,6.8L43.1,69.9L0,48.7L13.7,42z'
d='M18.009,43.225l15.178,7.817l15.378,-7.817l7.164,3.639l-22.542,11.469l-22.25,-11.469l7.072,-3.639Z M18.017,29.688l15.17,7.812l15.37,-7.812l7.172,3.645l-22.542,11.459l-22.25,-11.459l7.08,-3.645Z M33.187,8.333l-22.25,11.459l22.25,11.458l22.542,-11.458l-22.542,-11.459Z'
/>
</svg>
)
@@ -3560,6 +3560,30 @@ export function MicrosoftIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function IntuneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect x='3' y='3' width='18' height='13' rx='2' stroke='currentColor' strokeWidth='2' />
<path
d='M8 21h8M12 16v5m-4-12 3 3 5-5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
export function RampIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect x='2' y='4' width='20' height='16' rx='3' stroke='currentColor' strokeWidth='2' />
<path d='M2 9h20M6 15h4' stroke='currentColor' strokeWidth='2' strokeLinecap='round' />
</svg>
)
}
export function MicrosoftCopilotIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gid = (name: string) => `mscopilot_${name}_${id}`
@@ -10124,3 +10148,11 @@ export function YouComIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function PlaneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 85 52' fill='currentColor' {...props}>
<path d='M44.322 2.926C44.322 0.755 46.608 -0.658 48.551 0.313L80.455 16.265C82.929 17.502 84.492 20.032 84.492 22.799V48.249C84.492 50.42 82.207 51.833 80.265 50.862L62.328 41.893V22.797C62.328 20.031 60.765 17.502 58.291 16.264L44.322 9.28V2.926ZM0 2.925C0 0.754 2.286 -0.659 4.229 0.312L22.158 9.277V28.377C22.158 31.143 23.721 33.672 26.195 34.909L40.17 41.895V48.249C40.17 50.42 37.885 51.832 35.942 50.861L4.037 34.909C1.563 33.672 0 31.143 0 28.377V2.925ZM22.158 2.925C22.158 0.754 24.444 -0.659 26.387 0.312L44.322 9.28V28.378C44.322 31.144 45.886 33.673 48.36 34.91L62.328 41.893V48.249C62.328 50.42 60.043 51.832 58.101 50.861L40.17 41.895V22.797C40.17 20.031 38.607 17.502 36.133 16.264L22.158 9.277V2.925Z' />
</svg>
)
}
+6
View File
@@ -148,6 +148,7 @@ import {
InfisicalIcon,
InstantlyIcon,
IntercomIcon,
IntuneIcon,
JinaAIIcon,
JiraIcon,
JiraServiceManagementIcon,
@@ -212,6 +213,7 @@ import {
PineconeIcon,
PipedriveIcon,
PitchBookIcon,
PlaneIcon,
PlanetScaleIcon,
PolymarketIcon,
PostgresIcon,
@@ -226,6 +228,7 @@ import {
QuiverIcon,
RabbitmqIcon,
RailwayIcon,
RampIcon,
RB2BIcon,
RDSIcon,
RedditIcon,
@@ -478,6 +481,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
instantly: InstantlyIcon,
intercom: IntercomIcon,
intercom_v2: IntercomIcon,
intune: IntuneIcon,
jina: JinaAIIcon,
jira: JiraIcon,
jira_service_management: JiraServiceManagementIcon,
@@ -558,6 +562,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
pitchbook: PitchBookIcon,
plane: PlaneIcon,
planetscale: PlanetScaleIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
@@ -574,6 +579,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
quiver_v2: QuiverIcon,
rabbitmq: RabbitmqIcon,
railway: RailwayIcon,
ramp: RampIcon,
rb2b: RB2BIcon,
rds: RDSIcon,
reddit: RedditIcon,
@@ -37,6 +37,94 @@ Disconnect Credential (OAuth login or personal API key required)
</CommandTable>
## List credential members
```bash
sim credentials members list <credentialId> [options]
```
List Credential Members (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `credentialId` | Yes | Credential whose sharing grants are managed. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `0`. |
| `--sort-by <value>` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
</CommandTable>
## Remove credential member
```bash
sim credentials members remove <credentialId> <userId> [options]
```
Remove Credential Member (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `credentialId` | Yes | Credential whose sharing grants are managed. |
| `userId` | Yes | User whose explicit grant will be revoked. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
## Upsert credential member
```bash
sim credentials members upsert <credentialId> [options]
```
Upsert Credential Member (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `credentialId` | Yes | Credential whose sharing grants are managed. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--user <value>` | Yes | Existing workspace member to grant or change access for. |
| `--role <value>` | Yes | Credential role to grant; workspace administrators cannot be demoted. Accepted values: `admin`, `member`. |
</CommandTable>
## List credential providers
```bash
+1 -1
View File
@@ -31,7 +31,7 @@ sim logs get <runId> [options]
| Option | Required | Description |
| --- | --- | --- |
| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged. |
| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true; Sim’s in-app agent leaves it out unless this flag is passed). Set false to omit block configuration from a log read. Other run fields are unchanged. |
| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
@@ -7,6 +7,103 @@ import { CommandTable } from '@/components/ui/command-table'
Every command below also accepts the [global options](/cli/commands#global-options).
## Add organization domain
```bash
sim organizations domains add [options]
```
Add Organization Domain (OAuth login or personal API key required)
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `--domain <value>` | Yes | Domain to claim and verify through a DNS TXT record. |
</CommandTable>
## List organization domains
```bash
sim organizations domains list [options]
```
List Organization Domains (OAuth login or personal API key required)
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `0`. |
| `--sort-by <value>` | No | Field used to sort the result. Accepted values: `domain`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
</CommandTable>
## Remove organization domain
```bash
sim organizations domains remove <domainId> [options]
```
Remove Organization Domain (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `domainId` | Yes | Domain claim owned by this organization. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
## Verify organization domain
```bash
sim organizations domains verify <domainId> [options]
```
Verify Organization Domain (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `domainId` | Yes | Domain claim owned by this organization. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
</CommandTable>
## Cancel organization access request
```bash
@@ -386,6 +483,195 @@ Revoke Organization Invitation (OAuth login or personal API key required)
</CommandTable>
## Delete SSO provider
```bash
sim organizations sso providers delete <providerId> [options]
```
Delete SSO Provider (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `providerId` | Yes | Identity provider identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `-y, --yes` | Yes | Confirm this operation. |
</CommandTable>
## Get SSO provider
```bash
sim organizations sso providers get <providerId> [options]
```
Get SSO Provider (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `providerId` | Yes | Identity provider identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
</CommandTable>
## List SSO providers
```bash
sim organizations sso providers list [options]
```
List SSO Providers (OAuth login or personal API key required)
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `0`. |
| `--sort-by <value>` | No | Field used to sort the result. Accepted values: `providerId`, `domain`. |
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
</CommandTable>
## Save SSO provider
```bash
sim organizations sso providers save [options]
```
Save SSO Provider (OAuth login or personal API key required)
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `--provider-type <value>` | Yes | oidc: Configure an OpenID Connect identity provider. saml: Configure a SAML identity provider. Accepted values: `oidc`, `saml`. |
| `--provider-id <value>` | Yes | Globally unique provider ID; saving an existing provider replaces its supplied configuration. |
| `--issuer <value>` | Yes | Identity provider issuer URL. |
| `--domain <value>` | Yes | Email domain already verified by this organization. |
| `--jit-provisioning-enabled` | No | Allow SSO sign-in to provision organization membership, subject to eligibility and available seats. |
| `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. |
| `--mapping <json\|@file>` | No | Identity-provider claims mapped to user fields. (JSON, or @path / @- to read a file or stdin). |
| `--client-id <value>` | No | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. |
| `--client-secret <value\|@file>` | No | Write-only OIDC client secret; the redacted marker from providers get preserves an existing secret. Passing it inline exposes it to shell history and process listings. Required when --provider-type is oidc (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @). |
| `--scopes <json\|@file>` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. (JSON, or @path / @- to read a file or stdin). |
| `--pkce` | No | Use PKCE for the authorization flow. Available when providerType is oidc. |
| `--no-pkce` | No | Send --pkce as false. |
| `--authorization-endpoint <value>` | No | Optional authorization endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. |
| `--token-endpoint <value>` | No | Optional token endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. |
| `--user-info-endpoint <value>` | No | Optional UserInfo endpoint. Available when providerType is oidc. |
| `--skip-user-info-endpoint` | No | Read identity claims from the ID token instead of calling UserInfo. Available when providerType is oidc. |
| `--no-skip-user-info-endpoint` | No | Send --skip-user-info-endpoint as false. |
| `--jwks-endpoint <value>` | No | Optional signing-key endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. |
| `--entry-point <value>` | No | Identity provider SAML sign-in endpoint. Available when providerType is saml. Required when providerType is saml. |
| `--cert <value>` | No | Identity provider signing certificate. Available when providerType is saml. Required when providerType is saml. |
| `--callback-url <value>` | No | SAML callback URL; defaults to this provider’s Sim callback. Available when providerType is saml. |
| `--audience <value>` | No | SAML audience; omission preserves the saved value. Available when providerType is saml. |
| `--want-assertions-signed` | No | Require signed assertions; omission preserves the saved value. Available when providerType is saml. |
| `--no-want-assertions-signed` | No | Send --want-assertions-signed as false. |
| `--signature-algorithm <value>` | No | Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml. |
| `--digest-algorithm <value>` | No | Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml. |
| `--identifier-format <value>` | No | SAML NameID format; omission clears the saved value. Available when providerType is saml. |
| `--idp-metadata <value>` | No | Identity provider metadata XML; omission clears the saved document. Available when providerType is saml. |
</CommandTable>
## Set primary SSO provider
```bash
sim organizations sso providers primary <providerId> [options]
```
Set Primary SSO Provider (OAuth login or personal API key required)
**Arguments**
<CommandTable>
| Argument | Required | Description |
| --- | --- | --- |
| `providerId` | Yes | Identity provider identifier. |
</CommandTable>
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
</CommandTable>
## Get SSO policy
```bash
sim organizations sso policy get [options]
```
Get SSO Policy (OAuth login or personal API key required)
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
</CommandTable>
## Update SSO policy
```bash
sim organizations sso policy update [options]
```
Update SSO Policy (OAuth login or personal API key required)
**Options**
<CommandTable>
| Option | Required | Description |
| --- | --- | --- |
| `--organization <value>` | Yes | Organization identifier. |
| `--require-sso <true\|false>` | Yes | Require organization SSO on future sign-ins; existing sessions remain active. Accepted values: `true`, `false`. |
</CommandTable>
## Get organization
```bash

Some files were not shown because too many files have changed in this diff Show More