Skip to content

Commit 6c2cb92

Browse files
authored
v0.8.8: mothership improvements, new connectors, knowledgebase hardening, v2 extension
2 parents ceda457 + 63569a2 commit 6c2cb92

1,032 files changed

Lines changed: 178021 additions & 12355 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/sim-caching.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
paths:
3+
- "apps/sim/lib/**/*.ts"
4+
- "apps/sim/providers/**/*.ts"
5+
- "apps/sim/executor/**/*.ts"
6+
- "apps/sim/tools/**/*.ts"
7+
---
8+
9+
# In-Process Caching
10+
11+
**Never hand-roll TTL arithmetic.** `lru-cache` is a direct dependency of `apps/sim` and owns
12+
expiry, the ceiling, and — through `fetchMethod` — request coalescing. A
13+
`Map` plus `Date.now() - entry.fetchedAt < TTL` re-implements all three, badly.
14+
15+
## First decide whether it is a cache at all
16+
17+
Most module-level `Map`s in this codebase are **not** caches, and forcing them into one is worse
18+
than leaving them alone.
19+
20+
| Shape | Key dies when | Right tool |
21+
| --- | --- | --- |
22+
| **Lifecycle map**`activeStreams`, `pendingChildRuns`, `memoryStreams`, `handlerRegistry` | the tracked thing ends, and the code deletes it there | plain `Map`. No TTL, no ceiling. |
23+
| **TTL cache** — a remote read keyed by tenant (org id, user id, workspace id) | time passes | `LRUCache` |
24+
25+
A lifecycle map's key space is unbounded and that is fine, because every key has a defined death.
26+
Adding a TTL to one introduces an expiry that races the lifecycle. Adding a ceiling silently drops
27+
live state.
28+
29+
## TTL caches: always set `max`
30+
31+
```ts
32+
const policyCache = new LRUCache<string, ResolvedSessionPolicy>({
33+
max: 20_000,
34+
ttl: SESSION_POLICY_CACHE_TTL_MS,
35+
})
36+
```
37+
38+
`ttl` alone does **not** bound memory. Without `ttlAutopurge` (itself expensive — one timer per
39+
entry) an expired entry lingers until something touches its key or the ceiling evicts it. `max` is
40+
what actually caps the process, which is why a tenant-keyed `Map` grew for the life of the process
41+
before this rule existed.
42+
43+
**The ceiling is a memory backstop, not an operating limit.** Exceeding it makes the LRU evict
44+
*inside* the TTL, so each miss becomes one more read — never a wrong answer, it degrades to exactly
45+
the pre-cache behavior, but it is a hit-rate cliff on whatever path the cache sits on. Entries are
46+
tens of bytes, so set the cap far above any plausible per-instance working set within the TTL
47+
window and let it stay a backstop.
48+
49+
**Reads test `!== undefined`, not truthiness**, whenever the value can be `false`, `0`, or `null`.
50+
`if (cached)` on a cached `false` re-queries on every single call, for exactly the tenants the
51+
cache exists to protect.
52+
53+
## Async read-through: prefer `fetchMethod`
54+
55+
`fetchMethod` + `cache.fetch(key)` gives TTL, coalescing (concurrent callers share one promise),
56+
and eviction-on-rejection (`noDeleteOnFetchRejection` defaults to `false`) in one primitive. Reach
57+
for it before composing anything yourself.
58+
59+
**The one reason to compose instead: a hung producer.** `fetchMethod` has no settle deadline, and
60+
the app pool sets no `statement_timeout` (`packages/db/db.ts` sets only `connect_timeout` /
61+
`idle_timeout`, neither of which bounds a query already in flight). Where a wedged read would hold
62+
every caller for the whole TTL, wrap `coalesceLocally` from `@/lib/concurrency/singleflight` around
63+
a read-through `LRUCache` instead — it evicts and rejects at its deadline. See
64+
`lib/api-key/byok-entitlement.ts`, and `lib/oauth/credential-service.ts` for the same shape.
65+
66+
Do **not** build a house wrapper over `lru-cache`. Call sites differ in ways a thin helper cannot
67+
hold (synchronous memoization with `updateAgeOnGet` in `providers/client-cache.ts`, per-entry TTLs
68+
in `lib/auth/security-policy.ts`), so a wrapper covering the common case just adds a fourth pattern.
69+
70+
## Cache the gate, never the credential
71+
72+
Entitlements, plans, and policies tolerate bounded staleness **in the safe direction** — a lapsed
73+
organization keeping its own provider key for another minute costs a little metering and charges
74+
nobody wrongly. Key material does not: revocation has to be immediate, so
75+
`getBYOKKey` reads key rows fresh on every call and caches only the entitlement around them.
76+
77+
An outage must not be cached as a negative answer. A resolver that maps a failed read to `false`
78+
makes an outage indistinguishable from a real lapse, so give it an `onError: 'throw'` option and
79+
write the cache only on the success path — see `resolveOrganizationPlan`.
80+
81+
**Where a human is waiting, read fresh.** Keep two entry points rather than one cached function:
82+
the settings surfaces and management use cases must not tell an organization that just upgraded
83+
that it still lacks a plan, while the execution path underneath can serve from cache
84+
(`isOrganizationBYOKEntitled` vs `isOrganizationBYOKEntitledCached`).
85+
86+
## React `cache()` does nothing in a worker
87+
88+
`cache()` is request-scoped. Workflows run in Trigger.dev workers, which have no React request
89+
scope, so a `cache()`-wrapped gate that looks free on a settings page is uncached and per-block on
90+
the execution path. Anything reached from the executor needs a real cache — see
91+
`.claude/rules/sim-architecture.md`'s app/worker runtime boundary.
92+
93+
## Invalidation
94+
95+
Add a per-key invalidator only when the code that mutates the value runs in the **same process**
96+
that reads it. `invalidateSessionPolicyCache` works because the route writing the policy is the one
97+
serving the reads. An entitlement change arriving on a Stripe webhook lands in one process while
98+
the readers are per-worker, so an invalidator there would imply an immediacy it cannot deliver —
99+
the TTL is the real mechanism, and the absence of an invalidator should say so.

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ jobs:
123123
- name: Repo audits
124124
run: bun run check:audits
125125

