Commit Graph

6402 Commits

Author SHA1 Message Date
Theodore Li a781a325c5 fix(invitations): break outbox import cycle (#6969) 2026-08-21 23:14:34 -04:00
Waleed 8177f9abeb fix(tabs): restore the resource header's spacing and clear the scroll fades (#6967)
Porting the tabs onto the shared strip had taken the header from 43px to 34px,
which moved the overlaid collapse toggle from 6.5px below the panel's top edge
to 2px while its right inset stayed at 16px — the corner read lopsided. The
header goes to 40px: still shorter than it was, with the toggle back to 5px.
The toggle also gets its 8px radius back, dropped in that port for no reason
anyone asked for.

Selecting a partly-hidden tab scrolled it flush against the container edge,
which is exactly where the fade gradient sits, so it arrived half-faded and
still looked cut off. Reveal now insets by the fade width and clamps at the
scroll extremes, where no gradient is drawn.

Floating tabs cap at 160px so no single tab dominates the row.
2026-08-21 19:58:37 -07:00
Theodore Li 818f7141e0 fix(tables): disable dispatcher task retries (#6963) 2026-08-21 22:40:49 -04:00
Justin Blumencranz b7073a4dd4 feat(tables): return only selected columns from the Table block query (#6954)
* feat(tables): return only selected columns from the Table block query

* fix(tables): size row batches by stored bytes and surface stale picks on empty lists

* fix(tables): bound projected batches by the widest stored row seen
2026-08-21 19:33:18 -07:00
Justin Blumencranz 3d40003482 improvement(tables): backfill default views for existing tables (#6899) 2026-08-21 19:25:58 -07:00
Bill Leoutsakos afccfe98a2 fix(selectors): respect trigger credentials in trigger mode (#6952)
* fix(selectors): respect trigger credentials in trigger mode

* fix(selectors): isolate trigger selector context

* fix(selectors): isolate action credential fallback

* fix(selectors): scope fork reconfigs to trigger mode

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-08-21 19:10:28 -07:00
Waleed c16883e9dc improvement(tabs): give the resource tabs a quieter floating look (#6960)
* improvement(tabs): give the resource tabs a quieter floating look

Adds a `floating` variant to the shared TabStrip and uses it for the mothership
resource tabs. Only the active tab carries a shape; the rest are bare labels
divided by a hairline, sized to their content up to a cap. The browser and
terminal strips keep the attached look, which stays the default.

* improvement(tabs): align the floating tab ramp and icon size with the platform

Each surface token now does the job it is named for: a bare tab hovers to
--surface-hover instead of the Button variant's --surface-4, and a selected tab
takes the rung between hover and --surface-active. Tab icons match the action
icons beside them at 16px.
2026-08-21 19:01:37 -07:00
Vikhyath Mondreti 71129cd112 feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces (#6950)
* feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces

Joining a custom block's child run into its caller's trace shipped on by default,
gated at read time by whether the person reading could already open the source
workspace. That gate is doing the wrong job: a custom block's whole point is that
consumers need no access to the source, so the check refuses exactly the readers
the feature exists for, and it makes the answer depend on who is looking rather
than on what the block's owner agreed to publish.

The decision moves to the party whose data it is. `custom_block.trace_child_runs`
is set by the publisher in Settings, applies org-wide, and is the entire policy —
nothing downstream re-checks a caller. `getCustomBlockAuthority` already resolves
per invocation and is the one lookup both the canvas handler and the Agent-tool
runner pass through, so one column covers both surfaces and no consumer input can
assert it.

It defaults to FALSE. With the viewer check gone, an opted-in block publishes the
source workflow's block names, inputs, outputs, and prompts to anyone who can read
a consuming workflow's log. That is the same boundary curated outputs and redacted
errors hold, so it opens by an affirmative act of the publisher or not at all —
never as the residue of a column default on rows nobody revisited.

Closed means the handle is withheld outright rather than persisted behind a flag:
with no `childExecutionId` there is nothing for a reader, a migration, or a later
refactor to join. What replaces it is a `_childTraceDisabled` marker, because a
boundary span with no children renders exactly like a leaf block and an untraced
run would otherwise read as one that did nothing. The consumer-facing failure
`ref` is untouched either way — it is the only thing that makes an untraced
failure reportable.

Custom blocks invoked as Agent tools now join too. The child's handle already
reached the agent's persisted `toolCalls[].result` (`postProcessToolOutput` strips
only `__`-prefixed keys); nothing lifted it onto the tool span. Both span builders
lift and strip it, and `hydrateChildTraces` needs no change — its boundary walk
already recurses. The same handle is stripped from the model-facing copy of the
tool result in `executeProviderTool`, the single point where the raw and model
copies diverge: an opaque execution id in a tool result reads to a model like data
the tool returned.

The live SSE stream keeps one condition beyond the policy: an identified consumer.
Not an authorization check — no workspace query — but chat deployments and the
public API leave `liveTraceViewerUserId` unset because their consumer may be
anonymous, and opting into org-wide tracing is not consent to stream a publisher's
raw agent tokens to the internet.

Copilot deliberately cannot set the field; exposing a team's internals org-wide is
a human decision, not one an agent makes while publishing on their behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(custom-blocks): read the publisher's trace policy at read time, not from the handle's presence

Treating a persisted `childExecutionId` as proof of publisher consent is only true
for handles this PR's writer produced. Every handle written before it meant
something else — "a child ran; authorize the reader" — and the rows carrying them
outlive the migration, so removing the reader check turned them into an open door:
a consumer could open an old parent log and receive the source workflow's block
names, inputs, outputs, and prompts from a block whose publisher never opted in.

`hydrateChildTraces` now resolves the policy live, per boundary, from
`custom_block.trace_child_runs`. The child log row's `workflowId` is the key —
publish enforces one block per workflow — which also covers an Agent-tool boundary,
whose span carries no block type to look up. A workflow with no block row (never
published, or since deleted) has no publisher left to consent and stays shut, as
does a failed policy read.

This is not redundant with the write-time withholding. The handler still emits no
handle for a block that was closed when the run executed, so such a run stays
closed forever even if the block is opened later; this check decides whether the
runs that DO carry a handle may still be shown. Turning the policy off therefore
also closes what is already recorded, which is what a governance switch has to do
to mean anything.

Reported by Greptile on #6950.

Also drops `any` from the trace-policy tests: outputs read through
`Record<string, unknown>` (the handler's declared return does not name these
internal keys) and failures narrow through `ChildWorkflowError.isChildWorkflowError`,
which pins the failure type as well as its fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(logs): sum the child-trace drop counters from the struct, not a hand-listed set

`totalDropped` re-listed four of the five counters, so a read whose only drops were
policy refusals computed zero and skipped the log entirely. That is the commonest
drop there is now — every handle written before the publisher policy existed refuses
at that gate — so the one signal telling an operator the live check is closing joins
went silent exactly when it started mattering.

Summed from the struct instead. A hand-maintained list beside a struct is stale the
moment a field is added, which is precisely how `policyClosed` was left out.

Reported by Cursor Bugbot on #6950.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(db): renumber the custom-block trace migration around a 0299 collision

Staging landed its own 0299 (`table_run_dispatches.heartbeat_at`) while this
branch was open. The two migrations are independent — different tables, no shared
statement — so only the number and drizzle's snapshot chain collided.

Regenerated rather than hand-merged: a drizzle snapshot is a full-schema dump
whose `prevId` links it to its parent, so editing one by hand to sit after a
migration it was not generated against is how the chain silently stops matching
the database. Staging's 0299 and its snapshot are taken verbatim; this is 0300,
generated against them, and its SQL is byte-identical to what it replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 18:21:08 -07:00
Vikhyath Mondreti fe0b92bb6d feat(secrets): show where a secret is referenced, beside its usage log (#6947)
* feat(secrets): show where a secret is referenced, beside its usage log

"See usage" answered who has run something with a key. It could not answer the
question a rotation actually starts from — where is this wired in — because a
secret four blocks depend on but nothing has executed yet has no usage rows at
all, so the panel read "This secret has not been used yet" for a live key.

The usage view now carries two tabs. Logs is the existing trail, unchanged and
still the default, since that is what the header action has always opened.
References is new: the blocks that name the secret as {{KEY}}, grouped under
their workflow, then the custom tools and MCP servers whose own bodies carry it.

Detection is the workspace-fork remapper's. remapSubBlocks already walks nested
tool-input params, resolves canonical basic/advanced pairs, and skips dormant
and condition-hidden members, so calling it per block inherits every rule a
fork already obeys. Only the aggregation is new: scanWorkflowReferences
collapses its output to unique (kind, sourceId) pairs and discards the workflow
— right for building a mapping table, wrong for locating a key. Nothing under
ee/workspace-forking changed.

- Candidates come from strpos(sub_blocks::text, name) > 0, deliberately not
  LIKE: `_` is a LIKE single-character wildcard and nearly every env key
  contains one, so SB_ACTION_ROUTER_SECRET would match text it does not occur
  in. The prefilter can over-match but never under-match; the scanner decides.
  The plan is an index scan on workflow by workspace, nested-looped into
  workflow_blocks, so cost tracks the workspace rather than the table.
- Scope gates the read but does not narrow it. A {{KEY}} names a key, not a
  scope, so the same sites answer for a workspace secret and the personal one it
  shadows; narrowing here would report a personal secret as unreferenced the
  moment a workspace variable of the same name existed.
- References reports one field per block, not a list. The remapper dedupes a
  block's references by (kind, sourceId), so a block naming the secret twice
  yields one entry — the type says so and a test pins it, because the row
  renders that field as its whole description.
- Reads live state, not deployed: a draft workflow referencing the key must
  show. Blocks are capped and the cap is reported as `truncated` rather than
  silently trimming the list.
- Authorization is the existing usage gate, renamed requireSecretTrailReadAccess
  and shared verbatim, so the two tabs can never disagree about who may look.

UI is existing primitives only — ChipModalTabs for the strip, DetailSection per
workflow over RESOURCE_LIST_STACK rows, IntegrationTile for the block glyph so a
block reads here as it does on an integrations row, SettingsEmptyState for the
gates. No new component, no new class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): close the reference-scan scope bypass and bound its output

Review round 1.

- use-cases.ts: `scope` was a caller-controlled assertion the reference scan never
  narrowed by, so `scope=personal` returned from the shared gate before any check
  and handed any workspace member the admin-gated reference map for any workspace
  secret. The usage trail can trust that scope because it filters the read by
  `secretOwnerUserId`; a name-based workspace-wide scan cannot. References now
  authorize on what the NAME resolves to — a workspace secret under that name is
  admin-gated outright, and absent one the caller must actually hold a personal
  secret of that name, which also stops a member enumerating arbitrary names.
  `scope` is dropped from the input, the contract, the hook and the query key
  rather than merely ignored: a parameter that does not exist cannot be asserted.
  The trail gate keeps its old name and a note saying why only a scope-narrowed
  read may reuse it.

- scan.ts: the prefilter matched the bare name, so `API_KEY` also read every block
  holding `{{API_KEY_TEST}}` or the words "the API_KEY value" — and those false
  positives counted against the row cap, so on a workspace with enough of them
  genuine references sorted later were never read at all. It now matches the
  reference syntax (`{{name}}`, with the whitespace ENV_REF_PATTERN allows), so a
  candidate is a real occurrence and the cap means what it says. A name outside the
  env-key charset short-circuits, which is also what makes it safe to inline into
  the regex unescaped. Verified against a real workspace: the exact key still
  returns its 16 blocks, its prefix now returns 0 where it previously matched all
  16, and a metacharacter name touches no query.

- scan.ts: capping tool and server ROWS did not bound the output — one MCP server
  emits an entry per matching header plus one for its url, so 200 rows could expand
  past the contract's 400-entry bound and make the route reject its own response,
  turning a successful scan into a 500 and the tab into "Could not load
  references." Emission now stops at the bound and reports `truncated`.

- secret-references-panel.tsx: the empty-state early return preceded the truncation
  banner, so a capped scan that filtered everything out claimed the secret was
  unreferenced. Both paths now share one note, and silence from a capped scan reads
  as absence of evidence rather than evidence of absence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): cover unicode whitespace, legacy keys, and the shadowed-personal tab

Review round 2 — all three follow from round 1's own fixes.

- scan.ts: the syntax prefilter anchored on `[[:space:]]`, but the two engines
  disagree about what whitespace is. `ENV_REF_PATTERN`'s `\s` accepts U+00A0,
  U+202F and U+3000; Postgres `[[:space:]]` matches only the ASCII set. So a value
  pasted with a non-breaking space inside the braces is a reference the executor
  resolves and the prefilter silently dropped — the one failure direction this
  feature must never take, since the answer it gives is "unused, safe to delete".
  Anchoring on `[^[:alnum:]_]` instead accepts every whitespace encoding while
  still rejecting a longer key on either side, and needs no code-point list that
  could drift. It can admit a non-reference like `{{-NAME-}}`; that costs one
  candidate row, and the scanner re-checks every candidate regardless. Erring loose
  here is deliberate. (Greptile's `{{\tAPI_KEY\t}}` example was already handled —
  tab is ASCII — but the unicode half of the finding was real.)

- use-cases.ts: the gate read `keyAccess.knownKeys` as "a workspace secret exists
  under this name", but that set only covers names with an `env_workspace`
  credential row. A legacy value written before the ACL existed has no row and
  still wins at run time, so it fell through to the personal branch and handed a
  non-admin the reference map for exactly the oldest keys. It now reads the
  authoritative `workspace_environment.variables` map through a new
  `hasWorkspaceEnvValue`, which is documented against `knownKeys` so the two are
  not confused again. `getWorkspaceEnvKeyAdminAccess` keeps its existing contract —
  its `knownKeys` still answers the ACL question its other callers ask.

- secret-references-panel.tsx: a personal secret shadowed by a same-named workspace
  variable could open the view (its owner may read their own Logs) but References
  always hit the workspace refusal and rendered a generic load error — a tab
  offered in a state where it cannot succeed. The refusal is correct; the tab now
  states the shadowing instead of asking for a map it will be denied, reusing the
  wording the detail page already shows. No request is made in that state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): re-check the reference gate's volatile input after the scan

Review round 3.

The name-resolution gate reads whether a workspace value exists, then scans. A
workspace secret created between the two makes the map now in hand admin-gated,
so a personal owner could receive it without workspace-secret administration.

The window is small and the data is derivable — a workspace member can already
open every workflow and read its `{{KEY}}` references — but the gate's stated
contract is that references follow the same predicate as revealing the value, and
a point-in-time check that can be overtaken does not honour that. An advisory lock
or a snapshot transaction would serialize a read-only view against secret writes
for it, which is the wrong trade.

Instead the one volatile input is re-read after the scan and the request fails
closed if it flipped. `requireSecretReferencesReadAccess` now reports which branch
authorized: an `admin` grant holds however the name resolves and pays nothing,
while a `personal` grant — the only one resting on absence — is re-checked. A
non-admin loses nothing they were entitled to keep; the request is refused the way
it would have been a moment later.

Adds a `listSecretReferencesUseCase` suite covering both denial paths, the legacy
value, the personal owner, the admin short-circuit, and the race itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): accept JSON-escaped whitespace in the reference prefilter

Review round 4.

The prefilter reads a `::text` rendering of a JSON column, and `jsonb::text`
renders a real tab inside a string value as the literal pair `\` `t`. `t` is
alphanumeric, so `[^[:alnum:]_]` could not consume it and the row was discarded
before `ENV_REF_PATTERN` ever ran — the References tab omitting a live reference
and reporting `truncated: false` while doing it.

Round 3's fix was verified against a raw text value rather than the JSON
rendering, which is exactly why it looked correct: `E'{{\tAPI_KEY\t}}'` matches,
`jsonb_build_object('v', E'{{\tAPI_KEY\t}}')::text` does not.

The gap between `{{` and the name now accepts three encodings at once — raw
characters (covering every Unicode space, which Postgres `[[:space:]]` misses),
JSON two-character escapes, and `\uXXXX` (how a vertical tab survives the same
rendering). Verified against the real jsonb rendering: tab, newline, carriage
return, vertical tab and form feed all recover, U+00A0 / U+3000 / space / plain
keep matching, and `{{API_KEY_TEST}}`, `{{MY_API_KEY}}` and prose are still
rejected — so the row cap keeps meaning what it says.

Plain-text columns (`custom_tools.code`, `mcp_servers.url`) carry no JSON
escaping, but tool code is JavaScript source and can contain the same escape
sequences literally, so the one predicate is right for every column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(secrets): land the References link on the block, and name its field

Feedback round.

- Logs leads the tab strip. It was already the default tab; the order now says so.

- The usage view drops its resource heading for a plain "Usage" title. The back
  chip already names the secret, so the tile and the subtitle underneath were
  saying it a second time. `CredentialDetailLayout` gains an optional `title`
  that renders the same element, class and column position the settings shell
  gives `SettingsPanel` — which is how the sibling Forks "Activity" view titles
  itself. Existing callers pass nothing and are unchanged.

- A block row now lands on the block instead of the workflow's default framing.
  The editor had no URL params at all, so `?block=` is its first: read once on
  arrival, acted on, and stripped. It is a navigation signal rather than canvas
  state — the carve-out in sim-url-state.md is about pan, zoom, selection and
  drag, which are socket-synced or high-frequency; this is neither, and it rides
  in the link so a middle-click or reload keeps it where an in-memory handoff
  could not.

  The consuming effect mirrors the note-search reveal in the same file, including
  the three details that make that one work: read from `displayNodes` so a target
  arriving before its node mounts is retried on the mounting commit, route
  selection through `resolveSelectionConflicts`, and latch in a ref. It also
  claims `userFocusedWorkflowIdRef` the way a node click does, because `onInit`
  re-reads that inside its own rAF and would otherwise `fitView` over the camera —
  and that ref is reset by exactly the `workflowIdParam` change a deep link causes.
  The panel opens for free: `syncPanelWithSelection` already follows selection.

  `useSearchParams` needs a Suspense boundary and the editor's ancestry has none,
  so the read lives in a leaf under its own `fallback={null}` rather than wrapping
  the editor and adding a `loading.tsx` to its mount path. `next build` passes.

- A tool-input reference showed `tools-tool-0-code`. Those
  `{subBlockId}-tool-{index}-{paramId}` keys are documented as an ephemeral,
  client-only projection of the canonical `tool-input` value and are not meant to
  be persisted, but older rows carry them — so the scanner reported whichever the
  record yielded last. They are dropped before scanning, which is right even where
  the two disagree: `tool.params` is what executes, so a mirror the canonical no
  longer matches describes a reference that no longer runs.

- The row now shows the field's label from the block config rather than its
  storage id — "API Key", "Tools", "Code", "Bot Token" — falling back to the id
  when the block or field is unregistered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): make the reference prefilter exactly as tight as the authority

Review round 6.

The gap between `{{` and the name accepted any non-word character, so
`{{-API_KEY-}}` and `{{"API_KEY"}}` matched in SQL while `ENV_REF_PATTERN`
rejects them. The previous commit called that free — "costs a candidate row and
nothing else" — which was wrong: a candidate row is a slot under
BLOCK_SCAN_LIMIT, so enough near-misses sorted earlier exhaust the cap before a
genuine reference is read, and the tab reports a live key as unused. That is the
same failure the tightening in round 1 was meant to remove, reintroduced by the
round 4 loosening that fixed JSON-escaped whitespace.

The gap now enumerates exactly the whitespace `\s` accepts, in each encoding it
can arrive in: `[[:space:]]` for raw ASCII, `\\[tnrf]` and `\\u000[bB]` for the
JSON escapes, and an explicit class for the Unicode spaces Postgres emits
verbatim but `[[:space:]]` does not match.

That class is generated from a code-point table rather than written literally.
Writing it by hand put a run of invisible characters in the source — a reviewer
cannot check them, and a formatter or editor can silently mangle them. The table
is the readable form and `toPgEscape` renders it.

Verified against the real jsonb rendering, 17 cases: raw space, tab, newline,
carriage return, vertical tab, form feed, U+00A0, U+202F, U+3000 and an embedded
reference all match; `{{-API_KEY-}}`, `{{"API_KEY"}}`, `{{API_KEY_TEST}}`,
`{{MY_API_KEY}}`, prose and an across-braces span all do not. Every candidate the
SQL admits is now a real occurrence, so the cap counts references and nothing
else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): cap the reference scan on results, not candidates

Review round 7.

The prefilter now matches reference syntax exactly, but `remapSubBlocks` filters
further on semantics SQL cannot see: it drops dormant canonical members and
condition-hidden fields. So a block whose only `{{KEY}}` sits in a hidden field is
a genuine candidate that yields nothing, and with the cap counting candidates,
enough of those sorted earlier displaced active references out of the answer.

Unlike the previous two rounds this is not fixable by tightening the prefilter —
no SQL predicate can evaluate canonical modes or field conditions. So the cap
moves to what it should have counted all along: blocks REPORTED. Candidates are
read a page at a time up to a ceiling far above the result limit, so filtered rows
are absorbed as extra reads instead of taking result slots.

Paging rather than one large read because the alternative is holding every
candidate block's `sub_blocks` in memory at once; peak memory is now one page.
`blockId` joins the ordering as a final tiebreak, since OFFSET paging over a
non-unique sort can repeat or skip rows across pages — which here would
double-report a block or silently lose one.

This does not make the scan unconditionally complete, and the ceiling says so:
bounded work and guaranteed completeness cannot both hold, so the only real
choice is where the bound sits and whether it counts something the reader can
see. It now counts results.

Query plan re-checked with the OFFSET in place: still an index scan on workflow by
workspace, nested-looped into workflow_blocks. Test added that pins the fix — 2,500
prose candidates sorted ahead of one real reference, which the previous cap
dropped entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): drop the paging that caused drift, and stop false truncation

Review round 8.

- Paging removed. It bought headroom and paid with drift: `OFFSET` is positional,
  so a block renamed, inserted or deleted between page queries shifts the result
  set, and the scan skips a live reference or reports one twice. That is a worse
  failure than the one paging was added to fix, and it was self-inflicted last
  round. Candidates are read in one statement again — one statement is one
  snapshot, so neither skew nor duplication is possible — with the ceiling
  lowered to 4,000 so a single read stays a sane amount of memory. Result-capping
  survives, which was the actual point: filtered rows are still absorbed as extra
  reads rather than taking result slots.

- `truncated` no longer fires on an exact landing. The block path now uses the
  limit-plus-one read and strict `>` the resource paths already used, so a scan
  that ends precisely on a bound reports complete instead of warning about
  references that were all returned.

- The deep-link target is released when its block does not exist. It was cleared
  only on a match, so a link to a since-deleted block left the id set with the
  param already stripped: the effect re-checked on every canvas update forever and
  shadowed a later link to the same block. Once any node has mounted the canvas is
  populated, so an id still absent is gone and the target is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(secrets): gate the deep-link release on the workflow being ready

Review round 9.

Round 8 released a deep-link target once `displayNodes` was non-empty, reading
that as "the canvas is populated, so a missing id is deleted". It is not: arriving
from another workflow the store still holds that graph, so nodes are present while
the linked workflow is still hydrating — and a valid `?block=` target was dropped
before its own blocks ever mounted.

The file already had the right predicate. `isWorkflowReady` pins
`hydration.phase === 'ready'`, `hydration.workflowId === workflowIdParam` and
`activeWorkflowId === workflowIdParam`, which is exactly "the graph now loaded is
this workflow's". Absence is only conclusive under that, and a node count never
was — it says something mounted, not whose.

This is the second fix to this release condition in two rounds, both from guessing
at a readiness signal instead of using the one the component already computes for
the same question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:30:17 -07:00
Waleed b440f4ec11 improvement(mship): preview recent runs in @ mention, with the logs icon (#6951)
The Logs family flooded the `@` picker with up to 50 near-identical rows,
named after their workflow and drawn with the workflow icon, so they read
as workflow snapshots and buried every other family.

- Preview the 5 most recent runs while the query is empty; typing still
  searches the full fetched set. The cap spans the workspace rather than
  one workflow, so a few background runs cannot evict a run just started
- Draw the row with the Logs icon, matching the sidebar, the search
  palette, and the chip the selection turns into
- Trail the row with relative time, and with the dot `Badge` draws at
  `sm` for a run that did not simply succeed, so runs of one workflow are
  told apart at a glance
- Make `@logs` reach the family, which nothing in a row's text names

Mentioning a log also resolved to nothing: a log row is keyed by `id` but
its run is addressed by `execution_id`, and the picker sent the former
where the server resolves the latter. The run id now rides on the
resource and every menu builds that resource through one helper, rather
than eight inline literals that each silently dropped it.
2026-08-21 16:30:07 -07:00
Waleed f0a0970d2f improvement(tabs): port resource tabs onto the shared TabStrip (#6953)
Replace the hand-rolled resource tab bar with the shared TabStrip primitive,
and generalize the strip where this caller needed more than it offered.
2026-08-21 16:29:47 -07:00
Justin Blumencranz cd935e8ea4 feat(workflows): generate short machine-nature names (#6906)
* feat(workflows): generate short machine-nature names

* fix(workflows): remove vehicle name terms

* feat(workflows): expand generated name vocabulary
2026-08-21 15:57:37 -07:00
Justin Blumencranz 2252ac0ee6 fix(workflows): deduplicate generated workflow names (#6935)
* fix(workflows): deduplicate generated workflow names

* fix(workflows): retry deduplicated name races (#6935)

- recompute generated names after workflow-name conflicts
- preserve exact-name and unrelated constraint behavior
- cover the concurrent-create retry path
2026-08-21 15:29:50 -07:00
Waleed 7b6c58113d fix(knowledge,tables): recover abandoned dispatches, bound the sweep and the workbook preview (#6945)
* fix(tables,knowledge): recover abandoned dispatches and bound the sweep

Three defects measured in production this afternoon.

A dispatcher killed by an OOM left `table_run_dispatches` at `dispatching`
forever. Every terminal transition on that table is user- or flow-initiated, so
nothing reclaimed the row: four dispatches were stranded in one afternoon,
pinning each table's "X running" overlay and blocking re-runs, with no way to
clear them from the product. The `table_run_dispatches_watchdog_idx` index has
existed for this sweep since the table was created, unused.

Liveness comes from a new `heartbeat_at`, stamped by the per-window writes that
already advance `cursor` and `processed_count`, so a slow-but-live dispatch is
spared however long it runs — the in-process path has no duration ceiling, so
ageing from `requested_at` would reclaim live self-hosted work. The sweep reads
`COALESCE(heartbeat_at, requested_at)` so rows written before the column stay
reclaimable rather than NULL-false forever, and runs as the last arm of the
existing stale-execution cron at the same 95-minute window its table-job sibling
uses. Rows are cancelled, not completed: the scope never finished.

The OOM itself is not a leak. Peak RSS is a flat plateau — 457 MB at 20-45s and
461 MB past 200s, so ten times the duration buys four megabytes — that has crept
about two percent per release for a month, from 446 MB in late July to 545 MB,
past the 512 MiB `small-1x` ceiling. CPU peaks at 0.19, so the larger preset is
bought for RAM alone. `maxAttempts` never covered the kill either: Trigger.dev
retries `TASK_PROCESS_OOM_KILLED` only when `retry.outOfMemory.machine` names a
preset, and all four runs recorded `attempt_count = 1` while the docstring
claimed they resumed from the persisted cursor.

The connector stuck-document sweep dispatched without a bound. Its chunk size
paced the loop but the candidate query had no limit, so one connector enqueued
2,959 documents in fifteen seconds onto the queue every workspace shares.
Nothing was double-billed — those documents were genuinely unindexed — but one
connector monopolized the queue, and each dispatch mints a fresh requestId, so
the idempotency key differs every pass and none of it deduplicates. Candidates
are now taken oldest-first and capped per sync; a deeper backlog is deferred to
the next sync rather than dropped.

* fix(file-parsers): read officeparser's entry point across module systems

`officeparser` is CommonJS — `main: officeParser.js`, no `type`, no `exports`
map — so what `await import('officeparser')` yields depends on who built the
code. Node and webpack synthesize named exports from `module.exports`, so
`.parseOfficeAsync` is there. esbuild, which builds the Trigger.dev worker
bundle, puts `module.exports` on `.default` and leaves the named export
undefined, and the package is in neither `build.external` nor
`additionalPackages`, so it is bundled.

Reading the named export directly therefore worked everywhere except the
worker, where calling it threw `TypeError: parseOfficeAsync is not a function`.
All four parsers treat that as "the library failed" and answer with a scrape of
the archive, which returns `degraded: true`, and the document pipeline rejects a
degraded parse outright. The visible result was every `.pptx` and legacy `.doc`
reporting "No text could be extracted from this file — it may be scanned,
image-only, or password-protected", naming a cause that had nothing to do with
the fault. 118 pptx and 14 doc failures landed in a single burst when one
connector's sync first succeeded after ten consecutive crashes.

Resolved in one shared loader rather than per bundler: externalizing the package
has to be repeated in every build config this code runs under and regresses
silently the day one is missed.

The shape handling is split into a pure `resolveParseOfficeAsync` because the
failing shape cannot be reproduced by mocking the specifier — Vitest's
module-namespace proxy throws on a missing export rather than yielding the
`undefined` a real bundle produces, so a test going through `import` can only
assert the shape that already worked. That is also why the existing parser
suites never caught this: each mocks `officeparser` with a fabricated named
export, which presupposes the interop being broken here.

* fix(knowledge): bound the workbook preview to the rows it emits

`sheet_to_json` allocates from a worksheet's DECLARED `!ref` range rather than
its populated cells, and Excel routinely writes an inflated range from stray
formatting. The 1,000-row preview cap was applied to the result, so it bounded
the emitted string while the allocation it was meant to bound had already
happened. An 880 KB workbook exhausted an 8 GB worker; the same content
exhausted 16 GB when this ran inside the connector sync. No machine size fixes
that, because the allocation scales with a number the file declares about
itself — fleet p99 for this task is 691 MB against 8 GB, so this is a cliff, not
pressure.

Passing the window into the conversion is what makes the cap real. `defval` goes
with it: defaulting every cell in the range made each row dense, so allocation
scaled with columns x declared rows rather than with populated cells, and
because no row was left empty it silently defeated the `blankrows: false` beside
it. Reported totals still come from the declared range, so bounding the
conversion does not change what the metadata says the workbook holds.

The eleven documents killed this way recorded `attempt_count = 1`: `maxAttempts`
does not cover `TASK_PROCESS_OOM_KILLED`, which Trigger.dev retries only when a
larger preset is named. Adding that escalation is a safety net rather than the
fix, and the same gap the dispatcher had.

Also corrects the machine comment, which claimed `large-1x` was 2 vCPU / 2 GB.
It is 4 vCPU / 8 GB, and believing the stale figure makes a resize look like the
answer when the parser is what is unbounded.

* fix(tables): keep a cancelled dispatch cancelled when a step claims it

`dispatcherStep` reads the dispatch, then awaits the table load before writing
`dispatching`. Keying that write on the id alone resurrected a dispatch
cancelled inside that window — a Stop-all, or now the stale-dispatch sweep —
and the fresh heartbeat it writes would then buy the resurrected row another
full window before the sweep could reclaim it again.

The race predates the sweep, but the sweep is a new writer of `cancelled` that
no user action drives, so it is newly reachable without anyone touching Stop.
Re-asserting the status the step already read is the whole fix.

* fix(tables,knowledge): spare a live window, and restore the truncation notice

A lease needs its heartbeat interval to sit well under its TTL. The dispatch
heartbeat is stamped between windows, not during them, and `batchTriggerAndWait`
checkpoints the loop for the whole window — so the interval is really "one
window", which nothing bounds: the window ends when its cells do, and the
in-process path has no ceiling at all. A window outliving the stale threshold
had its dispatch cancelled while it was plainly alive.

Its cells carry the signal the checkpointed parent cannot — `updatedAt` on every
in-flight row execution, written by the cell tasks themselves. Both signals must
be stale before a dispatch is reclaimed, so a slow window is spared for as long
as its cells keep reporting while a run with nothing beating and nothing
executing is still collected. The subquery rides the partial `(table_id,
status)` index that already covers exactly those three statuses.

Bounding the workbook conversion also made its truncation notice unreachable:
the converted length can no longer exceed the window it was compared against, so
every sheet larger than the preview cap silently stopped reporting that it had
been cut. Compared against the declared row count instead, which is what the
comparison meant before the conversion was bounded.

* fix(tables,knowledge): act on the claim outcome and scope liveness to the dispatch

Three defects, two of them created by the previous round's fixes.

Guarding the pending-to-dispatching claim without reading its outcome was the
worse half of a fix. When a Stop-all or the stale sweep won the race the row
correctly stayed `cancelled`, while the step went on to announce `dispatching`,
stamp cells and enqueue a window for it — and an empty window would then reach
the unguarded `markDispatchComplete` and overwrite `cancelled` with `complete`.
The step now ends when it did not claim the row.

The cell-liveness probe was table-scoped, and `table_row_executions` carries no
dispatch column, so a live dispatch's cells vouched for an abandoned dispatch
beside it and the abandoned row was never reclaimed — turning the stuck overlay
this sweep exists to clear into a permanent one. Narrowed to the dispatch's own
groups, which it already stores. Two active dispatches over the same groups can
still mask each other, but that is the state `markActiveDispatchesCancelled`
already prevents.

Truncation asks whether the window cut the sheet short — a question about the
declared range against the cap. Comparing the converted length to the cap made
it unreachable once the conversion was bounded; comparing the declared count to
the converted length then reported truncation for any sheet merely containing
blank rows, which are now skipped rather than defaulted into existence.

* fix(tables): scope dispatch liveness to its rows, not just its groups

The previous round narrowed the cell-liveness probe to the dispatch's groups on
the reasoning that two active dispatches over the same groups cannot coexist,
because starting a run cancels prior work on its scope. That reasoning was
wrong. `cancelPriorRuns` in `workflow-columns` requires `isManualRun`, so
auto-fired runs never cancel anything, and the per-row path is explicitly a
no-op for dispatch cancellation. Same-group coexistence is ordinary.

A dispatch that names rows now only accepts liveness from those rows, which
covers the auto-fired and row-scoped runs that reach this state. What remains is
two table-wide dispatches over the same groups, where nothing in the row
execution says whose work it is; closing that needs a `dispatch_id` column on
`table_row_executions` threaded through six write sites, including the shared
cell-write path every cell task uses. That residue is a delay rather than a
permanent mask — the live dispatch's cells stop updating when it finishes, and
the next sweep after a quiet window reclaims the abandoned row.

* refactor(tables): name the dispatch liveness predicate and bound its fan-out

Extracts the cell-activity check into `hasRecentCellActivity`, so the stale
predicate reads as its two conditions — nothing beating, nothing executing —
rather than a twenty-line SQL blob nested inside an `and()`. No behaviour
change; this is the code three review rounds found defects in, and being able
to read it is what makes those defects findable.

Bounds the terminal-event fan-out with `mapWithConcurrency`, matching how the
scheduler already fans out. The sibling cancel paths emit over one table's
dispatches; this sweep can carry a whole tick's worth across many tables, and
each event is its own write.

Also repairs the test that covers it. `collectChunks` walks into the
`tableRowExecutions` table object the fragment interpolates, so every column
name appears in the chunks whether the predicate references it or not — the
group, row, table and timestamp assertions all passed with their predicates
deleted. Matching the literal SQL instead makes them fail, which mutating each
clause now confirms.

* fix(tables): make the row bypass NULL-safe and guard the post-wait completion

`jsonb_typeof(scope -> 'rowIds') <> 'array'` was the table-wide bypass, but a
table-wide dispatch has no `rowIds`: the extraction is SQL NULL, `jsonb_typeof`
returns NULL, and `NULL <> 'array'` is UNKNOWN rather than TRUE. The bypass
never fired, so no live cell could satisfy the probe and the sweep reclaimed
exactly the long-running table-wide dispatches the row filter was added to
protect — inverting it. `IS DISTINCT FROM` is the NULL-safe form, and the same
pitfall is already handled with `coalesce` in `markActiveDispatchesCancelled`.

`completeDispatch` also wrote through the unguarded `markDispatchComplete`. Both
its callers run AFTER the window's wait, so a Stop-all or the sweep landing
during that wait leaves the row `cancelled` and the write overwrote it with
`complete`, publishing a completion event after the cancellation one. The claim
guard cannot cover this — the cancel arrives long after the claim. It now goes
through `completeDispatchIfActive`, which already exists for exactly this, and
emits nothing when the transition does not land.

* fix(knowledge): give connector sync logs a retention pass

Nothing pruned `knowledge_connector_sync_log`, so it grew by one row per sync
run forever — a connector on a fifteen-minute interval writes about 35,000 rows
a year by itself. That cost lands on `loadPreviousListingObservation`, which
reads the newest `completed` row per connector through an index covering
`connector_id` alone, so every retained row makes the sort behind the
deletion-safety corroboration slower.

Added as another arm of the cleanup cron, batched the same way as its two
sibling prunes. Two `exists` guards are load-bearing rather than defensive: the
newest row per connector always survives, and so does the newest `completed`
one, because that is the row `loadPreviousListingObservation` reconstructs the
previous listing from — and that reconstruction decides whether a suspect
listing is corroborated, i.e. whether reconciliation may delete documents.
Pruning it would silently change deletion behaviour. `started` rows are never
eligible; they are in flight or waiting on the scheduler's own sweep.

* fix(tables): funnel every post-claim completion through the guarded write

The empty-window exit still wrote through the unguarded `markDispatchComplete`,
and it runs after the claim like the other two — so a cancel landing during its
window query was overwritten with `complete`. Shorter window than the two
post-wait exits, same defect, and leaving one of three unguarded is how this
came back twice already.

All three now route through `completeDispatch`, so the guard lives in one place
and covering it once covers every exit. The redundant test for this path went
with it: it could not be made to fail against the mock, and a test that cannot
fail is worse than none — the guard is held by the test on the shared funnel.

* fix(tables): bound how long cell activity may spare a dispatch

The liveness probe cannot tell whose cells it is looking at when two table-wide
dispatches share a group, because `table_row_executions` carries no dispatch
column. On a quiet table that is only a delay — the neighbour finishes and the
next sweep reclaims — but a busy table with continuous auto-fired work can keep
an abandoned dispatch masked indefinitely, which is the stuck overlay this sweep
exists to clear.

A ceiling bounds it: past a day without a heartbeat, a dispatch is reclaimed
whatever its cells are doing. That is safe because a live dispatch stamps its
heartbeat between windows regardless of cell activity, so only a single window
outliving the ceiling could be reclaimed wrongly, and no window lasts a day on
any path — the Trigger.dev run ceiling is ninety minutes.

The real fix is a `dispatch_id` on the executions row. Threading it through the
patch layer and the upserts underneath it is a change to the hottest write path
in tables and belongs in its own review, not on the sixth round of this one.

* refactor(tables): give the stale predicate one definition of "last beat"

`COALESCE(heartbeat_at, requested_at)` was written twice — once for the stale
threshold and again for the absolute ceiling — so the two could drift into
disagreeing about what proof of life means. One `lastBeat` fragment, one
`notBeatingSince(cutoff)` helper, both cutoffs expressed through it.

Also corrects the ceiling's comment: it triggers a day past the stale
threshold, not a day past now.

* fix(tables): delete the unguarded completion rather than guard it a fourth time

The two pre-claim exits — table missing, no target groups — still wrote through
`markDispatchComplete`. Last round I argued they run before the claim, "where
forcing a terminal state is the intent". That was wrong twice over: the table
lookup is awaited, so a cancel lands in that window like any other, and a
dispatch cancelled mid-lookup has not completed its scope any more than one
cancelled mid-window has.

Routing them through `completeDispatchIfActive` left `markDispatchComplete` with
no callers, so it is gone. That is the part worth having: this is the fourth
place the same defect appeared, each time because an unguarded writer was
sitting there to be reached. With it deleted, `completeDispatchIfActive` is the
only way to complete a dispatch and the class cannot recur.

* fix(tables): re-read the dispatch before committing a window

Several round trips separate the claim from the enqueue — the window query, the
executions prefetch, the tombstone filter — and nothing rechecked the dispatch
across them. A Stop-all or the stale sweep landing in that gap had the step
stamp cells and run a whole window for a dispatch already recorded as cancelled;
the existing recheck sits after the window, which is too late to prevent it.

Mirrors that existing check on the other side of the enqueue. It narrows the gap
to a single statement rather than closing it — a cancel arriving after this read
still races the enqueue, and no check can fix that. The cell-level
`cancellationGuard` and the `isExecCancelledAfter` tombstone filter are what
catch the remainder.
2026-08-21 15:02:12 -07:00
Vikhyath Mondreti 81ebb37563 fix(delivery): stable provider idempotency tokens, and correctness fixes around them (#6943)
* fix(delivery): stable provider idempotency tokens, and correctness fixes around them

Six independent fixes found while investigating a Slack transport failure. None
of them are that failure; all of them are live.

**Money writes could be delivered twice.** Square (8 tools), Brex (5) and Outlook
Calendar minted their provider idempotency token with `generateId()` at
request-build time. That is stable inside the transport retry loop —
`prepareToolRequest` runs once above it — but a BLOCK-level retry re-enters the
handler and mints a fresh one, defeating the provider's dedupe. A builder ticking
"retry" on a Square block turned a committed write into a second card charge, and
a Brex one into a second money transfer. Tokens now come from
`deriveDeliveryKey`, a pure function of execution + block + tool + invocation, so
every retry layer derives the same value. Stripe joins them: all ~50 tools
previously sent no `Idempotency-Key` at all.

The comment on `brex/create_transfer.ts` claimed a fresh key per transfer
*prevents* duplicate money movement. That is inverted — fresh per *attempt* is
what permits it — and is corrected here.

**`invocationId` is required, not optional.** Deriving from `executionId` alone
would be worse than the bug: five loop iterations paying five invoices would
share one token, the provider would honour the first and silently drop four real
payments, and it would look like five successes.

Also:

- `INTERNAL_API_BASE_URL` is now ignored on Trigger.dev workers. It names a route
  that resolves only inside the app container, and several modules run in both
  runtimes — `guardrails/mask-client.ts` says so in its own TSDoc — so setting it
  produced `PII redaction failed: Unable to connect` on every worker-side
  redaction. Mirrored into `packages/testing`'s urls mock, which reimplements the
  function and would otherwise have diverged.
- `engines.bun` raised to >=1.3.14. Measured on 1.2.15, which the old floor
  permitted: a fully-delivered POST is silently replayed and the caller sees 200.
- CloudWatch `put-metric-data` pinned to `maxAttempts: 1`. The AWS SDK default of
  3 already replayed it, and `PutMetricData` aggregates rather than overwrites, so
  a duplicate silently corrupts the customer's metric series and alarm thresholds.
- `webhookIdempotency` given a bounded in-progress lease. An untimed run held a
  SEVEN DAY lease while concurrent duplicates polled it once a second.

Verified: `tsc --noEmit` clean, 30,100 tests pass.

* fix(ci): restore staging files the branch split had reverted, and format

Three problems, all from assembling this branch by checking paths out of a WIP
branch built on an older `staging`. Anything `staging` changed since that base
came back as a revert.

- Root `package.json` had lost the `opentype.js` / `@types/opentype.js`
  dependencies `staging` added, which desynced `bun.lock` and failed `bun audit`.
  It also carried a `check:outbound-delivery` script belonging to other work.
  Every `package.json` is now taken from `staging` with only the `engines.bun`
  line re-applied.
- `executor/utils/block-data.test.ts` — a 77-line file `staging` added — was
  deleted outright. Restored.
- `executor/handlers/generic/generic-handler.test.ts` had lost a test `staging`
  added. Restored, with only the one `blockId` assertion re-applied.

Also formats `keyed-invocation-identity.test.ts` and sorts imports in
`internal-api-base-url.test.ts`, which is what `lint:check` failed on.

* fix(providers): thread the model's tool-call id into keyed tool execution

`prepareToolExecution` accepted an `invocationId` but no provider supplied one,
so a keyed tool invoked through an agent always hit the incomplete-context
fallback and minted a fresh token — leaving Stripe, Square, Brex and Outlook
Calendar writes able to double-deliver under the hosted-key retry layer even
though the block path was fixed.

The id is now a positional parameter rather than another optional field on
`request`, so a provider that cannot supply one fails to compile instead of
silently falling through. 22 of the 27 call sites already had the OpenAI-shaped
`toolCall` in scope; `tsc` identified the other five, of which Anthropic
(`toolUse.id`) and Bedrock (`toolUse.toolUseId`) name it differently.

Gemini is left deliberately unthreaded and documented: its function-call parts
carry no model-supplied identifier — the streaming loop has to synthesize a local
one — and a positional index would not survive the model re-emitting the call. A
token that only looks stable is worse than the loud fallback, which names the
missing fields.

* fix(executor): keep execution order monotonic across a resume

`executionOrder` is not carried in the pause snapshot, so a resumed run
restarted the counter at 0 and a loop or parallel body executing on both sides
of a pause could reuse a pre-pause value.

That was cosmetic while the number only ordered logs. It stops being cosmetic
once identity is derived from it: a `keyed` tool takes its provider idempotency
token from this value, so two distinct writes would present the same token and
the provider would silently drop the second. Suppressing a real payment is worse
than the duplicate the token exists to prevent, because it looks like success.

The counter is now seeded from the highest `executionOrder` among the restored
block logs rather than from a new snapshot field, so snapshots written before
this change are repaired on resume instead of needing a migration.

* fix(providers): thread the tool-call id through the streaming loops too

The previous commit only reached call sites written as a single-line
three-argument call. The streaming loops are formatted across lines, so
openai-compat (Groq, DeepSeek and everything else routing through it), Anthropic
and Bedrock still omitted the id and kept falling back to a fresh token.

Both Gemini paths now pass `part.functionCall?.id` — the RAW model id, not the
`ensureToolCallId` value used for stream events. That helper allocates an
execution-local id when Gemini supplies none, and it is freshly allocated per
attempt: passing it would complete the keyed context, silencing the "could not
derive" warning, while leaving the token just as unstable. Gemini frequently
omits the id, in which case this is `undefined` and the loud fallback stands.

All 27 call sites are now covered.

* fix(providers): make the tool-call id argument required, not optional

The TSDoc claimed a provider that cannot supply an id would fail to compile, but
the parameter was declared `toolCallId?: string` — so a new call site could omit
it entirely, typecheck, and silently take the unstable-token path the positional
parameter exists to close. The comment promised a guarantee the type did not
enforce.

It is now `string | undefined`: required in position, nullable in value. A
provider with no model-supplied id must pass `undefined` explicitly and take the
loud fallback, rather than being able to forget the argument.

All 27 existing call sites already pass it, so this is enforcement only. Verified
by deleting the argument at one site: `tsc` rejects it.
2026-08-21 13:35:20 -07:00
Vikhyath Mondreti 85a3226678 fix(security): stop redirects replaying request bodies and leaking credentials (#6941)
* fix(security): stop redirects replaying request bodies and leaking credentials

`secureFetchWithPinnedIP` passed its options straight into the redirect
recursion, so a 301/302/303 replayed the original method and body — delivering a
non-idempotent write twice — and forwarded `Authorization` and every other caller
header to whatever origin the upstream named.

`followRedirectsGuarded`, a hundred lines above it in the same file, already had
the correct RFC 9110 rules. The two had drifted, and the drift is the bug. Both
now route through one `resolveRedirectHop`:

- 303, and 301/302 on POST, degrade to a bodyless GET and drop the entity headers
  that described the removed body.
- A cross-origin hop drops every caller header, not just `Authorization`.
- A cross-origin hop that would forward a body is refused.

`stripAuthOnRedirect` still narrows same-origin hops for endpoints that redirect
to a target carrying its own signed URL.

Verified by stashing the fix and re-running: 4 of the 6 new tests fail against
the old code. The 2 that pass either way cover same-origin behaviour that was
already correct.

* fix(api): preserve HTTP redirect compatibility
2026-08-21 13:33:52 -07:00
Siddharth Ganesan 58aa6379e0 feat(cli): add chat command (#6937)
* feat(cli): add chat command

* fix(cli): harden chat command execution
2026-08-21 12:59:08 -07:00
Vikhyath Mondreti cd0516cade fix(billing): separate Enterprise reporting periods from Stripe terms (#6942)
* fix(billing): separate Enterprise reporting periods from Stripe terms

* fix(billing): reconcile accepted legacy intents

* fix(billing): keep accepted legacy intents fail-closed

* fix(billing): reconcile accepted retired intents

* fix(billing): retire invalid legacy intents
2026-08-21 12:52:49 -07:00
Vikhyath Mondreti 6a45b0d4a6 feat(library): Best AI Agent Platforms for Connecting Your Existing Tools (#6946)
Co-authored-by: Sim Pi Agent <pi@sim.ai>
2026-08-21 12:45:59 -07:00
Theodore Li 4c41fc6c3e fix(setup): bump sim-setup to 1.0.1 (#6944) 2026-08-21 12:24:06 -07:00
Waleed 6b7fd1a88d fix(webhooks): stop the generic webhook publishing a closed output schema (#6939)
* fix(webhooks): stop the generic webhook publishing a closed output schema

Declaring outputs on the generic webhook trigger did not add three reference
completions — it made those three the only legal fields on the block.

`collectBlockData` registers any non-empty output declaration as an exhaustive
schema, and `resolveBlockReference` then throws `InvalidFieldError` for any
reference outside it that resolves to `undefined`. A generic webhook receives
whatever the caller sends, so every workflow reading a body field started
failing the moment a delivery omitted that field, instead of resolving to
`undefined` and letting the condition evaluate falsy as it always had.

Revert the declaration to `{}` and record why it has to stay that way. The
request metadata is still merged into the workflow input by the provider's
`formatInput`; it is only undeclared, which is what keeps the shape open.
Offering these as editor completions needs a way to mark outputs as hints
rather than a closed schema — a change to `getRegistrySchema`, not to this list.

Pins the behavior at the executor level rather than on the trigger config,
since the config assertion is what passed while the block was broken.

* chore(audits): re-record the workspace module-graph baseline

`check:tool-registry-boundary` fails on CI for any branch right now: the
knowledge page measures 2255 modules against a 2209 baseline, one module past
the max(25, 2%) allowance. It passes locally at 2253, which is why it only
shows up in CI — the two platforms resolve a couple of modules differently, and
the route happened to sit inside that gap.

The drift is not from any one change. 26 of the 34 recorded routes have grown
since the baseline was last written, by up to +44. Measuring this branch's
route with and without its own diff gives 2253 either way, so it contributes
nothing; it is just the branch that happened to cross the line.

Re-records all 34 entries, which is what the script prescribes. No gateway was
added or removed on any route — only the module counts moved — so the boundary
this audit exists to protect is unchanged and the first assertion, that the
tool registry stays out of every workspace page graph, still passes.

Worth a separate look at why the workspace pages have grown this much; this
commit only stops a stale number from blocking unrelated work.

* chore(tests): give the block-data test helper an explicit return type
2026-08-21 11:32:57 -07:00
Waleed 00d8a3fbfe fix(knowledge): refund a processing attempt whose dispatch never happened (#6938)
* fix(knowledge): refund a processing attempt whose dispatch never happened

`markDocumentsQueued` spends one attempt from the processing budget on every
dispatch, and `clearDocumentsQueued` already withdraws the queue stamp on the
one path that proves nothing was dispatched — but it left the attempt spent.

The budget exists to stop re-billing a document that keeps failing the same way
in processing. An attempt that never reached a worker teaches it nothing, so an
infrastructure outage — a Trigger.dev region error, an exhausted quota — burned
the allowance without a single run. MAX_PROCESSING_ATTEMPTS such outages
dead-letter the document, and the connector sweep filters on
`processingAttempts < MAX_PROCESSING_ATTEMPTS`, so automatic recovery stops for
a document that was never processed once.

Refunded in the same guarded statement as the stamp, so it can only ever give
back the charge this call made, and floored at zero.

Also corrects the stale-lock TTL doc, which still described the reclaim as
measuring `updatedAt` after it moved to `COALESCE(syncLockLeaseAt, updatedAt)`,
and the attempt-budget rationale, which predates the refund.

* chore(audits): re-record the route module-graph baseline

Every workspace route had drifted past what the baseline records, uniformly:
the shared `[workspaceId]/layout.tsx` grew +28 and each route inheriting it grew
+27 to +29. The knowledge page carries +18 of its own on top, which put it +46
over a +44 allowance and was the only entry actually failing.

Bisecting the growth across the last five commits on staging shows 2249 → 2251 →
2252 → 2253 → 2255 — one or two modules per unrelated feature PR, not a single
regression dragging in a fat dependency. That is the organic creep the
`max(25, 2%)` ratchet is meant to absorb and the re-record is meant to settle.

Counts only: all 34 entries are preserved and every entry's gateway set is
unchanged, so the registry-reachability gate and the per-entry ratchet keep
exactly the strength they had.
2026-08-21 11:21:26 -07:00
Theodore Li a0a14383f0 feat(enrichments): add LinkedIn profile lookup (#6926)
* feat(enrichments): add LinkedIn profile lookup

* fix(enrichments): request Findymail profile data

* fix(enrichments): avoid Findymail profile charge

* refactor(enrichments): omit Findymail profile option
2026-08-21 14:05:23 -04:00
Siddharth Ganesan e578cfe5dd feat(files): mship file writing improvements- #6933 (#6933)
feat(files): mship file writing improvements (#6933)
2026-08-21 10:31:51 -07:00
Siddharth Ganesan 29acfb10ff improvement(mship): mship file fixes (#6918)
* improvement(files): preserve editable page source on round trips

* improvement(pages): harden previews and mobile rendering

* fix(copilot): keep generated API keys accessible

* fix(files): rewrite page sources after upload finalization

* fix(pages): expose compile diagnostics through VFS

* fix(copilot): surface classified tool access errors

* fix(copilot): surface actionable server tool errors
2026-08-21 10:09:31 -07:00
Waleed 63569a2459 fix(connectors): count hard-kill failures and cap deletion blast radius (#6909) 2026-08-21 09:41:21 -07:00
Vikhyath Mondreti 01795e1ed2 fix(combobox): report every open, not just the dismissals Radix initiates (#6931)
The Combobox owns its `open` state but renders a controlled Radix `Popover`
(`PopoverAnchor` + `open={open}`, no `PopoverTrigger`), and the consumer's
`onOpenChange` hung off that Popover alone. A controlled popover reports only
transitions it initiates itself, so outside-click and Escape arrived and nothing
else did: the trigger, the chevron, focus, Enter/Space/ArrowDown, and
select-to-close were all the component's own `setOpen`, invisible from outside.

Every consumer refreshed on open or reset on close, so the damage stayed quiet —
the credential selectors, MCP tool selector, workspace-file picker, connector
modal, and the sub-block dropdown's remote option list simply never refreshed
when opened. Then #6881 gated the agent block's `toolGroups` on the same signal
to keep the group build off the canvas's hot path, and a picker that could not
learn it was open built nothing: the dropdown rendered "No tools found" over the
full block registry.

Every transition now goes through one `changeOpen`, which Radix's own
`onOpenChange` also feeds, so `setOpen` has exactly one caller and the callback
cannot be missed. It dedupes through a ref, because several paths both close and
let the popover dismiss — a redundancy the raw setState absorbed silently but a
consumer callback would not — and reading that ref lets the toggles resolve
their next value without re-creating their handlers on every open.

Tests cover the transitions Radix never reported (trigger click both ways,
keyboard open, Escape) and the consumer shape that made this visible: options
supplied only once the dropdown says it opened must render, not the empty state.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 08:40:30 -07:00
Waleed 8be58682ca fix(custom-blocks): give a custom block's logo a tile its header chip can paint (#6929)
A custom block with an uploaded logo declared bgColor 'transparent', which
meant "the image is the whole tile" — true on tile surfaces, where the image
fills the box, but wrong for the canvas node header. The header chip sets its
label beside the icon rather than under it, so an unpainted chip left the label
nothing to contrast: perceivedBrightness('transparent') is null, the foreground
fell back to white, and the block name rendered white on the white card. The
header showed a bare logo where every other block shows a chip.

Custom blocks with an image now wear the same white plate as every other
light-tiled provider, so they read as an ordinary integration everywhere.
Icon and tile resolve together from one precedence rule, so a block can never
paint a tile its icon disagrees with.
2026-08-21 08:20:03 -07:00
Vikhyath Mondreti fd5ea3e399 fix(billing): cast outbox payloads for JSONB operators (#6928) 2026-08-20 23:22:27 -07:00
Vikhyath Mondreti ab66ce9149 fix(admin): harden dashboard billing operations (#6914)
* feat(admin): make dashboard billing operations durable

* fix(admin): close dashboard recovery gaps

* fix(admin): preserve member operation lock order

* fix(admin): harden durable operation boundaries

* fix(admin): report member operation failures accurately

* test(invitations): cover locked seat admission

* feat(enterprise): gate owner activation on acceptance

* fix(enterprise): keep owner activation recoverable
2026-08-20 22:31:11 -07:00
Waleed dbbe99e473 fix(integrations): validation pass over Crunchbase, PitchBook, and CB Insights (#6925)
* fix(crunchbase): widen tier-gated collection allowlists and cap the deleted feed

The deleted-entity, autocomplete, and fields-metadata allowlists each held only
the collections the narrowest package tier publishes, so requests valid on a
richer package were rejected locally before any request went out. An Advanced
Financials key could not read the funding-round deletion feed at all.

- deleted-entity collections: 9 -> the 14-collection union across all tiers
- autocomplete and fields-metadata: 14 -> all 43 collections
- clamp the deleted feed to its documented max of 25, not Search's 1000
- offer "All collections" so the cross-collection feed stays reachable
- name the richer-tier card additions instead of presenting the base set as exhaustive

Also rewrites a test that asserted the broken behavior and tightens a
substring URL assertion that passed on the value it was meant to reject.

* fix(pitchbook): stop a rejected API key reaching block output and logs

PitchBook's 401 body echoes the submitted key back inside `message`. No
PitchBook tool declared an `errorExtractor`, so the failure fell through to the
generic chain, whose first entry returns `data.message` verbatim — putting the
credential in the block error, the run log, and any agent context reading the
failure. The existing scrubber sat in `transformResponse`, which never runs on a
non-ok response.

- add a `pitchbook-errors` extractor that replaces the unauthorized message with
  a fixed string, and wire it through all 91 tools
- the extractor returns undefined unless the body carries a `message`, so a
  foreign 401 on the shared fallback chain is never labelled a PitchBook failure
- correct `investor_preferences.preferredIndustry` to the shape the API returns
- make `company_industries.emergingSpaces` opaque; its item shape is undocumented
- reject a non-list of article ids instead of throwing a bare TypeError

* fix(cbinsights): reject malformed input instead of silently rescoping a billed query

CB Insights is metered, so a filter that fails to parse must fail the request —
dropping it does not narrow the result, it charges for a query the caller never
asked for.

- reject an unrecognized boolean rather than dropping it, which had been
  widening a VC-backed firmographics search
- reject a non-numeric limit instead of falling back to the endpoint default
- reject non-text filter entries instead of stringifying them to "[object Object]"
- accept only asc/desc for sort direction; a typo had returned the bottom of a
  metered result set as though it were the top
- treat a whitespace-only numeric bound as unset, not as zero
- drop `totalHits`/`totalHitsRelation` from list business relationships; that
  endpoint reports no total, so both were permanently null
- trim `nextPageToken`, matching the id fields

Also moves the token cache onto `lru-cache` per the in-process caching rule,
replacing hand-rolled TTL arithmetic and a manual prune.

* chore(harmonic): drop the team-key help text from the credential descriptor

* chore(tools): regenerate tool metadata for the validation fixes

* fix(tools): redact the retained error body, not just the message

Scrubbing the extracted message left the raw provider body reachable:
`createTransformedErrorFromErrorInfo` attaches `errorInfo.data` to the thrown
error and the executor surfaces it on the failed tool's `output.data`, so a
PitchBook key rejected with an echoing 401 still reached block output and agent
tool results via `output.data.message`.

- add an optional `redactData` to the error-extractor contract, so an extractor
  that exists because a provider echoes a credential can replace the body too
- retain `redactErrorData(errorInfo, extractorId)` in place of the raw body
- PitchBook replaces only the unauthorized body; every other failure is untouched
- cover the executor path itself, since asserting on the redactor directly still
  passes when nothing is wired to it
2026-08-20 22:22:28 -07:00
Vikhyath Mondreti aca152c7fb fix(env): put runtime config on <html> so client reads can't outrun it (#6923)
* fix(env): put runtime config on <html> so client reads can't outrun it

The inline script that assigns `window.__ENV` is rendered from the component
tree, so it lands ~13KB after the `<script async>` bootstrap tags React emits
in the preamble. `appBootstrap` calls `hydrate()` synchronously whenever
`self.__next_s` is empty — which it always is now that the script is a plain
tag rather than a `beforeInteractive` one, that queue having been the only
thing sequencing the assignment ahead of hydration. So module bodies and the
first commit could both read env before the assignment landed: the socket URL
fell back to the page origin for the life of the document, `getBaseUrl()`
threw, and every module-scope flag in `env-flags` froze on nothing.

Carry the same snapshot on `<html>`, the document's first tag, and read it in
`getEnv` when `window.__ENV` is not yet assigned. Parsing is memoized against
the raw attribute rather than against having run once, so the cache can never
serve a value the document no longer carries. `window.__ENV` stays the public
global and the preferred read, and both transports are built from one function
so they cannot drift.

Alongside: guard the read-only webhook-URL field so a base URL it cannot
resolve is a blank field rather than a dead canvas; report what the workflow
error boundary catches, which it previously swallowed entirely; and enable
PostHog's native exception capture, since error boundaries only ever see their
own subtree and chunk-load failures, rejected promises and throws from event
or socket callbacks reached nothing.

* fix(realtime): count each failed connect attempt once

`manager.reconnect()` calls `open()`, whose error path emits `error` — which
the socket re-emits as `connect_error` — and then emits `reconnect_error`
itself. A failed reconnect therefore reached both handlers and advanced the
counter twice, so the outage report tripped on the second real attempt while
claiming three.

Count in `connect_error` alone: it is the only handler that fires exactly once
for both the initial failure and every retry. `reconnect_error` keeps its log
line and states why it deliberately does not count.

* fix(workflow): let an unresolvable webhook URL fail loudly

Reverts the guard added earlier in this PR. A blank row titled "Webhook URL"
is a worse outcome than a crash: it explains nothing, and the value is one a
user copies into a third-party provider, so any substitute — a guessed page
origin, an empty string — is a URL that provider accepts and then never
delivers to.

The read can no longer come back empty from the hydration race this PR fixes,
so reaching it at all means the deployment has no application base URL, which
breaks webhook registration and callbacks regardless. The error boundary now
reports what it caught, so the throw names its own cause instead of surfacing
as an unexplained fallback.
2026-08-20 22:12:36 -07:00
Justin Blumencranz 4ad1d53de9 fix(copilot): sanitize edit_workflow result state before returning it to the agent (#6904) 2026-08-20 20:22:16 -07:00
Waleed f9eaadfc70 fix(knowledge): give failed documents a grace while their retries are still scheduled (#6924)
The sweep treats failed as terminal — "the run that produced it has ended" — and
that was true when processing ran inline. Since dispatch became asynchronous the
worker writes failed and then rethrows, and the processing task retries up to
three times, so failed is a resting state between attempts rather than a final
one. The sweep gave it no grace at all, so it reclaimed a document mid-retry
chain, deleted its embeddings, and re-dispatched with a fresh pass id: two live
runs, two indexing passes, two bills, through the one state deliberately left
unguarded.

Aged from processingCompletedAt, which every failure write stamps, so it reads
exactly when the last attempt ended rather than when the document was dispatched.

Sized at the queue grace rather than a run-duration bound. What is being waited
on between attempts is a re-queue behind the same global concurrency limit, not
another run, so on a large backlog the next attempt starts hours after the
previous one ended and a run-duration bound would still reclaim live work.

The grace itself is now an expression rather than a constant — corpus over queue
concurrency, times occupancy, times a contention factor — with each input
documented where it can be re-measured. The previous 240 was derived from a
2,600-document corpus and sat below the drain time of the 7,730-document
connector it was written for. Rounding is up: too small silently re-bills live
work, too large only delays recovery of documents nothing is processing.

Also withdraws the queue stamp when every dispatch in a batch fails, so a
provably-undispatched document keeps next-sync recovery instead of waiting out a
grace it never earned. Scoped to the batch's own ids, to rows still pending, and
by compare-and-set on the exact stamp that call wrote, so a concurrent batch that
re-stamped a document keeps its grace. Best-effort by design, while the stamp
write still throws: a failed stamp means the grace cannot be promised and
dispatching anyway is the unsafe direction, whereas a failed withdrawal only
delays recovery and must not mask the dispatch error underneath it.
2026-08-20 19:36:52 -07:00
Waleed 1ced0b6065 fix(knowledge): stop the stuck-document sweep reclaiming still-queued documents (#6921)
* fix(knowledge): stop the stuck-document sweep reclaiming still-queued documents

The sweep gave 'processing' a staleness cutoff but gave 'pending' and 'failed'
none, and the only cross-run guard was uploadedAt < syncStartedAt, which scopes
within a sync but not across them.

That was harmless while document processing awaited inline: documents were
terminal by the time a sync ended. Since dispatch became asynchronous they sit
'pending' until a worker picks them up, so the next sync deleted their
embeddings, reset them, and re-dispatched while the original runs were still
executing — and each re-dispatch mints a fresh pass id, so it billed again.

Queued documents now get a grace period derived from queue drain time rather
than run duration: the processing queue's concurrency is 20 and global across
workspaces, so a 2,600-document corpus takes roughly two hours to drain. The
existing 45-minute threshold bounds a run, not a wait, and would still reclaim
live documents on any corpus over about 900.

No column records dispatch time, so the signal is processingStartedAt falling
back to uploadedAt. The sweep now stamps that column when it re-dispatches, and
the user-triggered retry stamps it instead of clearing it — without both, only a
document's first dispatch was protected and the sweep churned once per sync
forever on anything that kept waiting.

The grace narrows the duplicate-billing window but cannot close it: a
re-dispatch mints a new request id and the Trigger idempotency key is scoped per
dispatch by design. Closing it durably needs a document-scoped key or a
dispatch-generation column, recorded in TSDoc and deliberately not built here.

* fix(knowledge): record dispatch time in its own column instead of overloading the start time

Two review findings on this PR traced to the same root: the queue grace was
reading processingStartedAt, a column that means something else.

Externally, a pending row carrying a dispatch timestamp reported a processing
start time for work that had not started. Internally, updateDocument sets a
document pending and refreshes uploadedAt while leaving the previous run's
processingStartedAt in place, and completion never clears it — so the sweep aged
a re-dispatched document from a leftover stamp rather than its actual dispatch,
and could reclaim it inside the grace window while the earlier queue entry was
still live. That reopens the duplicate-billing race this PR closes.

processing_queued_at is written on every re-dispatch and read by the sweep;
processingStartedAt goes back to meaning what its name says, so its external
contract is byte-identical to before this PR.

Not stamped on first dispatch: the row is created and dispatched inside the same
sync run, so uploadedAt is already accurate there and a guarded write per upload
would buy no behavior.

The internal document route returns a full table row through a passthrough
schema, so the new column would have shipped as an undeclared raw Date. Declared
and serialized explicitly instead.

* fix(knowledge): stamp the queue time in the dispatch funnel, not at each call site

Giving dispatch its own column was not enough. The connector content-update path
updates a row in place with pending status and a fresh uploadedAt while leaving
every prior-run processing column intact, so a document dispatched once already
carried a stale queue stamp, the refreshed uploadedAt was never consulted, and
the row aged from a dead run's timestamp — the same bug one level down.

"Written on dispatch and only on dispatch" is now structural rather than a
convention three call sites remember: the stamp lives in processDocumentsWithQueue,
which every one of the nine dispatch paths already goes through, so none can
forget it. The sweep's and the retry's own stamps are kept because they land
inside the same transaction as their reset, so a document keeps its grace even
if the dispatch that follows throws.

The funnel also clears processingStartedAt, guarded on the row still being
pending so it cannot disturb a worker's compare-and-set. That closes the
API-facing half at its source: a pending row can no longer report a start time
from any path, including the content-update path no serializer would have seen.
2026-08-20 18:59:31 -07:00
Vikhyath Mondreti 2074afe8ed feat(library): Best AI Automation Tools in 2026 (#6922)
Co-authored-by: Sim Pi Agent <pi@sim.ai>
2026-08-20 18:42:29 -07:00
Vikhyath Mondreti 9a66fbb774 fix(auth): bound the three unbounded session-policy caches (#6919)
* fix(auth): bound the three unbounded session-policy caches

security-policy.ts and session-policy.ts are read from Better Auth's session
create and update hooks, so they run on every session validation. All three
caches were plain Maps with a hand-rolled 'Date.now() - fetchedAt < TTL' read
and no ceiling: entries were released only by an explicit invalidate, so they
grew for the life of the process. membershipCache is the sharpest of the three
because it is keyed by user, not organization — one entry per user who ever
authenticated on that instance.

Move all three to LRUCache, already a direct dependency and the pattern
copilot/entitlements.ts and providers/client-cache.ts use. The library owns the
TTL and the ceiling; every existing invalidate* keeps working unchanged.

Two things to preserve, both now pinned by tests:
- membership results keep their asymmetric TTL (a non-member result expires far
  sooner, so a user who joins through a path this codebase never sees cannot
  dodge the new org's policy). Expressed as membershipCacheTtlMs rather than an
  inline ternary, since it is a security property and not a tuning knob.
- reads test '!== undefined', because a version is a number and a membership is
  nullable — a truthiness check would treat both as a miss.

security-policy.ts had no test file; adds one covering caching, invalidation,
failure fallbacks and the TTL asymmetry.

* fix(auth): raise the cache ceilings to a memory backstop

getSessionCookieCacheVersion feeds Better Auth's session.cookieCache.version,
so these are read on every session read, not just create/refresh. At max: 1000
a busy instance could exceed the live key set inside the 60s TTL and start
evicting early — never a wrong answer (a miss is one indexed lookup, exactly
the pre-cache behaviour) but a hit-rate cliff on a hot path.

Entries are a few dozen bytes, so headroom is nearly free: orgs 1k -> 20k,
users 10k -> 100k. That is single-digit MB at worst and puts the ceiling far
above any plausible per-instance working set, leaving it as the memory backstop
it was meant to be.

* docs(rules): write down the caching decision tree

The lifecycle-map-vs-TTL-cache distinction, the ceiling-as-backstop sizing, and
the fetchMethod-unless-you-need-a-hang-deadline call each took real digging to
settle. Recording them so the next cache does not re-derive the same answers —
or re-introduce the unbounded tenant-keyed Map this branch just removed.

Notes the two non-obvious traps behind that: ttl alone does not bound memory
without a ceiling, and React cache() is a no-op in Trigger workers, so a gate
that looks free on a settings page is uncached and per-block on the executor.
2026-08-20 18:29:48 -07:00
Waleed b125cfdc05 fix(knowledge): dispatch document processing from inside Trigger.dev runs (#6920)
isTriggerAvailable() returns false inside Trigger.dev worker runs, so every
connector document has been downloaded, parsed, chunked and embedded inside the
sync task at concurrency 5 rather than dispatched to knowledge-process-document.
The per-document fan-out is inert in production.

Confirmed from a crashed run's spans: the dispatch log line carries
backend: "direct", and no knowledge-process-document runs exist for that
knowledge base while other knowledge bases dispatched normally in the same
window. On the largest connector — 7,730 documents, individual XLSX files over
20MB — that exhausts the heap and OOMs the sync.

Inside a run the answer is unconditionally yes: the platform is what is
executing the process, so no environment guess can beat the run marker. Two
independent signals, because this has now been got wrong twice — the SDK's
ambient taskContext.isInsideTask, already load-bearing in getAsyncBackendType
for the same carve-out, and a marker written by the global init lifecycle hook,
which uses only the documented public surface. Neither reads TRIGGER_SECRET_KEY
or TRIGGER_DEV_ENABLED.

Resolving true inside a run is safe under either hypothesis about which
conjunct was false: if a run process lacks the secret key the SDK rejects the
batch trigger and dispatch falls back in-process, which is where a false
predicate lands today.

The first evaluation per process now logs the resolved inputs, naming which
conjunct is false. A predicate unit test would not have caught either shipment;
both were deploy-environment differences only observable from inside a worker.
2026-08-20 18:06:26 -07:00
Waleed d8d983859a fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment (#6915)
* fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment

Follow-up to #6902, from a final validation pass against Harmonic's OpenAPI and
API reference. No endpoint, method, or response mapping changed.

- The `personUrns` field told users and the LLM that Clear Net-New Results
  "clears everything when omitted". That is the raw provider behavior the
  clearScope guard was added to block; omitting it now throws. The field is
  shared across three operations, so the wrong sentence was being served as
  guidance on all of them.
- Bulk email enrichment deduplicated LinkedIn URLs before canonicalising them,
  so `.../in/foo?utm_source=x` and `.../in/foo` were submitted as two people.
  Harmonic bills per submitted entry, so this spent quota twice and
  double-counted against the 5,000 cap. Deduplicate after canonicalising.
- The two documented bulk-enrichment failures carry a code in `error` and no
  message anywhere, so quota exhaustion surfaced as "Request failed with status
  429". Render the code with its counters instead. Gated on those counters being
  present: `extractErrorMessage` without an explicit id walks every extractor in
  order, and claiming a bare `error` key swallowed OAuth's `error_description`.
- An enrichment 404 whose detail carries only the URN no longer discards it.
- Report the identifier conflict before complaining about an individual URL.
- Validate `companyContextUrns` as company URNs, like every other URN param.

Forward-compat: `user_saved_search_type` is passed through rather than checked
against a fixed set. It is display metadata nothing branches on, and Harmonic
owns the enum — an allow-list turned any value they add into a hard failure of
the whole list while the selector reading the same rows kept working.

Also drops `USER_CONNECTION`, which Harmonic documents as unsupported via the
API, removes three superseded types and one dead helper, and extends the
"credential never reaches a URL or body" assertion from 4 tools to all 13.

* fix(harmonic): fold equivalent profile URLs and stop blank entries failing a batch

Review round on #6915.

- Blank and non-string `personLinkedinUrls` entries are dropped before the
  mutual-exclusivity check. Moving the filter after per-URL validation meant a
  list like `['']` alongside person URNs reported "not both" — naming a conflict
  the caller never created — or failed the URL parse instead of reading as absent.
- Deduplicate on a profile key rather than the canonical string, so
  `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash count once.
  Harmonic canonicalizes and silently deduplicates server-side and reserves quota
  afterwards, so this does not change what is billed; it keeps Sim's own 1-5000
  accounting in step with the set Harmonic accepts, so a batch of equivalent URLs
  is not rejected locally for a cap it never reaches.

Regional subdomains stay distinct: folding `uk.linkedin.com` into `www.` would
assert an equivalence Harmonic does not document, and the URL kept for display
must remain the one the caller supplied.

* fix(harmonic): fold only recognized profile URLs, never pass-through ones

Review round on #6915. The profile key added last round was applied to every
entry, but it is built from host and path alone. A URL forwarded verbatim for
Harmonic to adjudicate keeps its query, port and fragment significant, so two
distinct identifiers collapsed to one key and the later one was dropped before
Harmonic ever saw it.

The key now applies only to a URL `normalizeLinkedinProfileUrl` already
canonicalized — where the query and fragment are gone by construction, so folding
host and trailing slash is safe. Anything passed through deduplicates on its exact
text.
2026-08-20 17:59:28 -07:00
Theodore Li 5b28da1989 fix(tables): accept plain row query predicates (#6916)
* fix(tables): accept plain row query predicates

* fix(cli): show table predicate group syntax
2026-08-20 20:48:47 -04:00
Theodore Li 8eebd6e687 fix(enrichments): project provider failures (#6917)
* fix(enrichments): project provider failures

* fix(enrichments): share Prospeo failure projection
2026-08-20 20:47:17 -04:00
Bill Leoutsakos 42f6287911 feat(byok): add organization-wide key inheritance (#6834)
* feat(byok): add organization key management

* feat(byok): inherit organization keys at runtime

* feat(byok): add organization scope to BYOK settings

* fix(byok): refresh org key state after mutations

* fix(byok): hide stale inherited status badges

* chore(db): drop colliding byok migration ahead of staging merge

Staging independently claimed 0293. Remove ours so the merge is clean;
it is regenerated at the next free index right after.

* chore(db): regenerate byok migration at 0296

Staging claimed 0293-0295 during the merge; the regenerated SQL is
byte-identical to the dropped 0293.

* docs(byok): document organization scope, precedence, and the full provider list

The BYOK section described workspace-scoped keys only. Add the organization
scope, its Enterprise requirement, the per-provider precedence rule, what an
entitlement lapse does, and the Pi sandbox exposure. Refresh the provider
table from the settings page, which had drifted from 14 to 34 entries.

* feat(byok): open organization keys to every organization plan

Organization BYOK was gated on Enterprise, but an organization is the only
thing that can hold the keys, so every plan that can own an organization
should qualify — Pro for Teams, Max for Teams, and Enterprise.

Add checkOrgPlan/resolveOrganizationPlan beside the Enterprise pair rather
than widening checkEnterprisePlan, so the Enterprise-only gates (Access
Control, whitelabeling) are untouched, and restore
resolveOrganizationEnterprisePlan to module-private now that BYOK no longer
needs it.

* perf(byok): cache the organization entitlement, not the key material

getBYOKKey runs once per agent block and once per hosted-capable tool call,
so a loop over N items resolved N times — and each organization-inheriting
resolution paid three sequential billing queries on top of the two key reads.

Split the two reads by staleness tolerance. Key rows stay fresh, because
revocation must be immediate. The entitlement is a billing gate that tolerates
bounded staleness in the harmless direction (a lapsed organization keeps using
its own key for <=60s), so cache it per organization with an in-flight share so
concurrent blocks issue one query set. The management surfaces keep reading it
fresh, so an organization that just upgraded is never told otherwise.

Also run the block check and subscription read in parallel inside
resolveOrganizationPlan, and carry the resolved scope on BYOKKeyResult so a log
line can say whether a run used the workspace's key or an inherited one.

* feat(byok): let workspaces store the Z.ai and Cohere keys the runtime reads

Both ids were already in the BYOK contract enum and both are resolved at
execution time — getApiKeyWithBYOK reaches 'zai' (GLM models are in the hosted
catalog, so the BYOK branch runs), and 'cohere' backs both the Embeddings block
and Knowledge Base reranking — but neither appeared in the settings list, so
there was no way to store the key either path looks for.

Cohere had no icon; add one from the official multi-color mark so it stays
legible on a light and a dark page.

Cohere's embed-v4.0 is kbEligible:false, so the description says 'Embeddings
and Knowledge Base reranking' rather than claiming KB embeddings.

* improvement(byok): shorten the workspace scope chip to 'Workspace'

It sits beside 'Organization', so the scope reads from the pair; 'This'
only added width.

* fix(byok): do not cache a billing outage as an unentitled organization

resolveOrganizationPlan maps a failed billing read to false, which is
indistinguishable from a real plan lapse. The entitlement cache stored that,
so one transient outage held the gate shut for the full TTL and every
inheriting run silently fell back to a metered hosted key — and the cache's
rejection path, which exists to prevent exactly this, was unreachable.

Give the resolver the onError option its neighbours already have and let the
cached read ask for 'throw', so a failure stays out of the cache and the next
resolution retries. Behavior for the call that saw the error is unchanged:
getBYOKKey still fails closed.

Reported by Cursor Bugbot.

* fix(byok): propagate the subscription read's failure too

The previous commit threaded onError through resolveOrganizationPlan's own
catch, but getOrganizationSubscriptionUsable soft-fails to null on its own, so
a failed subscription read still arrived as an ordinary 'no usable
subscription' and returned a successful false — which the entitlement cache
then stored for the full TTL. Thread the option into that call as well.

Test it at the billing layer rather than the cache layer: the entitlement test
mocks resolveOrganizationPlan wholesale, so it could never have caught this.
Verified the new test fails against the previous commit.

Reported by Cursor Bugbot.

* refactor(byok): cache the entitlement with LRUCache, like copilot entitlements

The hand-rolled version reinvented three things the codebase already has a
canonical answer for. lru-cache is a declared dependency of apps/sim and
lib/copilot/entitlements.ts already caches an entitlement with it — by storing
the in-flight Promise, which is what makes concurrent callers collapse onto one
resolution with no in-flight bookkeeping at all. TTL and the size bound come
from the library.

That removes the second Map, the manual eviction (and its interaction with an
in-flight entry), and the dead value-while-refreshing state: 23 executable
lines. The one thing the library does not cover is dropping a rejected promise
so a billing outage is not cached for the TTL, which is kept and pinned by a
test that fails without it.

TTL expiry is no longer re-tested — that is the library's behavior, not ours,
and lru-cache reads its clock at module load so faking timers never moved it.

* refactor(byok): coalesce the entitlement read with the shared singleflight

lib/concurrency/singleflight.ts is the codebase's coalescing primitive and
oauth/credential-service.ts already pairs it with a read-through cache. Adopting
that shape fixes a case caching the promise directly did not: a *hung* billing
read wedged every caller for the full 60s TTL, where coalesceLocally evicts and
rejects at its settle deadline.

It also removes the hand-rolled rejection eviction — the cache is written only
on the success path, so an outage leaves no entry by construction.

The cache now holds booleans, which introduces the one trap worth a test: a
truthiness check would read a cached false as a miss and re-query billing on
every resolution for lapsed organizations. Pinned.

* fix(byok): keep an abandoned entitlement producer from writing the cache

coalesceLocally does not cancel a producer it timed out — its docstring says so
explicitly — so writing the cache from inside the producer let a late billing
result overwrite a fresher answer a retry had already cached, and hold it for a
full TTL.

Move the write onto the value the caller actually received. A caller that timed
out throws before reaching it, so an abandoned producer now resolves into
nothing. The test reproduces the overwrite and fails against the previous shape.

Reported by Cursor Bugbot.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-08-20 17:45:28 -07:00
Waleed e4a1fbeaae fix(og): put the landing model and integration cards on the brandbook template (#6913)
* fix(og): put the landing model and integration cards on the brandbook template

The five models/integrations cards were the last ones still rendering the
retired dark card with the green square-icon logo. They now share the cover
renderer the library, docs, and shared-file cards use, which retires
og-utils and the assets only it referenced.

Captions wrap to two lines so a catalog description survives the move; the
eyebrow, pill row, and domain label have no slot in the reference template
and are dropped, with the counts they carried folded into the two index
captions instead.

* fix(og): drop the dead hard-space helper and restore the unknown-context copy

withHardSpaces lost its last caller when captions moved onto wrapLines, which
packs the non-breaking space inline; its rationale moves to wrapLines with it.

A missing context window rendered as a bare "context window", reading as a
field label mid-sentence. formatTokenCount already returns "Unknown" for a
null value, so the ternary was both redundant and wrong.
2026-08-20 17:34:58 -07:00
Waleed 040f40cd61 fix(connectors): stop rendering running and crashed syncs as successes (#6910)
* fix(connectors): stop rendering running and crashed syncs as successes

The sync-history row derived its state from status literals the engine never
writes — 'running', 'syncing', 'error' are connector statuses, not sync-log
statuses. The in-progress branch was therefore false for every row that will
ever exist, so a live sync rendered as a green success tick for its whole
duration and a killed run left a permanent fake success reading "No changes".

- Derive the row state from the three statuses the engine actually writes
- Narrow the sync-log status contract from z.string() to an enum, and make the
  render switch exhaustive, so producer drift fails response validation and
  consumer drift fails the build
- Add an interrupted state for a started row older than the stale-lock TTL: it
  is a crashed run, not a live one, and rendering it as a permanent spinner
  would trade one false signal for another

* docs(connectors): record why the stale TTL is a hard ceiling for the interrupted state
2026-08-20 17:34:03 -07:00
Waleed 451d2ccbde fix(knowledge): stop billing a document once per processing attempt (#6911)
The usage sourceReference carried a per-attempt timestamp, which defeated by
construction the eventKey deduplication it flows into. With three Trigger
attempts plus the stuck-document sweep, one document could be billed four times
for a single indexing pass.

Key on the dispatch request id instead — already the codebase's name for one
indexing pass, fixed across attempts of a run and fresh on every new dispatch —
so retries collapse while a genuine re-index still bills. Falls back to the
embedding pricing id where no pass id is threaded. usage_log has no retention,
so keying on documentId alone would have suppressed legitimate re-indexing
permanently.

Thread requestId through the Trigger worker, which previously dropped it before
the call — the path that actually retries.
2026-08-20 17:33:50 -07:00
Bill Leoutsakos ea70f8dcf1 feat(harmonic): add contact workflow integration (#6902)
* feat(harmonic): add contact workflow integration

* fix(harmonic): sync docs manifest

* fix(harmonic): address integration review findings

* feat(harmonic): add the missing people endpoints and fix two error paths

Extends the integration from 4 to 13 tools, covering every non-deprecated
people-scoped Harmonic endpoint, and repairs two defects found by validating
the existing tools against Harmonic's OpenAPI and API reference.

New tools:
- Enrich Person (POST /persons) — the only path from a LinkedIn URL or email
  a workflow already holds to a Harmonic contact.
- Get Person, Get Company Employees — account-based sourcing; employees returns
  URNs that chain into Batch Get People.
- Saved-search net-new results and their acknowledgement, so a monitor stops
  reprocessing the entire result set on every poll.
- Bulk email enrichment: submit, poll, and quota, plus Get Enrichment Status.

Fixes:
- The error extractor dropped Harmonic's string and object `detail` envelopes.
  A tool that names an extractor gets no fallback chain, so every FastAPI abort
  surfaced as "Request failed with status 403". The enrichment 404 also carries
  the scheduled `enrichment_urn`, which was being discarded — that URN is the
  only handle on the job, so it is now kept in the message.
- The saved-search selector failed the whole dropdown instead of degrading:
  the response cap was half the sibling value on an endpoint that is
  unpaginated and returns every saved search with its full query object, and
  the option ceiling threw rather than truncating. Raised to 1MB and switched
  to truncate-and-warn, matching the other data-driven selectors.

Clearing net-new results now requires an explicit scope. Harmonic treats an
absent `entity_urns` as "clear everything", so an empty field would have
silently discarded the backlog.

Scope deliberately excludes company-side, deal, typeahead, network, and Scout
streaming endpoints, and every endpoint retiring on 2026-11-05.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-20 17:12:57 -07:00
Waleed 893e729b3f fix(og): put the shared-file card on the brandbook cover template (#6907)
* fix(og): put the shared-file card on the brandbook cover template

* fix(og): bound the cover title and caption to the fixed canvas

* fix(og): measure cover text with the font's real advance widths

An average glyph width under-measures caps-heavy names and over-measures
narrow ones, so a viewer-supplied file name could still clip off the fixed
canvas. Measure against the same font Satori is handed instead, matching
the library cover generator; the tests parse the font independently so the
assertion is not made with the estimator it is checking.

* fix(og): apply the leading compensation to the title, not the whole footer

On the footer the 14px nudge dragged the caption into the bottom padding as
well, and the caption has no phantom leading to correct for. Matches how the
sibling cover renderers scope the same offset.
2026-08-20 16:28:09 -07:00
Waleed 865f8173ab feat(affinity): add Affinity CRM integration (#6908)
* feat(affinity): add Affinity CRM integration

Adds the Affinity v2 API as a block with 70 tools, covering 86 of the 87
documented endpoints. Only Send Feedback is omitted — it reports product
feedback to Affinity rather than doing workflow work.

Endpoint families that differ only by an entity segment are one tool with an
entityType param, so companies/persons field, list, row, and relationship
reads, the company/person merge endpoints, and entity notes each collapse
into a single operation.

* chore(affinity): regenerate the docs manifest for the new integration page
2026-08-20 16:27:29 -07:00
Theodore Li a99f61bee9 feat(api): add v2 resource management endpoints (#6900)
* feat(api): add v2 resource management endpoints

* fix(cli): gate destructive v2 commands

* fix(test): update v2 request-slice count

* feat(cli): add shared workspace profiles
2026-08-20 18:55:57 -04:00
Vikhyath Mondreti 85290693c4 refactor(search): one definition of what a search occurrence is (#6905)
Follow-up to #6901, closing two places where the workflow search index and the
Note card that mirrors it could drift apart. Neither is a live bug; both are the
shape that produced one — the card silently disagreeing with the panel about
which hit is which, counted in one place and painted in another.

THE SCAN. #6901 shared `foldSearchWhitespace` but left the scan around it
duplicated: normalize, then non-overlapping `indexOf` stepping by
`max(len, 1)`, written out once in the indexer and once in the renderer package.
They agree today. They would stop agreeing the moment either grew whole-word
matching, diacritic folding, or a regex mode, and the failure is silent. Both
now call one `forEachSearchOccurrence` in `@sim/utils/string` — the only place
either package can share, since the card renders from a package that cannot
import from `apps/*`.

THE DECLARATION. The indexer projects markdown escapes only for a field
declaring `searchTextFormat: 'markdown'`; the card projects unconditionally,
because it cannot read the block registry. Dropping that one line from the Note
config would leave them disagreeing with nothing to catch it, so a test now
pins it and explains why.

Net negative in lines: this deletes a duplicated loop rather than adding a
layer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 15:44:22 -07:00