126+
- name: Verify docs manifest is in sync
127+
run: bun run docs-manifest:check
128+
126129
- name: Migration safety (zero-downtime) audit
127130
run: |
128131
if [ "${{ github.event_name }}" = "pull_request" ]; then

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,22 @@ describe('my route', () => {
472472

473473
Use `@sim/testing` mocks/factories over local test data.
474474

475+
## Caching
476+
477+
Never hand-roll TTL arithmetic — a `Map` plus `Date.now() - fetchedAt < TTL` re-implements expiry,
478+
the ceiling, and coalescing badly. Use `lru-cache` (a direct dependency), and always set `max`:
479+
`ttl` alone does not bound memory, so a tenant-keyed cache without a ceiling grows for the life of
480+
the process. Prefer `fetchMethod` + `cache.fetch(key)` for async read-through — it gives TTL,
481+
coalescing, and eviction-on-rejection in one primitive — and compose `coalesceLocally` around an
482+
`LRUCache` only when a hung producer would otherwise wedge callers for the whole TTL.
483+
484+
First check the thing is a cache at all: a lifecycle map whose entry is deleted when the tracked
485+
thing ends (`activeStreams`, `pendingChildRuns`) is a plain `Map`, and giving it a TTL or a ceiling
486+
introduces an expiry that races the lifecycle. Cache the gate, never the credential — entitlements
487+
tolerate bounded staleness in the safe direction, key material must stay fresh so revocation is
488+
immediate. Full decision tree, sizing, `!== undefined` reads, and the invalidation rule are in
489+
`.claude/rules/sim-caching.md`.
490+
475491
## Utils Rules
476492

477493
- Never create `utils.ts` for single consumer - inline it

apps/desktop/build/entitlements.mac.plist

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,15 @@
99
even after the user grants access in System Settings. -->
1010
<key>com.apple.security.device.audio-input</key>
1111
<true/>
12+
<!-- The agent browser joins real meetings (Google Meet, Zoom web): its
13+
getUserMedia grant is gated on the OS grant, and without this key the
14+
Hardened Runtime denies the camera no matter what the user allowed. -->
15+
<key>com.apple.security.device.camera</key>
16+
<true/>
17+
<!-- WebAuthn hybrid transport (passkey on the user's phone via QR) rides
18+
Bluetooth proximity; without this the QR option silently never
19+
completes in signed builds. -->
20+
<key>com.apple.security.device.bluetooth</key>
21+
<true/>
1222
</dict>
1323
</plist>

apps/desktop/electron-builder.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ mac:
5858
# macOS refuses to show the microphone prompt at all — it kills the process —
5959
# unless the bundle declares why it wants the device.
6060
extendInfo:
61-
NSMicrophoneUsageDescription: Sim uses your microphone for voice input in Chat.
61+
NSMicrophoneUsageDescription: Sim uses your microphone for voice input in Chat and for meetings you join in the built-in browser.
62+
NSCameraUsageDescription: Sim uses your camera for meetings you join in the built-in browser, such as Google Meet.
63+
NSBluetoothAlwaysUsageDescription: Sim uses Bluetooth to complete passkey sign-ins with a nearby phone in the built-in browser.
6264
entitlements: build/entitlements.mac.plist
6365
entitlementsInherit: build/entitlements.mac.plist
6466
notarize: true

apps/desktop/src/main/browser-agent/cdp.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
22

33
vi.mock('electron', () => import('@/test/electron-mock'))
44

5-
import { WebContentsView, type WebFrameMain } from 'electron'
5+
import { nativeImage, type WebContents, WebContentsView, type WebFrameMain } from 'electron'
66
import {
7+
captureScreenshot,
78
clickAt,
89
ensureInstrumented,
910
evaluateInIsolatedFrame,
@@ -482,3 +483,89 @@ describe('browser-agent CDP theme', () => {
482483
})
483484
})
484485
})
486+
487+
/**
488+
* The browser panel shows a LIVE view, so a capture must not perturb the page.
489+
* Chromium serves `clip` by applying device-emulation params to the widget and
490+
* syncing visual properties, which the user sees as the page rescaling and
491+
* snapping back. Resolution is bounded on the returned image instead.
492+
*/
493+
describe('browser-agent screenshot capture', () => {
494+
function captureFixture(imageSize: { width: number; height: number } | null) {
495+
const contents = new WebContentsView().webContents
496+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
497+
if (method === 'Page.getLayoutMetrics') {
498+
return Promise.resolve({ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
499+
}
500+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
501+
return Promise.resolve(undefined)
502+
})
503+
const resized = {
504+
toJPEG: vi.fn(() => Buffer.from('resized')),
505+
}
506+
// Shared module-level mock: without this, a later fixture reads the
507+
// earlier test's decoded image.
508+
vi.mocked(nativeImage.createFromBuffer).mockReset()
509+
vi.mocked(nativeImage.createFromBuffer).mockReturnValue({
510+
isEmpty: vi.fn(() => imageSize === null),
511+
getSize: vi.fn(() => imageSize ?? { width: 0, height: 0 }),
512+
resize: vi.fn(() => resized),
513+
toJPEG: vi.fn(() => Buffer.alloc(0)),
514+
} as unknown as ReturnType<typeof nativeImage.createFromBuffer>)
515+
return { contents, resized }
516+
}
517+
518+
function screenshotParams(contents: WebContents): Record<string, unknown> {
519+
const call = vi
520+
.mocked(contents.debugger.sendCommand)
521+
.mock.calls.find(([method]) => method === 'Page.captureScreenshot')
522+
if (!call) throw new Error('no capture was requested')
523+
return call[1] as Record<string, unknown>
524+
}
525+
526+
it('never sends a clip, which would emulate the live page for the capture', async () => {
527+
const { contents } = captureFixture({ width: 4096, height: 2048 })
528+
529+
await captureScreenshot(contents)
530+
531+
expect(screenshotParams(contents)).not.toHaveProperty('clip')
532+
})
533+
534+
/**
535+
* A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture
536+
* arrives at device resolution (4096px on a 2x display). The resize is what
537+
* lands the image on the CSS-relative size the coordinate contract
538+
* (cssX = imageX / scale) assumes.
539+
*/
540+
it('downscales the returned image to the CSS-relative size', async () => {
541+
const { contents, resized } = captureFixture({ width: 4096, height: 2048 })
542+
543+
const shot = await captureScreenshot(contents)
544+
545+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
546+
expect(image.resize).toHaveBeenCalledWith({ width: 1024, height: 512, quality: 'good' })
547+
expect(resized.toJPEG).toHaveBeenCalled()
548+
expect(shot).toEqual({
549+
dataUrl: `data:image/jpeg;base64,${Buffer.from('resized').toString('base64')}`,
550+
scale: 0.5,
551+
})
552+
})
553+
554+
it('skips the re-encode when the capture already matches the target size', async () => {
555+
const { contents } = captureFixture({ width: 1024, height: 512 })
556+
557+
const shot = await captureScreenshot(contents)
558+
559+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
560+
expect(image.resize).not.toHaveBeenCalled()
561+
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
562+
})
563+
564+
it('returns the raw capture when the image cannot be decoded', async () => {
565+
const { contents } = captureFixture(null)
566+
567+
const shot = await captureScreenshot(contents)
568+
569+
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
570+
})
571+
})

0 commit comments

Comments
 (0)