Skip to content

v0.8.0: new workflow UI, v2 API, salesforce JWT, provenance improvements, incidentio tools, block retries - #6533

Closed
waleedlatif1 wants to merge 40 commits into
mainfrom
staging
Closed

v0.8.0: new workflow UI, v2 API, salesforce JWT, provenance improvements, incidentio tools, block retries#6533
waleedlatif1 wants to merge 40 commits into
mainfrom
staging

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

j15z and others added 14 commits August 10, 2026 18:08
…6513)

* fix(provenance): name every guard that can latch a registry

A production latch reported `reason: "unspecified"` because 44 call sites took
the default. The reason is the only thing that names which guard tripped, and a
refusal surfaces many frames later as one fixed sentence, so an unnamed latch is
undiagnosable — that is what left an incident's origin unidentified for a day.

Give each call site a literal that names its guard, add the 20 new literals to
the reason union, and sort them into the existing error/warn split: a guard that
should not trip on a healthy run reports at error, everything else stays at warn.
`log-creation-skipped` joins the by-design set since it fires on every run that
does not persist a log.

Make `reason` required on both `markIncomplete` and `markInputPathIncomplete`, so
omission is a compile error rather than a silent `unspecified`. A caller with
genuinely nothing to say now passes `'unspecified'` where a reviewer can see it.

The three remaining bare calls are on ResolvedSecretTraceProvenanceAccumulator,
a different class with no reason concept.

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

* test(provenance): pin the new reasons and the guard that latched in production

Cover what the reason set is for rather than only that it compiles: the
non-enumerable tool-params branch now asserts it names `tool-input-not-enumerable`,
which is the guard the production logs showed reporting `unspecified`, and every
new literal asserts which stream it reports on — error for a guard that cannot
trip on a healthy run, warn for one reachable without a fault, silent for the
by-design log-less session.

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

* refactor(provenance): make reason the only locator, not one of two

Two fields had grown into competing answers to the same question. `origin` is a
free-form label for which importer accepted an already-incomplete bundle; four
latches had started passing `markIncomplete('unspecified', { origin })`, using it
to stand in for a reason that did not exist yet. That splits one fact across a
closed enum and an open string, leaving neither worth alerting on.

Give those four the literal they were reaching for — none needed a new one — and
split the five reasons that covered genuinely different guards, so the reason
alone locates the site rather than needing an origin beside it. `origin` keeps its
narrow job, now documented: it disambiguates importers that share one guard, and a
latch that wants an origin because no reason fits should add a reason instead.

No production call site passes 'unspecified' any more.

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

* fix(provenance): stop two expected states from reporting as faults

Cursor Bugbot caught `backfill-checkpoint-*`: the guard covered four conditions
under one reason classified as an originating fault, and one of them — a state
persisted before the checkpoint contract existed — is what essentially every
legacy row looks like. A backfill over historical rows would have put one error
line per row into the stream the error/warn split exists to protect.

Auditing the rest of the error-level reasons for the same shape found a second:
a client tool invoked without a run id has no binding to unseal against, so it
took the `[null, null]` path and reported `client-tool-seal-failed` at error on an
ordinary configuration.

Split both along the line that matters — absent versus unusable, not attempted
versus failed — and classify each half: expected states warn, genuine faults keep
error. `backfill-scope-mismatch` is retired; it named one of its four conditions.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(comparisons): add code-sandbox and session-policy rows

Add two comparison rows covering configurable code sandboxes and
admin-configurable session policy, populated for Sim and all 20
competitor profiles. Source Sim's facts from the docs rather than the
codebase, and repoint the execution-limits row at the Run Time Limits
table.

* fix(comparisons): resolve customCodeSteps contradictions for Retool and Workato

Both profiles documented code steps in the new codeSandboxRuntime row
while their adjacent customCodeSteps row still read Unknown/unclear,
so the rendered table gave contradictory answers for the same
capability. Settle both from the vendor docs already cited by the
sandbox row.
…rs (#6515)

* fix(folders): show the folder trail in table and knowledge base headers

A table or knowledge base opened from inside a folder rendered `Tables / name`,
dropping every folder between it and the root — while a file's header showed the
full `Files / docs / name` path. The detail pages never read the resource's own
`folderId`, so the trail could not include it.

Converge all six foldered surfaces on one builder instead of fixing the two
headers in place:

- `folderAncestorChain(folderId, lookup)` in `lib/folders/tree.ts` is now the
  single upward walk. Both `getFolderPath` variants delegate to it; the two lock
  predicates deliberately keep their inline walks, which short-circuit at the
  first locked ancestor on a per-row render path.
- `folderBreadcrumbItems` takes a `trailing` slot for detail pages, as a
  discriminated union so an open-folder rename cannot be passed alongside it and
  silently dropped.
- `useFolderAncestors` owns the tree plus the `foldersResolved` staleness rule;
  `useFolderNavigation` now delegates to it.
- `FOLDERED_RESOURCE_HEADERS` owns each resource's root label, root icon, and
  list path, which seven sites previously restated.

Files' list trail moved off splitting the materialized `path` string, which
could not tell two same-named siblings apart, onto the shared parentId walk.

Also fixes the Files loading trail, whose folder crumbs used the nuqs setter
while rendering on the file detail route — appending `?folderId=` to the open
file's own URL instead of navigating to the list.

* fix(knowledge): confirm before a breadcrumb navigates away from an unsaved chunk

* fix(knowledge): source the pluralized root label from the folder registry

* chore(folders): tighten the shared breadcrumb docs and dedupe the ancestry type
… dsl for natural language preview, retry configs (#6458)

* improvement(workflow): refine canvas interactions and rendering

* fix(workflow): keep outputs on the right, focus newly created blocks

Connection anchors: an output now always leaves a card from the right.
The cursor swell lets a drag start on any edge, but the left side is the
input, so anchoring an outgoing edge there drew a line out of the input
port and read as a second input. `normalizeCursorSourceHandleId` resolves
every drag to the right anchor, `normalizePositionedSourceHandleId`
collapses `source-left` alongside the legacy vertical anchors (so data
from the API, an older client, or a stale save self-heals on load), and
only the right-side source anchor is mounted.

Drops in `onConnectEnd` are always source -> target. The branch that
reversed the edge for a drag starting on an input could never run: the
`target` handle is `isConnectableStart={false}` and the positioned side
anchors are `isConnectable={false}`, so React Flow never reports an input
as a drag origin. Removed it and its now-unused imports.

A newly created block is centered once its node mounts and is measured,
so a card added from a drag-release, the block menu, or the toolbar is
never left off-screen or under the editor panel.

The editor panel's block icon uses the same type accent as the card's
badge instead of the block's legacy `bgColor`, which had left the panel
on the old per-integration brand colours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(workflow): floor header-only card height, adopt brand tag palette

The Start card intermittently collapsed after load, squashing the
action-menu tab so its icon row sat over the card.

`.workflow-drag-handle` is the host the border renderer measures, and both
it and the header row took their height from `blockHeight && blockHeight >
0`. `blockHeight` comes from the deterministic-dimensions pass and is
already floored at MIN_PAINTED_HEIGHT (48), but it is absent on the first
frames — and with no floor the host collapsed to its natural content
height (25.5px for a header-only trigger, exactly the title's line box).
The border builds its perimeter from `host.offsetHeight`, so that window
painted a sub-floor card: too little straight edge remained on the
vertical runs for the action-menu tab, which collapsed into the corner
arcs. Whether you saw it depended purely on whether the dimension publish
had landed, which is why it reproduced on one workflow and not another.

Floor all three: the host, the header row (so `items-center` centres the
title and type tag rather than pinning them to the top), and the border's
own `offsetHeight` read.

Also raise ACTION_MENU_CONTENT_READY_THRESHOLD to 0.9. At 0.8 the 24px
icon row was revealed while the swell had only reached 22.4px of its 28px
— shorter than the row it contains. Secondary to the above, but a real
overflow window on its own. The test now pins the ratio rather than the
constant.

Tag palette moves to fixed brand values (hex, not derived oklch) with two
inks — #F8F8F8 on dark fills, #1A1A1A on light. Tones are renamed to match
what they render. `green` (2.55:1) and `orange` (3.15:1) sit under WCAG AA
against their paired ink; both are deliberate brand decisions and are
documented in the component.

Deploy and Run take two new Button variants rather than className
overrides, so `tertiary` stays green everywhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* improvement(workflow): polish workflow canvas interactions

* fix(workflow): restyle loop drop target outline

* fix(workflow): shorten human block catalog label

* fix(workflow): canonicalize realtime edge handles

* code cleanup

* sizing fixes

* improvement(blocks): sentencify every block

* change tebse

* improvement(workflow): land notes UI and execution progress, consolidate duplicates

Ports the notes canvas editing and execution progress-indicator work, then
removes the parallel paths it arrived with so each concern has one owner.

Fixes found while consolidating:

- Note height was measured while the card was expanded to NOTE_EXPANDED_WIDTH,
  where text re-wraps shorter, and published as the node's compact height. The
  collapse then animated to a height never measured at the compact width.
- The edge pulse glow filter used the default objectBoundingBox units, so a
  straight horizontal edge — what an auto-laid-out chain produces — resolved the
  filter region to zero height and stopped the glow rendering entirely.
- A subflow's inner Start pill still read isNodeSelected while its border read
  usesSelectedVisuals, so the two disagreed during execution.
- The Run/Stop button's disabled prop gated only Run while its handler cancelled
  unconditionally, offering a Stop the cancel route answers with 403.

Consolidated:

- One note editor. The view's built-in textarea was unreachable in production
  (the app always injects the markdown editor) and was kept alive only by tests
  asserting against it; renderContentEditor is now required.
- onBlur/onCancel collapse to onEndEditing — content persists per keystroke, so
  there was never a draft for a cancel path to discard.
- DEFAULT_NOTE_COLOR, the note height bounds, the note content reader and the
  card width each had two or three definitions; each now has one.
- Removed with zero consumers: graphite/graphiteSubtle button variants,
  data-subflow-selected, inputPlaceholderClassName, an effect that could never
  fire, and getNoteColorOption's unreachable fallbacks.

Restores the role='status' announcement the progress rewrite dropped, and hardens
isNoteColor against inherited Object keys.

Co-Authored-By: Claude <noreply@anthropic.com>

* improvement(workflow): reuse the platform markdown editor in notes

Notes carried their own TipTap wiring — a second markdown editor that
reimplemented, more thinly, what `RichMarkdownField` already does for the skill
modal, skill fields and the deploy version description. It is now a ~20 line
skin: the Note supplies its type scale and per-colour selection tint, and the
field supplies the extension set, frontmatter held out-of-band, the round-trip
safety gate and its raw-source fallback, and markdown paste.

`RichMarkdownField` gains two additive props, both defaulting to today's
behaviour so the file editor is untouched: `surface` ('field' | 'bare') and
`proseClassName`. All three existing consumers pass an explicit `minHeight` and
no `surface`, so they take the original path unchanged.

Exiting the note editor moved to the card, because the editor's `/` and `@`
menus consume Escape to close themselves and ProseMirror checks `editorProps`
before plugin handlers — intercepting it inside the editor would have broken
both menus. The card now honours Escape only when nothing already consumed it,
which also let `onEndEditing` leave the injection contract.

The note editor is lazy now, matching every other consumer: it was pulling
TipTap and the full extension set into the canvas's initial chunk.

Also:

- One `areRunFromBlockDependenciesSatisfied`. The ActionBar, the canvas context
  menu and the run-from-block handler each carried a byte-identical copy, and
  the handler expressed the snapshot requirement differently, so the affordance
  and the action could disagree. Each copy also re-scanned `edges` once per
  incoming edge, on every ActionBar on the canvas.
- Reduced motion is one `usePrefersReducedMotion` in @sim/emcn rather than a
  sixth ad-hoc `matchMedia`. The edge pulse now stops rendering instead of
  hiding: `motion-reduce:hidden` is `display: none`, which left four SMIL
  timelines running per edge.
- The pulse glow bleed covers the canvas minimum zoom. The strokes are
  `non-scaling-stroke`, so the 6px tail spans 3/zoom user units — 30 at 0.1.

Co-Authored-By: Claude <noreply@anthropic.com>

* improvement(workflow): port canvas styling from workflow-updates

Ports the 14 styling commits your colleague added since the last sync, leaving
the ~68 staging PRs on that branch alone — those are platform/core work, not
this. Cherry-picked individually rather than merged so each conflict was small
enough to reason about.

What came in:

- Core block colors unify behind a two-level map: block type -> semantic role
  -> accent, replacing the flat per-type table. Adds `purple` and `content`
  tones to ChipTag, and a shared `WorkflowTypeIcon` that replaces the
  hand-rolled ChipTag + accent lookup at each discovery surface.
- Native triggers take semantic colors; the deployments block moves to the
  shared Rocket icon and drops its now-unused `iconColor`.
- Running-state polish: loader artwork and position, stop hover in dark mode,
  the loader blended into the execution swell, and tooltips suppressed for
  actions that are hidden mid-run.
- The toolbar drag preview clones the rendered icon container instead of
  rebuilding a bgColor tile, so it matches what the canvas paints.
- The sidebar shows route-derived workspace identity instead of a skeleton
  while the full record loads.

Conflict resolutions worth knowing:

- The running-loader artwork went through the shared `Loader` and back to the
  custom SVG on their branch; the second commit is the intent, so that is what
  landed — keeping our `role='status'` announcement layered on top.
- Two commits carried the lucide-react -> in-house icon migration along with
  them. That migration is a staging change we have not taken, so our imports
  stayed on lucide: adopting it in two files would leave the icon set split
  across the app.
- `getMappedWorkflowTypeAccent` referenced a constant their refactor removed.
  It had no consumers left once the search modal moved to `WorkflowTypeIcon`,
  and their branch deletes it too, so it is gone here.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workflow): make the subflow Start swell a real connection source

Dragging an edge into whitespace opens the add-block picker, but starting that
drag from a loop/parallel Start pill did nothing: the pill's border swell was
visual-only. Regular blocks and the container's own exit mint a draggable
cursor handle from their swell; the pill rendered only its invisible 14px
static strip, so grabbing the glowing affordance started no connection at all.

Everything downstream already worked and was nearly unreachable:
- the drop hit-test skips subflow containers, so a release inside the loop
  opens the picker there (z 2000, above containers)
- handleToolbarDrop parents the new block into the container at the drop point
- it already carries the exact boundary rule for this source: a container
  start handle only wires to a child of that container

The pill now runs the same cursor-handle machinery as the container view, with
one deliberate difference: its temporary handle carries the branch-cursor form
of the start id. The plain cursor id normalizes by block type — for a
container that is `loop-end-source`/`parallel-end-source`, the exit — so a
swell drag from Start would have persisted as an edge leaving the container.
The branch form passes `loop-start-source`/`parallel-start-source` through
normalization verbatim on both the picker and direct-connect paths; a test
pins that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(notes): stop the field's prose classes recoloring bare-surface editing

Opening a note for editing shifted the text and turned it black: the
ProseMirror root unconditionally carried `rich-markdown-prose
rich-markdown-field-prose`, which pin the field's own ink and type ramp —
`--text-primary` at 15px/25px, then 14px/22px — overriding the card's
`text-current` at 14px/20px the moment the editor mounted.

`surface='bare'` means the host owns typography (the Note card mirrors its
rendered view via `proseClassName`), so on that surface the root now carries no
shared prose classes. The field surface is untouched. Edit mode inherits the
note colour's ink — including the caret — and sits on the same metrics as the
read view.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(workflow): give the in-flight connection line contrast inside containers

The drag line was drawn but camouflaged: its default stroke was the
resting-edge grey (#e0e0e0), which disappears against a loop body's opaque
`--surface-3` fill (~1.1:1) — so dragging an edge inside any container, nested
included, showed nothing. The z-order was never the problem; the connection
line layer already sits above every node.

The default token is now `--text-muted`, one value with contrast on every
canvas surface, still lighter than the `selected` variant so the variant
hierarchy holds. No per-surface special-casing.

Resting edges inside containers share the same camouflage (`--workflow-edge` on
`--surface-3`) — left alone deliberately: recoloring placed edges is a design
decision, not a bug fix.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(notes): match edit mode to the read view, and land the caret where clicked

Three defects, all from the read and edit views being built independently.

1. Blocks jumped up ~12px on entering edit mode. Streamdown wraps its output in
   a container carrying `space-y-4` plus first/last margin resets, which outrank
   the per-element margins in NOTE_COMPONENTS — so that wrapper, not those
   margins, is what the read view actually paints. The editor had no equivalent.
   The rhythm is now named (NOTE_MARKDOWN_FLOW), passed to Streamdown
   explicitly so a dependency upgrade cannot move the read view out from under
   the editor, and mirrored on the ProseMirror root. Tailwind's JIT only sees
   literal strings so the mirror cannot be composed from the constant; a test
   pins the two together instead, and fails if either side drifts.

2. The caret was barely visible: it inherited the note's 75%-opacity ink. The
   palette owns per-colour chrome, so it now names the caret alongside the
   selection tint.

3. The caret always landed at the document end. The read view sits under a
   full-bleed overlay that must swallow the click to enter editing, so the
   point never reached the editor and `autofocus: 'end'` was all that was left.
   The view now forwards that point and the field resolves it through
   `posAtCoords` on create — after the DOM is laid out, which `autofocus`
   cannot wait for. Keyboard activation carries no point and still lands at the
   end.

`autoFocusAt` is additive on the shared field and defaults to null, so the file
editor and the other three consumers are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(workflow): stop edges rendering behind top-level subflows

Containers are z-indexed by nesting depth, so a top-level subflow is 0. Edges
derived their z from their parent container — `+1`, or 0 with no parent — so a
root-level edge landed on exactly the same z as a root-level subflow. Equal
z-index falls back to DOM order, and React Flow paints the nodes layer after
the edges layer, so the container's opaque body won: any edge crossing a
top-level loop or parallel was drawn behind it, in-flight or persisted.

Edges now sit in their own band above the whole container scale and below
cards, keeping both the deeper-container-wins ordering and the rule that a line
always passes behind card chrome. This is why the edge became visible only once
a block was dropped: the new block is selected, and an edge inside the
container was already `containerZ + 1`, clear of the tie.

The in-flight connection line is declared in the same scale rather than
inheriting React Flow's stylesheet default of 1001, which is both below a
selected container child and outside the scale this file owns. Its stroke moves
to `--text-secondary`, the token the canvas already uses for an active edge —
the previous `--workflow-edge` grey is ~1.1:1 against a subflow body.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(notes): paste and drop images through the workspace-file pipeline

Traced the file editor's image path end to end and reused it verbatim rather
than minting a note-specific source: insertImages -> useUploadWorkspaceFile ->
POST /api/workspaces/{id}/files/presigned -> direct-to-S3 PUT -> workspace_files
row -> the editor persists the workspace-scoped
/api/workspaces/{id}/files/inline URL, which the serve route authorizes by
workspace membership and the embedded-image-ref machinery already recognizes
for share rewriting and referenced-by-doc tracking.

The shared field gains an optional `uploadImage(file) -> {url, alt} | null`
capability. With it, image paste/drop uploads sequentially and inserts each at
the evolving position, mirroring the file editor's flow, with a bail if the
editor unmounts mid-upload; without it, the existing swallow-guard on file
drops is unchanged, so the skill modal, skill fields and version-description
consumers behave exactly as before. The upload mutation owns its own toasts.

The note host wires the capability with folderId null, so note images land in
the workspace Files root — visible, manageable and deletable there like any
other upload. The note read view renders images through its Streamdown
components map with the card's own sizing.

Co-Authored-By: Claude <noreply@anthropic.com>

* improvement for notes, subflows

* fix(uploads): surface the server's message when a multipart upload is refused

A file over the 50MB direct-PUT threshold goes through multipart initiate, which
is where the storage quota is enforced — but the client threw away the response
body and reported `Failed to initiate multipart upload: Payload Too Large`. That
is the string the upload mutation puts in its toast, and it names neither which
limit was hit nor by how much, so the one place that answer surfaces didn't have
it.

It now prefers `errorBody.error` exactly as `getPresignedUploadInfo` already does
on the single-PUT path, and passes the body through as the error's details.
Control flow is unchanged: still throws, still `MULTIPART_ERROR`, and the
cloud-storage-absent branch above still claims its 400 first.

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

* fix(notes): restore GFM in the note read view

Streamdown's `remarkPlugins` prop REPLACES its default plugin list rather than
extending it, and remark-gfm is one of those defaults. The note passed
`[remarkBreaks]` — so the read view silently lost every GFM construct: task
lists, tables, strikethrough and autolinks.

The editor writes all of them (it has TaskList, TableKit and Strike), so a note
round-tripped through editing came back as raw source the moment editing closed:
`- [x] HELLO` rendered as a disc bullet followed by the literal text `[x] HELLO`.
`NOTE_COMPONENTS` has carried table/thead/tbody/tr/th/td entries this whole time
that could never fire.

Restoring the plugin is only half of it: remark-gfm marks a checklist
`contains-task-list` and emits a native checkbox, which under the note's generic
`ul` styling renders a checkbox sitting behind a disc bullet — the same defect
the editor had before the chrome/typography split. The read view now drops the
marker and indent for a task list, lays the row out as a flex line, and styles
the checkbox to match `.rich-markdown-nodes input[type="checkbox"]` declaration
for declaration, tick clip-path included, so the two views agree either side of a
click.

`remark-gfm` is now a declared dependency of the renderer package rather than one
borrowed transitively from streamdown.

Five tests cover the GFM surface — checked/unchecked boxes, no literal `[x]`, the
marker only dropped for checklists, tables, strikethrough — and four go red with
the plugin removed.

Checked the other three Streamdown call sites (Chat, the chat interface renderer,
the changelog): none override `remarkPlugins`, so none were affected.

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

* refactor(canvas): drop dead markers and share the tile-brightness maths

Review pass over the branch against staging.

Dead code removed:
- `tileIconColorClass` in the renderer package — never called; only its
  `isLightTileColor` sibling is.
- `data-connection-selector-search-frost`, `data-workflow-cursor-edge` and
  `data-workflow-cursor-source-side` — written on three elements, read by no
  stylesheet, selector or test.
- `CHIP_TARGET_SELECTOR_TYPES`, `MAX_CHIPS` and `chipPriority` were exported from
  `canvas-rows.ts` but only used inside it.

Consolidated the one real divergence: the renderer package carried a hand-copied
mirror of the app's perceived-brightness maths, because it may not import app
code. The copy had already drifted — it dropped the `white`/`black` keyword
handling, so a block shipping `bgColor: 'white'` would render a white
`currentColor` icon on a white tile on the canvas while every other surface drew
it black. No block ships one today, which is exactly why nothing caught it. The
function now lives in `@sim/utils/color` and both sides import it; only the
0.75 threshold stays local to each.

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

* refactor(canvas): share the z-scale, fix the preview's edge layering, and re-home strays

The preview canvas carried its own z numbers and had the collision the editor
canvas was fixed for: containers at nesting depth, top-level cards at an implicit
0, and edges at 0/5/10 by execution status — so a default edge tied with a
top-level subflow and painted behind it, while a success edge painted over
unselected cards.

The scale now lives once, in `@sim/workflow-renderer/canvas-layers`, and both
canvases read it. The preview keeps its status ordering, expressed inside the
shared edge band rather than as a second set of magic numbers.

Placement and duplication:

- `perceivedBrightness` moved to `@sim/utils/color`, with its unit test, and its
  consumers import it directly. It had been re-exported through
  `lib/colors/brightness.ts`, and the renderer package kept a hand-copy.
- `filterAcyclicEdges`/`wouldCreateCycle` were pass-through wrappers in the
  workflow store's utils over the real implementations in `@sim/workflow-types`.
  Deleted; the three consumers import the source.
- `lib/ui/glass-surface.ts` was a one-constant, one-consumer app-wide module, and
  its consumer then aliased it a second time. Collapsed into the navbar shell.
- `nested-subflow-node` was set on nested container nodes in both canvases with no
  stylesheet, selector or test behind it.

`packages/workflow-renderer` now has its own vitest config, so the four mount
tests for its components live with the components instead of in
`apps/sim/lib/workflows/**`. That immediately earned its keep: `apps/sim`
excludes test files from type-check, and once these were checked, tsc found three
`SubflowNodeView` renders being handed a `renderContentEditor` prop it does not
accept — a copy-paste from the note cases that had been silently ignored.

Verified: type-check 23/23, 21,143 app tests + 49 renderer + 147 utils, biome
clean, all 23 audits pass (`check:bare-icons` imported the moved helper and was
repointed).

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

* fix field noun bug + notes

* fix(notes): let a note service the canvas actions the panel editor cannot

The panel editor clears any note put in front of it and renders nothing, but the
block menu still routed Rename and Open Editor through it.

Rename latched the editor's rename state onto the note — `handleStartRename`
reads the store directly, so it saw the id the menu had just set — and nothing
reset it when the clear ran. `handleSaveRename` writes to `renamingBlockIdRef`,
so the header went on showing a rename field over whatever was selected next and
saved that name to the note. Open Editor was a plain no-op that opened an empty
pane.

Rename now goes to the card, which expands and opens its own title — the same
menu-to-card routing Add Image already used, so both events now live in one
`lib/workflows/notes/canvas-requests.ts` and `add-image.ts` keeps only its
markdown concern. Open Editor is hidden for notes.

The panel editor also drops any rename whose block stops being the selected one.
That is belt-and-braces for notes now, but it closes the same hole for ordinary
blocks, where only the input's blur ended a rename and blur only fires if it held
focus. A rename interrupted that way is now discarded rather than left pending.

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

* feat(canvas): author sentences for Snowflake and Mintlify, repoint Instagram's

Staging's two new integrations shipped without a `canvasPresentation`, so their
39 operations painted the field rows the rest of the canvas has stopped using.

The Instagram break is the more interesting one: staging renamed the insight
metrics subblock `metrics` -> `insightMetrics` while this branch was adding
sentences that named `metrics`. Both hunks merged cleanly — the union check
reports the file as an exact union — and the result was two clauses pointing at
a field that no longer exists, which resolves to nothing with no error and no
log. Only `check:canvas-sentences` sees it.

Two Snowflake sentences say something the block does not do, so they anchor
elsewhere: `taskName` filters `list_task_runs`/`get_task_run` rather than keying
them, and `table` filters `introspect_schema` — blank means "every one", not "not
filled in yet", and a core chip would have claimed otherwise.

Coverage is back to 4727/4727 operations across 321/321 blocks.

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

* fix(canvas): keep the block-type tag naming its type after a rename

The header tag dropped its label whenever the block's title already said the
same word, so the same block read two ways depending on nothing the user did
deliberately: a freshly dropped Wait showed a bare icon, and its second copy —
auto-named "Wait 2" — showed "Wait". The tag looked like a badge that appeared
on rename rather than a fixed part of the header.

It now always names the type, which is what loop and parallel containers already
do with their own tag, so every card on the canvas reads the same way.

`blockName` was only ever read for that comparison, so the prop is gone rather
than left behind for a future reader to wonder about.

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

* fix(deploy): compare edge handles by port, not by spelling

Two places answer "does this need redeploying?" and they load their sides
differently. The client diffs the live store against `/api/workflows/[id]/deployed`;
the server diffs the normalized tables against the version's raw jsonb. Only
some of those paths run handles through `loadWorkflowFromNormalizedTables`, so a
snapshot holding a side-anchored id (`source-right`) met a canonical one
(`source`) on the other side and the set comparison read it as every edge being
removed and re-added.

Each answer therefore differed, and they arrive on separate query timelines: the
button reads the client's, the modal badge reads the server's, so the state
flipped between Live and "Update deployment" with whichever query landed last
until both settled.

`normalizeEdge` now canonicalizes both handles, so the comparison cannot tell
two spellings of one port apart no matter how its inputs were loaded. The
existing normalization in `materializeDeploymentState` stays — that path also
feeds React Flow, which needs the handle it mounts to match.

The preview's error port had the mirror problem: it rendered for every
non-trigger block regardless of `errorEnabled`, so a card with no error row grew
a red knob anyway. It now gates the way the editor canvas does, keeping the port
mounted when an error edge already leaves it so React Flow cannot drop that edge.

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

* fix(deploy): stop counting the error flag twice in change detection

`errorEnabled` has two homes. It persists inside the block's `data` jsonb — the
realtime server `jsonb_set`s it there, and load mirrors it back onto the block as
a field — so it reached the diff twice, and only some paths populate the copy.

`setBlockErrorEnabled` writes the mirror alone, so right after toggling the port
the live block said `errorEnabled: true` with `data.errorEnabled: false`, while
the snapshot the deploy had just taken from the tables said true in both. The
diff read the stale `data` and reported the workflow as changed the instant it
finished deploying — then a state refetch rehydrated the block and it agreed
again. That is the flip between Live and "Update deployment": the button and the
modal read two different queries, so each landing swapped the answer. A block
created in-session had the same shape from the other side, its `data` carrying no
key at all against a persisted `false`.

Excluded from `normalizeBlockData` alongside the other fields that are duplicated
out of the block's own state. The block field is still compared on its own, with
`!!`, so absent and `false` agree and turning the flag on is still a change.

Fixing the store to write both homes was the other option and is not taken:
nothing reads the in-memory `data.errorEnabled` (save and load both let the block
field win), so it would add a second copy that only the diff could see — which is
the shape of this bug, not its fix.

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

* refactor(blocks): give the error-output flag a column instead of two homes

`errorEnabled` had no column, so it persisted inside the block's `data` jsonb
and was mirrored onto the block as a field on load. Every writer had to route
its `data` through `withPersistedErrorEnabled` or silently drop the toggle, the
realtime op `jsonb_set`, and change detection saw the same value twice — which
is what made the deploy badge flip between Live and "Update deployment" after
toggling the port.

Its siblings — `enabled`, `horizontal_handles`, `advanced_mode`, `trigger_mode`,
`locked` — are all boolean columns; `data` is for React Flow and subflow state.
The flag belongs with them, so it now has `error_enabled` and one home. The
shuttle helper, its `BlockData` mirror, the store's fallback read, and the
comparison exclusion the duplication forced are all gone.

Backwards compatibility, since released versions draw the error port with no
toggle in front of it: a block already wired to an error edge HAS the output on,
because there was no other way to draw that edge. That rule is now stated in
three places and none may be narrowed to read the flag alone —

- the migration backfills `error_enabled` from the edges, so live rows are true
  before any new code reads them;
- `materializeDeploymentState` derives it for a version's frozen jsonb, which the
  migration cannot reach — otherwise every workflow deployed before the toggle
  would ask to be redeployed once;
- `workflow-block.tsx` keeps it at render time for states that reach the canvas
  through neither (imports, copilot edits), where unmounting the port would make
  React Flow drop the edge leaving it.

The migration also moves any `data.errorEnabled` a developer created on this
branch onto the column and strips the key; both statements match zero rows in
production, where it never shipped.

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

* fix(canvas): realign the Snowflake and Dynatrace sentences with staging's blocks

Both breaks are the class the union check cannot see: separate hunks of the same
file merged cleanly, and the result names fields that no longer exist. A sentence
that does resolves to nothing, with no throw and no log.

Snowflake's rewrite (#6474) moved database, schema, table, warehouse and
procedure onto canonical selector pairs, so seven clauses anchored on ids that
are gone. Each now names both members of its pair, which is also what keeps the
card readable for someone working in advanced mode. Its nine new operations have
sentences.

Dynatrace (#6463) scoped the mute reason to the operations that mute, because
unmuting accepts exactly one — so the two unmute sentences were asking for a
field their card no longer shows. They drop the clause.

Coverage is 4736/4736 operations across 321/321 blocks.

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

* fix(api): drop the block-data error flag from the workflow contract

Left behind by the consolidation: the flag no longer lives in `data`, and a
schema that still declares it there invites the mirror back through the wire.

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

* fix(deploy): compare edge handles by port, so a falsy one cannot read as changed

`loadWorkflowFromNormalizedTables` now runs handles through the canonicalizer,
which falsy-coalesces — so an edge persisted with `sourceHandle: ''` loads as no
handle at all. The server diffs that against the deployment version's raw jsonb,
which still has `''`, and the set comparison reads one edge as removed and
another added. Every workflow holding such an edge would ask to be redeployed
the moment this ships, for nothing. Two write paths use `?? null` rather than
`|| null`, so `''` is reachable.

Canonicalized inside `normalizeEdge` rather than at either call site: the two
sides are loaded by different paths and only some of them normalize, so the
comparison has to be unable to tell two spellings of one port apart however its
inputs arrived.

This is the change reverted in 066e18a. That revert reasoned only about
side-anchored ids, which are genuinely unreachable — it missed that the same
coalesce collapses the empty string, which is not.

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

* fix(deploy): keep ignoring the error flag's old home in block data

Deploying never converged: the badge asked to redeploy again the moment it
finished, forever.

The flag lived in `data` before it had a column. `0287` moves it, but a migration
only reaches the live tables — every deployment version already written is frozen
jsonb that keeps the old key. The wire schema no longer declares it either, so Zod
strips it from the live state on its way to the client. So the two sides of the
check genuinely differ: live `data: {}` against a snapshot's
`data: {errorEnabled: false}`, reported as `data.errorEnabled` changed. Deploying
cannot fix that — the next snapshot is taken from rows that still carry the key.

Confirmed against a real stuck workflow: 18 versions, both blocks reporting
`data.errorEnabled`, and the same two states comparing equal with this restored.

Removing the exclusion in 7934df7 assumed the migration could reach every copy
of the value. It cannot reach a frozen snapshot, so the comparison has to keep
tolerating the old key regardless of where it survives. The block field is still
compared on its own, with `!!`, so the flag itself is not ignored.

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

* revert(deploy): stop tolerating a block-data shape that never shipped

The flag's stint inside `data` began and ended on this branch: `main` and
`staging` have zero mentions of `errorEnabled`, and `ci.yml` gates every deploy
job on a push to main/staging/dev, so opening a PR deploys nothing. No released
version ever wrote the key, which means no production row and no production
deployment snapshot can hold one.

That makes the exclusion permanent code apologizing for a shape that cannot
reach the database it defends. Migration 0287 already strips the key from the
live tables, which is where a one-time data fix belongs; in production it matches
zero rows, and on a developer database it makes the next deploy write a clean
snapshot.

Reverts 5ece9f9. That fix was correct about the mechanism and wrong about the
scope: it read a local database as evidence about production.

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

* improvement(canvas): retract bystander cards during a run, slant the sweep mark

Two things about a running canvas.

A run pinned every card's action bar open and suspended every card's hover on
top of it, so the canvas became a wall of open swells that could neither retract
nor respond to a pointer — and the `isWorkflowRunning && !isRunning` hover
treatment already written for those cards was unreachable. Only the card that is
actually running is pinned now; the rest behave as they do at rest, which is what
makes hovering one bring its bar up again.

The sweep's filled slot painted a full 24px square. It now paints a slanted band
across the slot, as a hard-stop gradient rather than a `clip-path` — the two end
slots already carry one for the swell silhouette and a second would have to win a
specificity race with it. The stops hold `--surface-2` exactly, so only the shape
changes. Each variant is spelled out because Tailwind's JIT reads literal class
strings and a composed `hover-hover:${FILL}` compiles to nothing.

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

* improvement(canvas): fill the running sweep one way, and tighten its mark

The sweep drained back to empty after each pass, which reads as undoing the
progress the block is making. It fills left to right and starts over. The
direction flag goes with it — the state is just the count now.

The slanted mark also sat too far off its neighbours. Slant and tightness trade
against each other here: the transparent wedge has to be at least as wide as the
edge's horizontal travel, or the cut clips a corner instead of crossing the slot.
Leaning 7° off vertical instead of 17° travels 2.9px across the 24px slot rather
than 7.3px, which brings the wedge in from 26% to 12% — 3.2px a side against
7.8px, so the gap between marks drops from ~17.6px to ~8.4px with the slant
still crossing cleanly.

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

* improvement(canvas): fill the running bar once, edge to edge

The sweep restarted from empty every time it filled, so the bar kept re-running
ground it had already covered. It fills left to right once and holds.

The mark also sat inset in its slot, which put a gap on both sides of every join
and made the row read as separate chunks instead of one bar. It now spans its
slot edge to edge, leaving only the row's own `gap-[2px]` between marks, and
takes its weight off vertically instead: `bg-clip-content` with symmetric padding
paints a 10px band inside the 24px slot without changing the slot's size, so the
swell measured around it does not move. `--surface-2` is untouched.

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

* improvement(canvas): paint the running bar as right-leaning uprights

The fill read as a row of horizontal slabs. Each filled slot now paints one
narrow upright bar leaning right, so a run fills as `/ / / /`.

Geometry, since the two constraints fight: leaning the edge 15° off vertical
carries it 4.3px across the bar's 16px height, so the transparent margin has to
stay above 15% or the cut clips a corner instead of crossing top to bottom.
38%/62% leaves a 7px bar with room to spare. Height comes from `bg-clip-content`
plus symmetric padding, which does not change the slot's own size, so the swell
measured around it stays put. `--surface-2` is untouched.

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

* improvement(canvas): pitch the running hatch to the row, not to one bar a slot

One bar per 24px button left the rest of the button empty, so the marks
inherited the button grid's rhythm and sat ~19px apart — a row of isolated ticks
rather than a loader.

The fill repeats now, at a pitch that divides the row's own rhythm: a slot plus
its `gap-[2px]` is 26px, so a 13px horizontal pitch puts exactly two bars in
every slot and stays in phase across the gaps, including the 40px end slots.
Bars land every 13px with a uniform 6px between them, whatever the run's length.

Stops are measured along the 105° axis rather than horizontally, so they carry
the `sin(105°)` factor: a 7px bar on a 13px pitch is 6.76px on a 12.56px period.

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

* fix(canvas): paint the running hatch once across the row, not per slot

The bars came out bunched in some places and spread in others. Per-slot
backgrounds cannot avoid that: each button starts its own gradient at its own
origin, so the phase resets at every slot — and the end slots are 40px against
the others' 24px, so the resets are not even uniform. Three passes of tuning the
stops were all chasing a constraint the approach could not satisfy.

The hatch is now one element spanning the row, so there is one gradient and one
phase. It sits behind the buttons and grows by width: the run/stop button keeps
an opaque fill while running and masks the part growing underneath it, and every
other slot is transparent mid-sweep so the hatch reads through. The slots no
longer paint anything themselves, and the per-slot filled flag goes with them.

`--surface-2` is unchanged; only where it is painted moved.

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

* improvement(canvas): close the running hatch's gaps

The hatch ran a 50/50 duty cycle — 6px of fill to 6px of air — which read as
sparse. It now runs 8px to 3px.

Both stops are measured along the 105° axis rather than horizontally, so each
carries a `sin(105°)` factor; the note records that, and that closing the gap
further is a matter of moving the first stop toward the second.

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

* fix(emcn): drop the brand highlight from popover menus (#6506)

Context menus opted into a palette of their own — `variant='secondary'` for a
brand-blue row highlight and `colorScheme='inverted'` for a dark card — so the
canvas, block, toolbar, terminal, sidebar, and preview menus looked nothing like
the menus everywhere else in the product.

Removes both overrides so they inherit the same surface, border, and
`--surface-active` highlight the terminal's overflow menu already uses, and drops
the brand state from the Popover itself along with the `variant` prop that only
ever selected it. One fewer way to style a menu.

* feat(executor): opt-in per-block retry (#6505)

* feat(executor): opt-in per-block retry

Adds a per-block retry policy, off by default, surfaced in the editor's
additional-fields disclosure alongside the block's other advanced settings.

A block that opts in replays its handler while tries remain, then rethrows the
final error so the error port behaves exactly as it does for a block that never
retried — retrying only delays the existing outcome, never changes it. Retry is
deliberately indiscriminate about the failure, since there is no reliable way to
tell a transient error from a permanent one and classifying would silently do
nothing for the generic errors people turn it on for. Only throws that are not
failures are excluded: a deliberate stop, a child workflow whose own blocks
already ran their policies, and the block types whose throw is control flow
(human-in-the-loop, sentinels, subflow containers, notes, triggers).

Eligibility lives in one predicate read by both the editor and the executor, so
a block can never keep retrying after an edit that hides its control.

`retry` is a nullable jsonb column; NULL means "runs once", which is how every
existing block already behaves, so the change is inert until someone opts in.
Bounds are clamped on read rather than rejected, so a value written before a
bound moved still resolves to something runnable.

Also decouples the additional-fields disclosure from `block.advancedMode`. That
flag decides which member of a canonical pair serializes, so opening the
disclosure used to be able to drop a block's configured credential. Expansion is
now view state; the stored flag is no longer written by the editor.

Retried blocks report their try count on the trace span, shown in log details.

* fix(realtime): allow the write role to persist a block retry policy

`update-retry` was added to the protocol but not to the write-role allowlist, so
the editor applied the change optimistically while the server dropped it and the
policy never reached the database.

Adds a test asserting the write role holds every per-block operation the protocol
declares, so the next block setting cannot repeat this silently.

* fix(editor): keep a retry number field's value when it is blurred untouched

Committing on blur normalized the draft unconditionally, and an untouched field's
draft is null — which normalizes to the default. Focusing and leaving Max tries
silently reset a configured 5 back to 3.

* improvement(canvas): close the running fill to solid, slant its leading edge

Gaps gone entirely: the bar is one solid fill now.

The slant moves onto the growing edge, because a repeat with its gaps closed has
no edges left to show. 4px of run across the 16px height is the same 15° lean the
bars carried, so the fill still leans right — it just leans at its front instead
of throughout.

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

* improvement(canvas): return the running fill to the squares' rhythm, sheared

Marks are 24px with the row's 2px gap after them again — the geometry the slots
carried before any of this — so they land where the squares did. The shear is
the only thing that is new.

It stays on the single spanning element rather than going back to per-slot
backgrounds: one gradient means one phase, which is what lets the 24/2 rhythm
hold across the 40px end slots instead of resetting at every boundary.

Stops are measured along the 105° axis rather than horizontally, so both carry a
`sin(105°)` factor: 24px of mark is 23.18px of stop, and the 26px pitch is
25.11px of period. Writing 24/26 directly renders ~3.5% wide and drifts out of
the squares' rhythm across the row.

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

* fix(canvas): trim the running fill to the swell's tapered end

The fill ran off the block. The row is a rectangle but the swell is not — its
last slot cuts a diagonal so the shape narrows toward the top, and a rectangular
overlay therefore painted past the gray edge up there while still sitting inside
it at the bottom. The per-slot version never showed this because each button's
own clip contained its fill; moving the paint onto one spanning element took that
containment away with it.

The overlay now carries the same taper, read off that slot's own path: 16.67px in
from the row's right at the overlay's top, 3.33px at its bottom, a slope of 20/24.
Only applied to the swell variant, which is the shape that tapers.

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

* fix(canvas): stop the handoff highlight pinning a bystander's toolbar open

Dropping `isWorkflowRunning` from `forceOpen` was not enough: it also read
`usesSelectedVisuals`, which is `isNodeSelected || isExecutionHighlighted`, and
the handoff highlight covers the block feeding the running one. So the upstream
card kept its bar down for the whole run — the wall of open swells this was
supposed to end, one card smaller.

Those are two different questions. `usesSelectedVisuals` still drives the
TREATMENT — the graphite silhouette and `data-node-selected`, so the eye can
follow the baton — while whether the toolbar is pinned open now keys off
selection alone.

The container keeps `isRunning` by itself. Selection was never a pin there, and
its own tests hold it to opening on hover.

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

* fix(realtime): refresh the error flag on a block upsert

`BATCH_ADD_BLOCKS` wrote `errorEnabled` on insert but left it out of the conflict
clause, so re-adding an existing block id kept whatever the row already held
while every sibling flag — enabled, advancedMode, triggerMode, retry, locked —
was refreshed from `excluded`. The client's value was silently discarded and the
old error-output state came back on the next load.

Mine: the insert side gained the field when the column landed and the conflict
set did not.

The other two block writers delete before inserting, so no stale row survives
them; this upsert was the only path that merged into one.

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

* update loader animation

* change the loader

---------

Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Waleed <walif6@gmail.com>
* feat(salesforce): add JWT bearer flow and sandbox OAuth support

Salesforce integration users could only authenticate through interactive
OAuth, which an API-only integration user cannot complete — there is no UI
for them to log in to. Adds the JWT Bearer Flow as a second grant on the
existing service-account provider, and registers sandbox as its own
authorization server so sandbox orgs can connect at all.

The assertion is audienced at the org's My Domain URL rather than
login/test.salesforce.com: Salesforce ended legacy hostname redirections in
Spring '26 and External Client Apps now reject the generic sandbox host with
app_not_found. My Domain is valid for Connected Apps and External Client
Apps, production and sandbox alike, and is what the Salesforce CLI
recommends — so the stored host alone determines the environment.

Sandbox credentials are stored under their own provider id, mapped back to
the one Salesforce service via additionalProviderIds on OAuthServiceConfig.
That is threaded through every resolution point, including the two SQL
filters that would otherwise have hidden sandbox credentials from the block
picker entirely.

Also fixes three latent bugs surfaced along the way: Zoom interpolated an
undefined client secret into its Basic auth header, sandbox refresh tokens
would have been posted to the production endpoint, and the sandbox connector
would have been silently dropped as unconfigured.

* fix(salesforce): canonicalize connected provider ids in the copilot credential tool

A credential stored under an alternate authorization server was recorded in
`connectedProviderIds` under its own id, while the not-connected list compares
against the service's canonical id — so a sandbox-only Salesforce user was
reported as both connected and not connected. Record the canonical id instead.

Also types the JWT test's assertion decoder instead of returning `any`.

* fix(salesforce): close reconnect, instance-URL, and key-handling gaps

An independent audit swarm found four real defects in the JWT bearer work:

- The credential update hook rebuilt its request body from a hand-written
  allowlist, so `authMethod`, `privateKey`, and `username` were silently
  dropped. A JWT private key could never be rotated through the UI, and
  switching grants failed with a generic error. Forwards the whole contract
  body instead, so a field added to the contract later cannot be lost again.
- `getInstanceUrl` guarded only the `sub` claim against login-host origins, so
  a sandbox id token whose `profile` was rooted at test.salesforce.com yielded
  the login host as the org's API base. Both claims are now guarded, and a
  guarded-away `profile` falls through to `sub` instead of ending the lookup.
- `canonicalizeServiceProviderId` replaces the previous fold, which also
  matched family-wide service-account ids and so dropped one arbitrary sibling
  product (Gmail, Confluence) from the copilot's not-connected list.
- The private key was collected in a plain textarea, leaving browser spell
  check and autofill free to ship it to third parties.

Also restores the explicit https check on the userinfo-derived instance URL,
anchors the scope marker, caps the accepted RSA modulus, and stops single-grant
providers paying for a stored-blob decrypt on every reconnect.

Docs: the JWT path no longer tells readers to enable the Client Credentials
Flow, and calls out the my.salesforce-setup.com host as the likely wrong paste.

Adds coverage for the paths the audit proved untested: partitionClientCredentialFields,
credentialProviderMatchesService's alternate-server clause, reconnect
carry-forward, the typographic-apostrophe error branch, and the passphrase hint.

* fix(salesforce): handle the Government Cloud JWT audience and unassigned-profile errors

Verification against Salesforce's own sfdx-core surfaced two gaps:

- `gs1` Government Cloud orgs have ordinary *.my.salesforce.com hosts, but
  Salesforce requires `https://gs1.salesforce.com` as the JWT audience. The
  host regex accepted them, so they would have failed with an opaque audience
  error. The token still posts to the org's own host; only `aud` differs.
- `invalid_app_access` — Permitted Users is set to admin-pre-authorized but the
  run-as user's profile was never assigned to the app — is the likeliest
  misconfiguration and had no hint at all.

Also sends `iat`, matching sfdx-core and every mainstream implementation, and
softens two TSDoc claims that were stronger than the evidence: Salesforce does
not hard-reject a far-future `exp` (its own CLI ships one), and My Domain is the
right audience for commercial orgs rather than universally.

* fix(salesforce): match sandbox credentials in Chat and the connect draft

Two more surfaces resolved a credential to its service by exact provider id:

- `credentialsForTarget` compared only `providerId`/`baseProviderId`, so a
  sandbox-only user's Salesforce chip in Chat read as disconnected and
  re-prompted them to connect. The alternate ids are passed in by the caller
  rather than resolved in the module, which is `'use client'` and would
  otherwise pull the OAuth provider registry into the chat bundle.
- `createConnectDraft` resolved the service name by exact id, so a sandbox
  connect defaulted to the label "My salesforce-sandbox".

* fix(salesforce): carry alternate provider ids through chat connect verification

The chip's live target was widened to match a sandbox credential, but the
post-connect verification leg re-reads the STORED attempt, which did not carry
the ids — so completing a sandbox connect from Chat was detected as a failure
and the chip was marked failed. The attempt now persists them; attempts written
before this simply match as they did, and they expire within 15 minutes.

Also marks the auth-method picker required while it is the field blocking
submit on a reconnect, so the greyed button has a visible cause.

* fix(salesforce): send reauthorize to the server that issued the credential

"Update access" derived its provider from the service id, which always yields
the primary authorization server. A sandbox credential missing a scope sent the
user to login.salesforce.com — where a sandbox-only user cannot sign in at all,
and where a user who can sign in creates an orphan production account while the
banner never clears. Both credential selectors now pass the selected
credential's own provider id, which the connect modal already honours.

Also names the alternate provider ids explicitly in the disconnect sweep. That
branch is unreachable today (every caller sends an accountId), but it was
catching them only by the `{base}-` prefix accident.

* fix(salesforce): make the Government Cloud audience check exact, not a prefix

`startsWith('gs1-')` was invented from a paraphrase of sfdx-core and would have
misrouted an ordinary org like gs1-widgets.my.salesforce.com to the GovCloud
audience — breaking a setup that works today. sfdx-core's host signal is the
literal gs1.my.salesforce.com; its other signal is the org's createdOrgInstance,
which we never see. Matching exactly means a miss falls back to My Domain, which
is the behaviour before the branch existed, while a false positive cannot happen.

Also replaces the hand-rolled origin regex in getInstanceUrl with URL parsing,
which normalizes userinfo, ports, and case before the login-host comparison, and
drops two error hints that had no evidence behind them.
The new workflow block card (#6458) imports 16 icons from `lucide-react`,
which #6241 removed from the dependency tree in favour of the in-house
`@sim/emcn/icons` set. `next build` fails on both files:

    Module not found: Can't resolve 'lucide-react'

It builds green locally because `fumadocs-ui` pulls lucide-react in for
`apps/docs` and the install hoists it into `apps/sim/node_modules`, so
dev, `tsc`, and biome all resolve it. Only an install that excludes the
docs app — the Docker build — sees it missing. `check-import-specifiers`
does not cover this, as it skips bare npm specifiers by design.

Eleven of the icons already existed in the house set. Three map onto
existing glyphs that are already the same drawing: `Braces` -> `TypeJson`
(curly braces), `Hash` -> `TypeNumber` (hash), `KeyRound` -> `Key`.

The remaining four are new, ported with the same transform #6241 used —
lucide geometry scaled 0.86 and translated so its (12, 12) centre lands
on (10.25, 9.75) in a `-1 -2 24 24` viewBox, stroked at the house 1.55.
Every icon ported in #6241 sits on that centre; these four measure there
exactly.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…6526)

* docs(salesforce): correct the setup steps that would strand an admin

Verified the guide against Salesforce's current UI and docs. Most of it holds;
these do not:

- The Client Credentials step told admins to check "Enable Client Credentials
  Flow" under OAuth Policies. On an External Client App that checkbox is under
  Edit Settings → OAuth Settings; the Policies page holds only the Run As
  picker, so anyone following it literally hunts for a control that is not on
  the screen. The FAQ answer inherited the same conflation.
- Pre-authorizing the app must go through the profile or a SECOND permission
  set. A permission set backed by the Salesforce API Integration license cannot
  hold an Assigned Connected Apps section at all, so the app can never be
  assigned from the same permission set that grants object access — which
  produces exactly the "user hasn't approved this consumer" failure that step
  exists to prevent. This is the likeliest way a JWT setup fails.
- Salesforce requires an RSA key of at least 2048 bits; an ECDSA key is
  silently rejected, and the certificate must stay under 4 KB.
- The JWT toggle does not appear until Enable OAuth is on, and the control is
  "Upload Files".

Also scopes the capability promise for the API-only license: SOQL and CRUD on
standard objects are supported, reports and dashboards are genuinely unverified
in either direction, and Apex Class Access is a permission this license cannot
hold, so Tooling API calls touching ApexClass will fail.

* docs(salesforce): align the Developer Edition host in the FAQ with the setup section

The setup section was corrected to make the `-dev-ed` suffix conditional, but
the FAQ still presented it as mandatory — so an admin whose Developer Edition
domain lacks the generated suffix would read two contradictory formats on the
same page and validate against a host that does not exist.
…ate (#6528)

* fix(logs): keep run provenance when compaction drops the execution state

Oversized-payload compaction drops executionState wholesale but keeps
secretProjectionVersion, so the display projection saw a contract-marked
row it could not verify and returned structural-only spans — blanking
every input and output in the trace.

Store the provenance top-level so it survives compaction, omit it from
both display projections (it carries encrypted secret values and their
names), and let rows truncated before this shipped keep the spans they
were already projected with at write time.

* improvement(logs): type the new test helpers instead of using any
…#6527)

* fix(chat): stop the streaming transcript floor inventing scroll space

The sizer floor was the viewport's bottom edge (scrollTop + clientHeight),
which exceeds the content height whenever the transcript is shorter than the
viewport. That invents scrollable space no content occupies, and a mid-turn
container shrink turns it into real scroll room the bottom-pin scrolls into.

Clamp the floor to the space content has actually held this turn: the max of
the virtualizer's total size and the still-applied floor. The applied-floor
term keeps undrained debt across a turn boundary that interrupts the drain.

* fix(chat): release the transcript floor when the chat changes

The high-water mark and applied floor are per-turn refs on a component that
survives a chat switch, so a tall chat's mark could size a newly opened short
one for as long as the outgoing turn kept the floor engaged. Release both
outright on a chat change — the switch re-lands the viewport, so there is no
eased settle to preserve — while treating a pending chat adopting its id as
the same conversation.

Also switch the sizer-floor import to the absolute path convention.
* feat(incidentio): add on-call, alert, catalog, and team tools

Adds 19 tools to the incident.io block, taking it from 46 to 65
operations. Every endpoint, param, and response field is taken from the
official OpenAPI spec at api.incident.io/v1/openapiV3.json.

Who is on call had no reachable answer before: the data lives in
ScheduleV2.current_shifts, and both schedules_list and schedules_show
returned it but never declared it. The new incidentio_on_call_now tool
flattens current and upcoming shifts to one row per person, and the two
existing schedule tools now declare the fields they were already
returning.

Also fixes two pre-existing wiring bugs: the block declared an output
named schedule_override while the tool emits override, and the on-call
handoff skill described a lookup the integration could not perform.

* fix(incidentio): stop the alert filter sentinel reaching the API

The has_notes and include_maintenance_window dropdowns default to the
string "any", meaning "do not filter". The params transform skipped the
key in that case, but the executor merges its output over the raw inputs
(`{ ...inputs, ...transformedParams }`), so the sentinel survived and the
tool sent has_notes[is]=any, which incident.io rejects.

The transform now always assigns the key, mapping "any" to undefined so
it overwrites the sentinel instead of leaving it in place. The tool also
only serializes these filters when it actually has a boolean.

Adds tests covering the sentinel, both real boolean values, and the
documented bracket-operator filter syntax.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 11, 2026 05:04
@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (687 files, 100 file limit).

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 11, 2026 9:58pm

Request Review

@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Broad surface area: auth/credential flows (Salesforce JWT, OAuth provider matching), collaborative workflow persistence, and provenance behavior—any regression affects production workflows and security boundaries.

Overview
This release bundles workflow canvas work (block cards, progress, colors, retry/error output persistence over realtime), Salesforce server-to-server auth (JWT bearer plus sandbox OAuth and credential/API matching), and a large incident.io expansion (on-call, alerts, catalog, teams, follow-ups, and related docs).

Authoring & ops: Block-add guides now require canvasPresentation sentences and the canvas-sentences checker; self-host docs steer Docker upgrades through bun run sim update; comparison pages add session-policy and configurable code-sandbox rows.

Workspace UX: Folder breadcrumbs are unified for list vs detail pages (shared ancestor hook, table/knowledge/files headers); OAuth connect can pick authorization server/environment when a service has multiple provider IDs.

Editor & provenance: Rich markdown splits node chrome (rich-markdown-nodes) from prose typography so bare surfaces (e.g. canvas notes) render checklists correctly; in-editor image drag-reorder moves nodes instead of re-uploading. Provenance registries record named reasons when marking incomplete (knowledge, memory, workflow logs).

Realtime: Workflow edges normalize source/target handles on persist; block errorEnabled and retry sync with new socket permissions for write roles.

Reviewed by Cursor Bugbot for commit a64ce49. Configure here.

@waleedlatif1 waleedlatif1 changed the title v0.7.69: new workflow UI, salesforce JWT, provenance improvements, incidentio tools v0.7.69: new workflow UI, salesforce JWT, provenance improvements, incidentio tools, block retry Aug 11, 2026
@waleedlatif1 waleedlatif1 changed the title v0.7.69: new workflow UI, salesforce JWT, provenance improvements, incidentio tools, block retry v0.7.69: new workflow UI, salesforce JWT, provenance improvements, incidentio tools, block retries Aug 11, 2026
* fix(chat): stop losing sends aborted during mount-settling

* fix(chat): detect aborts by signal state, not error identity

fetch rejects with the RAW abort reason when its signal carries one —
abort('unmount:client_cleanup') surfaces as a plain string, so every
err.name === 'AbortError' check missed it and the restore path never ran
(verified live). The test stub now rejects with the raw reason like real
fetch, which turns this gap red.

* fix(chat): hand an aborted chatless send to the next mount

The mount-settling cycle is a full remount — the pending chat key is
regenerated per instance, so restoring the aborted send into the dead
instance's queue orphaned it (verified live). A chatless send now
re-persists as a one-shot MothershipHandoffStorage handoff the next
mount's consumer re-sends; chat-bound sends keep the queue restore.

* fix(chat): deliver an aborted chatless send to the live replacement surface

The settling remount's consumer checks handoff storage before the
restore microtask re-persists it, so the stored handoff sat unread until
a navigation. The replacement surface's send listener IS registered by
restore time — deliver the message directly through the claimable send
event, keeping the stored handoff as the no-surface fallback.

* refactor(chat): thread the recoverable-abort outcome through the send result

Replaces the restorableCleanupAbortRef reset choreography with a widened
startSendMessage return ('recoverable_cleanup_abort'), so the restore
decision is ordinary data flow and the second caller cannot leave a stale
flag behind.

* fix(chat): carry attachments through the cross-mount send handoff

The recoverable-abort delivery excluded attachment-bearing sends, so
they restored under the dead instance's pending key and were silently
lost. The claimable send event now carries fileAttachments end to end
(dispatcher, home listener, restore path); only the storage fallback —
whose shape cannot hold attachments — still queue-restores them.

* fix(panel): forward event attachments to the copilot send

* fix(chat): carry attachments through the stored handoff lane too

The unclaimed-event fallback excluded attachment sends and restored
them under the disposed mount's pending key. The persisted handoff now
carries fileAttachments (they are plain references to already-uploaded
files), the home consumer forwards them, and the recovery branch always
hands off — no stranded lane remains.

* fix(chat): probe the orphaned stream before re-sending a withdrawn send

The cleanup-abort recovery treated "no response headers yet" as "the server
never got it" and re-sent. It is not the same thing: the mothership chat route
never reads `request.signal`, so a request it had already accepted still runs
to completion — resolveOrCreateChat, persistUserMessage, and the billed turn
all commit even though the client socket is gone. Re-sending blind therefore
left the user with two chats and two billed runs for one message.

Recovery now carries the withdrawn send's `userMessageId` as a stream id
through both lanes (the live `mothership-send-message` event and the stored
one-shot handoff) and through a restored queue entry. Before re-sending, the
dispatcher polls that stream: when it resolves to a chat, the server already
has the message, so the chat is adopted instead of sent again. Only a stream
the server has no record of — a 404, i.e. genuinely never accepted — re-sends.
Timing out re-sends too, which is the safe direction.

Also corrects the root cause recorded in the comments. A Suspense hide/reveal
cannot run this cleanup: React 19 disappears layout effects only, and this is
a passive effect (verified against react-dom 19.2.4). What does run it is
StrictMode's dev double-mount and a real client-side navigation away, both
mid-flight — and because MothershipHandoffStorage consumes atomically, the
replacement mount finds nothing left to retry.

* fix(chat): never re-send on an unresolved probe, and reconnect after adopting

Two defects in the orphaned-stream probe, both found by Bugbot.

A probe cut short by an epoch change (unmount, chat switch) returned the same
`undefined` as "the server has no such stream", so the dispatcher fell through
to `startSendMessage`. After unmount the teardown has already dropped the abort
controller, so that send opened a POST nothing could cancel — duplicating the
very message this recovery exists to protect. The probe now reports
`superseded` distinctly and the dispatcher leaves the entry queued, keeping its
`recoverStreamId` so a later mount probes again.

Adopting the recovered chat also invalidated only the chat list. Hydration
reconnects to a live turn solely on `chatHistory.activeStreamId`, and that
query is cached for MOTHERSHIP_CHAT_HISTORY_STALE_TIME — on a chat-bound
recover the client normally holds a copy predating this stream, so the adopted
chat rendered with the running response invisible. Adoption now invalidates the
chat detail too.

Both regression tests were confirmed to fail without their fix: the first
re-sends (2 POSTs instead of 1), the second never invalidates. The probe stub
gained a `pending` mode because a `gone` probe answers on the first attempt and
leaves nothing in flight to interrupt — the earlier draft of the first test
passed with the guard removed and proved nothing.

* test(chat): cover the departing surface's own recovery-event claim

Greptile flagged that a surface being torn down could claim the recovery event
its own cleanup emits — which would return `true`, suppress the storage
fallback, and strand the message under a disposed pending key. It cannot: React
removes the listener during the same synchronous unmount commit, while the
recovery runs from the fetch rejection a microtask later, so by then nothing of
the departing surface is listening.

That ordering was previously only argued, never asserted — the suite unmounted a
bare hook with no listener attached. This mounts a home.tsx-shaped surface that
both drives useChat and registers the claiming listener, and asserts the
departing listener claims zero times while the handoff still reaches storage.
Confirmed meaningful: neutering the listener's removeEventListener cleanup so it
survives teardown makes it claim, and the test fails.

* fix(chat): hand off a chatless send when the probe is superseded

The previous commit made a superseded probe leave the entry queued rather than
re-send it. That is the right retry for a chat-bound key, which is the stable
chat id, but wrong for a chatless one: a `pending::` key is regenerated every
mount, so anything left under it is unreachable and the message is stranded —
the same loss this PR exists to prevent, just reached by a different route.

A superseded probe on a pending key now goes through the same recovery lanes as
the cleanup-abort path (live replacement surface, else a one-shot stored
handoff), still carrying the stream id so the next surface probes before it
sends. Skipped when the entry is no longer under that key, since adoption
migrating it to a live chat already leaves it recoverable there. The lane is
extracted so both call sites share one implementation.

The existing superseded test only asserted that nothing sent, which this bug
satisfied trivially; it now also asserts the message survives. Confirmed red
without the fix.

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
waleedlatif1 and others added 2 commits August 11, 2026 00:28
…row hover states (#6534)

* improvement(ui): align terminal with the workflow design system, fix row hover states

Terminal:
- derive log-row block tiles the way the canvas does (role accent for core
  blocks and subflows, provider colour only for role-less integrations)
- compose rows from chipGeometryClass, chipContentLabelClass and
  disclosureChevronClass instead of re-deriving the pill
- align the output tree's greys with the log rows; share ROW_STYLES.nested
  and BADGE_STYLE instead of duplicating the literals
- neutralise value-type badges so red is the only colour in the tree
- unify the row/separator gutter; normalise icon sizes to size-[14px]
- drop dead flattenEntryTree and the RunningBadge re-exports

Hover model (chipVariants, PopoverItem, Combobox, docs sidebar, terminal):
- hover paints --surface-hover, one step below the --surface-active a
  selected row keeps, so a hovered row no longer impersonates the selected one
- an active row holds its surface through hover instead of brightening

Deploy modal:
- move the footer actions onto the Chip family, primary action as
  Chip variant='primary' to match every other modal footer

* improvement(emcn): extract the row-state surface pair, simplify the terminal rows

Review follow-ups from /simplify and /cleanup:

- add chipHoverSurfaceClass / chipActiveSurfaceClass to chip-chrome as the one
  home for the two-surface row model, and route chipVariants, PopoverItem,
  Combobox, the docs sidebar, the landing preview and queued messages through
  them instead of restating the literals
- terminal ROW_STYLES now renders chipVariants rather than re-deriving its
  output, and the four rows share content/label/status classes resolved once
- structured-output composes chipGeometryClass with an h-auto override rather
  than restating four of its literals
- getEntryAccentType collapses to one expression, dropping the SYNTHETIC_BLOCK
  types coupling
- collapse the chip compound variants to two array-matched entries
- deploy modal: hoist the shared loader adornment, size it with
  chipContentIconClass so it matches every other chip icon
- landing preview drops its --c-active/--c-hover inline aliases for the tokens
- trim the rationale to one canonical copy with cross-references, and convert
  the block comments on declarations to TSDoc

* improvement(workflow): align canvas controls with the canvas surface and icon scale

- floating controls sit on --surface-2, the surface the block cards use, rather
  than --surface-1 (the sidebar/panel surface)
- undo/redo/fit glyphs drop 16px -> the platform's 14px default, and the mode
  dropdown's own 12px icons come up to match; the control had three icon sizes
- inactive buttons hover to --surface-hover instead of --surface-5, which was
  the active mode button's resting fill, so hovering one looked selected
- inner radius goes concentric with the 4px padding (rounded-sm inside
  rounded-lg)

* improvement(workflow): give the canvas-mode chevron the same treatment as its siblings

It was the only control in the cluster with no hover fill and a different rest
colour (--text-muted against the others' --text-secondary), so it read fainter
and behaved differently under the pointer. It is also a disclosure chevron, so
it now uses disclosureChevronClass instead of a hand-rolled duration-100 copy,
and a real 20px box instead of the !p-1.5 override plus -m-1 hit-area hack.

* test(terminal): lock the log-row accent rule against the block toolbar

getEntryAccentType encodes a cross-surface rule — a block must be accented the
same way in the terminal as in the block toolbar — and nothing enforced it. The
table covers every branch: core blocks mapped and unmapped, role-bearing and
role-less integrations and triggers, the config-less subflows, and the
synthesized error/validation/cancelled rows that must keep their status fill.

Verified failing: reverting the guard to an unconditional return reds two of
the four cases.

* fix(workflow): make notifications track panel and terminal resize live

The toast stack insets by --panel-width / --terminal-height, but a resize drag
writes those to the resized subtree only (.panel-container / .terminal-container)
rather than to :root, because a custom-property write on :root recalculates the
whole document (~150x slower). The stack is portalled to <body>, so it shares no
ancestor with either and kept reading the stale :root value — it held its
pre-drag position and jumped once the drag committed, while the canvas controls,
which are laid out inside the shrinking canvas, tracked the drag in realtime.

useDragResize now accepts several target subtrees and writes each one, so the
scoped recalc is preserved and every consumer follows the drag frame by frame.
The stack is found through a new data-toast-viewport attribute.

Also drops the canvas controls from bottom-4 to bottom-2: the toast clears the
terminal by 8px (it anchors from the viewport, and the terminal is inset by
CONTENT_WINDOW_GAP), where the controls measure from the canvas floor and so
sat at twice the gap.

* improvement(workflow): inset the canvas controls 8px off both edges

The toast stack clears the terminal and the panel by 8px — it anchors from the
viewport at --terminal-height/--panel-width + 16px, and both are themselves
inset by CONTENT_WINDOW_GAP (8px). The controls measure from the canvas floor
and wall instead, so their 16px read as twice the gap on both axes.

* improvement(workflow): lift the canvas controls and toasts to a 12px clearance

8px sat them too close to the terminal. 12 is on the same 4px grid as the
surrounding spacing, where 10 would have been the only off-grid value in the
area. Both surfaces clear the terminal and the panel by the same amount, so they
read as one row; the toast's literals move into named insets rather than staying
bare numbers in a style object.

* refactor(hooks): split the drag's resize target from its other var consumers

getTarget briefly accepted a list, which made the first entry both the resized
element and the drag's liveness reference. A toast auto-dismisses after 5s, so
had one ever led that list, its mid-drag unmount would have read as the drag
target detaching and skipped the final recompute on release. The co-consumers
now come through getExtraTargets, which is written but never consulted for
liveness, and can come and go freely.

* fix(emcn): stop the combobox cursor diverging from what Enter commits

The option rows painted --surface-active from CSS :hover as well as from
isHighlighted. CSS :hover tracks the pointer continuously while highlightedIndex
only advances on mouseenter, so once the list scrolled under a stationary
pointer the row that looked selected was not the one Enter would commit —
Enter reads filteredOptions[highlightedIndex].

isHighlighted is now the single source of truth for the cursor, so paint and
commit cannot disagree. The row under a stationary pointer may lag a scroll
until the mouse moves, but it lags in agreement with what Enter will do, which
is the invariant worth keeping. Disabled options also stop painting on hover,
matching the mouseenter guard that already refused to highlight them.

The 'All' row keeps its own hover: it clears the highlight rather than taking
it, so it has no isHighlighted paint to fall back on.

* fix(toast): derive the workflow inset from the shell's actual padding

WORKFLOW_INSET_PX baked in the 8px the workspace shell normally insets the panel
and terminal by, so the stack's 20px resolved to a 12px clearance — matching the
canvas controls. But the shell drops to p-0 on the desktop title-bar shell with a
collapsed sidebar, and there the stack would have sat 20px out while the
controls, laid out inside the shell, stayed at 12.

The stack now adds --workspace-content-gap (published on :root, zeroed by the
same condition that zeroes the padding) to a flat 12, so the two surfaces hold
the same clearance in both configurations. Before this PR they matched in the
p-0 case at 16px each, so this closes a divergence the PR would otherwise have
introduced.
* fix(chat): stop losing sends aborted during mount-settling

* fix(chat): detect aborts by signal state, not error identity

fetch rejects with the RAW abort reason when its signal carries one —
abort('unmount:client_cleanup') surfaces as a plain string, so every
err.name === 'AbortError' check missed it and the restore path never ran
(verified live). The test stub now rejects with the raw reason like real
fetch, which turns this gap red.

* fix(chat): hand an aborted chatless send to the next mount

The mount-settling cycle is a full remount — the pending chat key is
regenerated per instance, so restoring the aborted send into the dead
instance's queue orphaned it (verified live). A chatless send now
re-persists as a one-shot MothershipHandoffStorage handoff the next
mount's consumer re-sends; chat-bound sends keep the queue restore.

* fix(chat): deliver an aborted chatless send to the live replacement surface

The settling remount's consumer checks handoff storage before the
restore microtask re-persists it, so the stored handoff sat unread until
a navigation. The replacement surface's send listener IS registered by
restore time — deliver the message directly through the claimable send
event, keeping the stored handoff as the no-surface fallback.

* refactor(chat): thread the recoverable-abort outcome through the send result

Replaces the restorableCleanupAbortRef reset choreography with a widened
startSendMessage return ('recoverable_cleanup_abort'), so the restore
decision is ordinary data flow and the second caller cannot leave a stale
flag behind.

* fix(chat): carry attachments through the cross-mount send handoff

The recoverable-abort delivery excluded attachment-bearing sends, so
they restored under the dead instance's pending key and were silently
lost. The claimable send event now carries fileAttachments end to end
(dispatcher, home listener, restore path); only the storage fallback —
whose shape cannot hold attachments — still queue-restores them.

* fix(panel): forward event attachments to the copilot send

* fix(chat): carry attachments through the stored handoff lane too

The unclaimed-event fallback excluded attachment sends and restored
them under the disposed mount's pending key. The persisted handoff now
carries fileAttachments (they are plain references to already-uploaded
files), the home consumer forwards them, and the recovery branch always
hands off — no stranded lane remains.

* feat(search): improve command palette results

* feat(search): sharpen command palette discovery

Unify command surfaces, flatten ranked results, and remove favorites so the palette stays focused on fast discovery. Add Tab result cycling and workspace identity icons for quicker keyboard navigation.

* feat(search): unify cmd+k into one page-aware section model

Every palette view now derives from a single rule: the page's action group,
its own entity section hoisted, Platform actions, then a fixed tail shared by
all pages. Adds page commands for table/file/KB details, logs, and deploy; a
Logs section with run dates; chat last-activity receipts; kebab-cased
secondary search text with per-entry scattered matching; exact-section-name
ranking lifts; and scroll/selection fixes on open, loop, and arrow
navigation. Removes the canvas block/tool/trigger/docs sections and the
store's unused section restriction and pending-connect plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(search): ask-Sim tab mode, canvas sections, and palette refinements

Tab now flips the palette into ask mode — Enter lands on Chat with the query
seeded via the proven curated-prompt handoff (auto-send deferred on a
diagnosed use-chat mount-abort bug) — replacing the no-results New Chat
fallback. Restores the canvas Blocks/Triggers/Tools/Tool operations sections
between the workflow Actions group and Sim, keeps the integrations catalog
and connected accounts off the canvas, renames the global group to Sim and
page groups to Actions, puts an exactly-named page above its lifted contents,
adds chat last-activity receipts, and softens the list chrome (hidden
scrollbar, shorter fade, scroll-margin fixes for arrow and loop navigation).
Also renames the generic webhook block to Webhook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(search): order module results below Sim actions

* fix(review): make command palette fast and focused

* fix(search): restore Ask Sim prefill and refine palette ranking

* chore(sidebar): drop unused getSettingsHref destructure

* fix(deploy): gate every deploy invoker on full eligibility

* fix(search): port the palette to post-#6458 staging

Carries the former merge resolutions as one commit: the emcn icon set
(SelectAll fit-to-view, Search chrome), native browser-panel occlusion
gating, scheduled-tasks retirement, the chip-aware handoff consumer
superseding the ?handoff=1 machinery, and Knowledge bases pluralization.

* feat(search): auto-send Ask Sim queries via the chat handoff

* revert(search): return Ask Sim to prefill, storing raw prose

Auto-send still loses the message on cross-route navigation — use-chat's
cleanup abort fires during Home's mount-settling effect cycle. Prefill
restored, but via LandingPromptStorage directly so free-form queries are
never mentionified into @ chips.

* feat(search): auto-send Ask Sim queries via the chat handoff

Re-lands the auto-send flip: with fix/mship-mount-send-loss beneath this
branch, sends started during Home's mount-settling window survive the
cleanup abort (queued, restored, re-dispatched), so the handoff no longer
loses the query on cross-route navigation.

* fix(deploy): include registry loading in the deploy invoker gate

* improvement(search): label the ask row New Chat

* refactor(search): apply simplify-pass cleanups

- rank against a deferred query and hoist search-independent derivations
  so typing never blocks on the cross-section re-rank
- cache secondary-text tokenization (hottest per-keystroke loop)
- skip building browse groups mid-search; gate the palette-only
  credentials and logs queries on the palette being open
- flatten getGlobalSearchResults onto a spec-stable sort
- drop the dead store-side SEARCH_SECTIONS/docs path, the no-op
  CommandSearch surface variant, a duplicate hex regex, a dead
  font-base class, and the TaskItem/FolderedItem shape overlap

* fix(search): paint the palette fog with the dialog's own surface

The frost under the floating input reused the canvas card's --surface-2
gradient, which reads as a tinted band on the palette's --surface-4/
--surface-5 dialog (visible in dark mode). The CommandSearch surface
variant returns — this time with genuinely different values — and the
chrome test pins the host-matching fog.

* fix(search): the palette fog matches the inner --bg panel, not the dialog ring

* fix(search): review-round parity and consistency fixes

- table import command respects the in-progress upload gate
- Export CSV is offered to viewers (matching the header control)
- palette mode flips with the deferred query the ranking ran against
- a gated palette deploy reports the button tooltip's reason via toast

* fix(deploy): use the emcn toast input shape

* fix(search): rename logs view toggles to "Switch to Logs/Dashboard"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…updated docs based on openapi spec (#5273)

* v0.6.29: login improvements, posthog telemetry (#4026)

* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha

* improvement(external-endpoints): v2 versions with clean signatures + updated docs

* feat(usage): accept X-API-Key on usage-logs list + export

/api/users/me/usage-logs and /export now use checkHybridAuth — the same
auth /api/users/me/usage-limits already accepts — so external monitors
can read summary.bySourceCredits (the source breakdown of usage-limits'
aggregate currentPeriodCost) instead of estimating Copilot spend by
subtraction. Workspace-scoped keys are pinned to their own workspace's
slice of the ledger: the filter defaults to the key's workspace and an
explicit mismatch 403s. Both endpoints documented in openapi-core.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only

Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs
with a dedicated public surface, so the internal Billing-settings
endpoints can evolve with the UI while external monitors get a stable
versioned contract:

- GET /api/v2/billing/usage — current-billing-period summary with
  bySourceCredits (the source breakdown external monitors need to watch
  e.g. Copilot consumption without estimating by subtraction), plus
  limitCredits and plan
- GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2
  envelope
- workspace-scoped keys are pinned to their own workspace's slice;
  personal keys read the account ledger

The public wire is credits-only: usage-logs rows now carry a hasCost
boolean instead of dollarCost (the Billing UI only needed the >0
signal), and the rateLimit block is removed from the usage-limits
response and docs (deploy-modal tab relabeled accordingly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(docs): validate OpenAPI specs against the Zod contracts in CI

The specs in apps/docs are hand-authored because they carry what Zod
never defines — error envelopes, status codes, prose, examples — so
they can't be generated; check:openapi validates them instead:

- spec integrity: $refs resolve, operationIds unique, 2xx documented,
  no orphaned component schemas
- v2 conventions: every /api/v2 operation documents 401 + 429 and every
  4xx/5xx resolves to the canonical { error: { code, message } } envelope
- contract cross-check: contracts are auto-discovered from
  lib/api/contracts/v2 (each carries its method + path); doc<->contract
  coverage both ways, query/body/response field diffs via z.toJSONSchema
- examples: documented request/response examples must parse with the
  matching contract's actual Zod schemas

First run caught real drift, fixed here: 16 stale orphaned schemas in
the core spec, the v2 billing ops referencing v1-shaped error
components, deploy/rollback examples missing the required nullable
lifecycle keys, CreateTableBody missing folderId, a legacy-grammar
delete-rows example, and four knowledge document ops missing their
required workspaceId query param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(docs): recursive field diff in check:openapi + the deep drift it found

A mutation test showed the doc<->contract field diff only compared
top-level properties, so a typo inside the { data } envelope passed.
The diff now descends through matching object properties and array
items (both sides must expose a property set — passthrough contracts
and prose-only docs end the descent instead of false-positive), with
the Zod JSON-schema root doubling as the $defs context.

Deep drift it immediately caught, fixed here: select-column config
(options/multiple) missing from every tables column schema, AddColumnBody
hand-rolling a third column shape (now composed from ColumnInput, with
position/workflowGroupId as the per-op extensions the contracts actually
admit), chunking strategyOptions undocumented, and the deployment
lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from
DeploymentState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(security): close the triggerType rate-limit bypass on workflow execute

Caller-supplied triggerType flowed unchecked into preprocessExecution,
whose checkRateLimit default turns OFF for 'manual'/'chat' — so any
API-key caller, and any anonymous public-API caller billed to the
workspace owner, could execute unthrottled by sending
{"triggerType":"manual"} (async runs also skipped the worker-side check
via admissionCompleted). External callers may now only send the
redundant 'api' value; internal JWT callers ('workflow'/'mcp') are
unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* refactor(execution): extract enqueue/status/cancel into shared libs

Prepares the v2 execution surface: handleAsyncExecution's queue logic
moves to lib/workflows/executor/enqueue-execution.ts (slot/claim
semantics encoded in a discriminated outcome, not HTTP statuses), the
execution-status read to execution-status.ts, and the order-sensitive
cancel machinery to lib/execution/cancel-workflow-execution.ts. The v1
routes re-render identically — their suites pass unmodified.

Also: preprocessExecution gains rateLimitCounter ('sync'|'async') and
its 429 now carries code RATE_LIMIT_EXCEEDED + retryAfterMs (previously
indistinguishable from the concurrency 429 and Retry-After was
discarded); and the duplicate cancel contract in contracts/logs.ts is
unified on the full 5-value reason enum — its narrower copy made
requestJson throw a client ZodError when cancelling a paused HITL run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(execution): callable execution service + structured error classifier

executeWorkflowService composes the same libs the v1 route holds inline
(call-chain guard, execution-id claim, LoggingSession, preprocessing,
deployed-state load + file-field processing, timeout-bound
executeWorkflowCore, output hydration/compaction) for the deployed-state
caller class — the seam the v2 execute route and in-process internal
callers share, making the HTTP endpoint syntactic sugar.

classifyExecutionError stops discarding the block context that
buildBlockExecutionError already attaches at throw sites: failed runs
now yield {message, code, blockId, blockName, blockType} with a stable
append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/
INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/
OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class
instead of substring-matching messages — the single place raw errors
are interpreted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): POST /api/v2/workflows/[id]/execute

Thin route over executeWorkflowService: X-API-Key or anonymous
public-API auth (sync/stream only for anonymous), strict body with
body-flag async (no mode headers on v2), SSE passthrough for stream,
and the execution resource response — executionId always present,
in-band run failures are status:'failed' with the structured
{message, code, blockId, blockName, blockType} error, sync timeout is
status:'failed' + TIMEOUT instead of v1's 408, and a Response block's
payload stays inside output (authors never control response
status/headers on this origin). Async debits the async bucket and the
202 statusUrl points at the v2 executions resource. Adds
CLIENT_CLOSED_REQUEST/SERVICE_UNAVAILABLE to the v2 error codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): v2 executions status + cancel with queued backfill

GET /api/v2/workflows/[id]/executions/[executionId] is the single
status URL for sync and async runs: before the async worker writes the
durable log row, status is backfilled from the job queue (deterministic
job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window —
and failed runs carry the structured error object. POST .../cancel
renders the shared cancellation lib in the v2 envelope with the
tightened 5-value reason enum. Both authenticate via the shared
resolveV2WorkflowAccess (X-API-Key, authz masked as 404,
allowPersonalApiKeys honored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(execution): workflow tool + MCP bridge run in-process

workflow_executor (workflow-as-agent-tool) short-circuits in executeTool
through WorkflowBlockHandler — the same invocation boundary canvas child
workflows use — mirroring the deployed_block_executor precedent. The
MCP serve bridge calls executeWorkflowService directly instead of
fetching its own execute endpoint; deployment-version pinning, MCP
response-size rejection, and the actor override become typed options
instead of header sniffing. Both callers drop the double admission slot
and duplicate top-level log row the HTTP hop cost, and failed child
runs now surface the structured error + child executionId so parents
and MCP clients can route on error class and hand providers a
reproducible handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(infra): CORS + CSP coverage for the v2 execute path

/api/v2/workflows/:id/execute gets the same wildcard-origin,
credential-free CORS policy as v1 (the default credentialed policy
would block browser API-key calls and open a cookie CSRF surface) with
X-Sim-Stream-Protocol allowed and no X-Execution-Mode (async is
body-selected on v2), plus the COEP/COOP/CSP header block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(ui): deploy modal + copilot advertise the v2 execute surface

All 20 API-tab snippets move to POST /api/v2/workflows/{id}/execute with
the nested {"input": ...} body, async as the "async": true body flag
(X-Execution-Mode gone), status polling against the v2 executions
resource, the third tab renamed Usage and pointed at
/api/v2/billing/usage, and {data} envelope unwraps in the printed
responses. Fixes the latent baseUrl derivation
(endpoint.split('/api/workflows/')) that would have silently built
garbage URLs under a v2 endpoint, and deletes dead code (exampleCommand
across 3 sites, getAsyncExampleTitle). Copilot deploy/manage/serializer
endpoint builders and the api_trigger bestPractices example follow (the
latter also drops its hardcoded staging host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* docs(api): document the v2 execution surface

Adds execute, execution status, and cancel to openapi-v2-workflows.json
with the structured ExecutionError schema (append-only code enum + block
attribution) and the ExecutionResource contract, documenting the rules
that differ from v1: modes are body-selected, a failed run is HTTP 200
with status 'failed', an executionId always means data (never the error
envelope), queued status is visible immediately, and Response-block
payloads stay inside output. Registers the three pages in the generated
workflows meta.json and bumps the route-count baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): gate the whole /api/v2 surface behind one flag; UI stays on v1

Every v2 route now runs exactly one check immediately after auth —
v2ApiGateError — and answers 404 when the `v2-api` flag is off, so the
surface is invisible until it is deliberately rolled out. The gate is
keyed on userId only: a workspace/org-keyed check would have to read
membership for a caller-supplied id before authorization runs, and its
404-vs-403 split would leak cohort membership (the trap the per-domain
table gate worked around by running late). The two executions routes
inherit it from the shared access resolver; the tables-specific gate is
removed so no route checks twice.

`tables-v2-api` stays, now gating only the internal predicate-grammar
route /api/table/[tableId]/query — note v2 tables routes move to the
unified flag, so enabling them is a `v2-api` decision now.

Reverts the deploy modal, copilot handlers, and api_trigger example to
the v1 execute endpoint: v1 works unchanged, and the UI must not
advertise a surface most users would get a 404 from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(executor): restore child-cost aggregation dropped by the staging merge

Staging's custom-block rewrite deleted `aggregateChildCost` from
workflow-handler.ts, and git merged that file cleanly — but this branch's
workflow-tool-runner.ts, added for the v2 execute migration, still imports it.
A silent semantic conflict: no marker, broken build.

Taking staging's rewrite is correct, so the helper is defined locally in its
one remaining consumer rather than resurrected in the file staging just
rewrote. Same four lines over the still-exported `calculateCostSummary`, so a
failed child workflow keeps billing the hosted-key spend it consumed instead
of reporting $0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): make lib/table/orchestration the single implementation (#6134)

* refactor(orchestration): move the shared error contract out of lib/workflows

OrchestrationErrorCode and statusForOrchestrationError are the contract every
lib/[resource]/orchestration module returns against, but they lived inside the
workflows module, so resource-neutral code (lib/folders) already had to import
from a workflow path. Moved to lib/core/orchestration/types.

Adds a 'locked' class mapping to 423. Both tables and workflows have a lock
that forbids a mutation, and each caller was translating that to a status
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): make lib/table/orchestration the single implementation

Column update was implemented four times — the UI route, v1, v2, and the
copilot table tool — each calling the same column services but owning its own
guards, error mapping, and audit. The copies had drifted, and the drift was the
bug: v2 was missing both guards, only the copilot copy minted stable option
ids, and only v1/v2 audited.

performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own
that logic; all ten call sites reduce to auth, parse, call, render. The guards
are asserted once in lib/table/orchestration rather than four times against
four routes.

Behavior this consolidates, previously true on only some paths:

- The typeChanging guard. updateColumnType early-returns on an unchanged type
  and drops any options sent with it, so restating the current type alongside
  new options silently discarded them. v2 had no guard at all and, since its
  contract shares v1's body schema, accepted options and ignored them.
- The select-unique guard. Each write is its own locked transaction, so a
  rename or type change paired with a constraint write that is going to fail
  commits first and then throws, half-applying the schema change.
- Stable select-option ids. Cells reference the option id, so an edit that
  re-sends an option by name has to reuse it or every cell holding it is
  orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to
  lib/table/select-options and now covers every caller. It preserves a supplied
  id, so it is a no-op for the fully-formed options the HTTP contracts accept.
- required forwarded into the type and options writes, so a conversion
  validates against the constraint the same request is setting.
- An audit on every successful update. The UI route and the copilot tool
  emitted none.
- Single-row delete through the row service. v2 did a raw db.delete, skipping
  assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200
  and the row-count bookkeeping never ran.
- The delete actor handed to deleteTable, which audits only when a row was
  actually archived. v1 and v2 omitted it and audited themselves outside that
  check, emitting TABLE_DELETED for a no-op delete of an archived table.

Failure classes come back as OrchestrationErrorCode; v2 renders them through a
new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1
and UI surfaces, so a given failure maps to the same status everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(tables): bind the column-update tests to the orchestration function

The base's route tests assert which column service each payload reaches — the
behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table`
barrel; the orchestration module imports the service directly, so they mock that
too and keep asserting the same thing through the extracted implementation.

The orchestration tests move onto the base's semantics: writes address the
stable column id, a rename rides inside the write it accompanies rather than
running first, and the currency guards replace the non-select options guard the
service now owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(copilot): drop the column-type import the delegation made dead

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): move the audit log out of the table service

`lib/table/service.ts` wrote its own audit rows, so whether an operation was
audited depended on which function a caller reached for rather than on a user
having performed it. That is what let v1 and v2 audit a no-op delete, and what
made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag.

Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed
call was logged against the table's *creator*. The copilot `mv` path passed no
actor at all: renaming someone else's table recorded them as the renamer.

Audit now lives in the orchestration functions — performDeleteTable,
performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and
the services just write. Internal callers (folder cascade, import rollback)
keep calling the service and are silent by construction rather than by
remembering to omit an argument.

Two services now return what the audit needs: `deleteTable` reports whether it
actually archived a row, so a repeat delete logs nothing; `updateTableLocks`
returns the before/after locks, since only the locked write can observe the
transition its description names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tables): restore audit provenance and conflict status in orchestration

Moving the audits into the orchestration functions dropped three things the
routes had been carrying, and added one the orchestration now owns twice.

- The v1 and v2 column-update routes passed `request` to `recordAudit`, so
  their audit rows recorded the caller's IP and user-agent. The orchestration
  function had no way to receive it. Every table orchestration function now
  takes an optional `OrchestrationRequestContext` and every HTTP route
  forwards it; the copilot and VFS callers, which have no request, omit it.
- `classifyTableMutation` matched `TableConflictError` on "already exists"
  appearing in the message and reported it as `validation`, turning the UI
  route's 409 on a duplicate table rename into a 400. It now matches the type,
  the way `performRestoreTable` already did.
- `captureServerEvent` ran on every delete while the audit was gated on a row
  actually being archived, so a repeat delete of an archived table still
  reported `table_deleted`. Both now hang off the same evidence.
- The copilot delete path kept its own `captureServerEvent` from when the
  service did not emit one, double-counting every copilot table delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

* fix(tables): say which type a no-op column update restated

A copilot `update_column` payload whose only content was the column's current
type used to return success with the live schema, while the v1, v2, and UI
routes rejected the same payload with "No updates specified". Delegating to
`performUpdateTableColumn` unified them onto the routes' rejection — correct,
but the message tells the caller its request was empty when it named a type.

The orchestration function now reports the same thing `updateColumnType` reports
when it loses this race concurrently: the column is already that type, re-issue
without the type change. An empty payload still reads "No updates specified".

Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the
comment described the no-op that can no longer reach that line, and a success
always carries a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

* refactor(tables): classify failures by type instead of by message text

The table module decided HTTP statuses by searching error messages for
phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32
substrings between them, and fifteen more lists were inlined in routes — 83
matchers over 17 files, each its own copy of the guesswork and already drifted
apart. It made message wording load-bearing: `TableRowLimitError`'s own doc
comment noted that its text had to contain "row limit" for a route to answer
400, and adding "already exists" to a rename message silently demoted a 409 to
a 400 (the bug fixed one commit ago, by adding another special case).

Services now throw `OrchestrationError`, which carries the transport-neutral
`OrchestrationErrorCode` the layers above already speak. Classification is one
`instanceof` in `orchestrationErrorResponse` (UI + v1) and
`v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free
to change; an unclassified error still becomes a generic 500, which is what an
unexpected fault should be.

`asOrchestrationError` walks the `cause` chain rather than testing the caught
value directly: drizzle wraps a throw raised inside a transaction callback in a
`DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof`
would drop every failure raised inside `withLockedTable`. That is the same
reason `rootErrorMessage` had to dig for a root cause before.

Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace
ID mismatch`, and `Failed to build upsert conflict predicate` are internal
invariants no consumer classified, and they keep falling through to a 500.
`Insufficient capacity` was in the pattern list with no producer anywhere in
the codebase.

Status changes, all deliberate:

- `'forbidden'` joins the code union so the table-row-limit ceiling keeps its
  403; without it this refactor would have flattened it to 400.
- import-async's table-limit rejection: 400 -> 403, matching the two other
  create routes it had drifted from.
- Renaming a table to an invalid name: 500 -> 400. `validateTableName`
  messages don't contain "Invalid", so no matcher ever caught them.
- Restoring a table that isn't archived, or into an archived workspace:
  500 -> 400.
- A duplicate *column* name stays `validation`/400 rather than becoming a 409
  like a duplicate table name. Both v1 and the orchestration have always
  answered 400 for it; changing a published status is not this refactor's job.

The twelve tests that changed were asserting the substring mechanism itself,
constructing plain `Error`s with magic strings. They now assert the real
contract, plus new cases pinning that identical wording carrying no
classification stays internal and keeps its message off the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials (#6150)

* feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials

* fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping

* fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts

* fix(api): close unique-violation, revival, orphan-write, and env-rename gaps

* fix(api): treat every provider-outage code as unavailable on create and update

* fix(credentials): use the shared outage predicate on the session update path

* fix(contracts): anchor the predicate double-cast annotation to the cast

`check:api-validation:strict` counted 9 unannotated double-casts against a
baseline of 8, failing CI. The predicate leaf schema was annotated, but the
annotation sat above the declaration while the checker anchors on the line
carrying the cast — five lines below, at the close of the object literal. The
scanner walks back at most three lines and stops at the first non-comment one,
so it hit `value: z.unknown().optional(),` and never saw the reason.

Splitting the object schema from the cast puts them adjacent, so the existing
reason binds. No behavior change — the cast, the schema, and the reasoning are
unchanged.

Also lowers the rawJsonReads ratchet 6 -> 5 to match the current count, which
had drifted down; leaving it high lets a removed raw read silently come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): point the orchestration error contract at its moved module

#6150 branched before #6134, so skill-lifecycle.ts imports
@/lib/workflows/orchestration/types — the module #6134 moved to
@/lib/core/orchestration/types. Git merged a file deletion on one side with a
new file referencing it on the other: no textual conflict, broken build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(knowledge): make lib/knowledge/orchestration the single implementation (#6154)

* refactor(knowledge): make lib/knowledge/orchestration the single implementation

Knowledge base create was implemented four times — the internal route, v1, v2,
and the copilot tool — and the orchestration around the shared write had
drifted. Extract it the same way lib/table/orchestration was: services write,
orchestration decides which writes run, guards them, audits them, and returns a
transport-neutral failure.

Behavior converged, not preserved:

- One chunking default (DEFAULT_CHUNKING_CONFIG). The agent defaulted minSize to
  1 against the API's 100, so identical input produced differently-chunked
  knowledge bases depending on who created it. The agent path now chunks at 100.
- Every successful mutation is audited inside the orchestration function. The
  copilot tool called recordAudit zero times, so agent-created knowledge bases,
  document uploads, updates and deletes left no audit trail at all.
- Failures classify by class, not by message text. The knowledge service errors
  are OrchestrationError subclasses and storage-quota rejections throw a shared
  StorageLimitExceededError, replacing four separate message greps for
  "already exists" / "does not have permission" / "storage limit".

delete_connector reported the opposite of what happened. It reached the route
through an internal HTTP self-call that sent no query string, so the route's
keep-documents default always applied while the agent told the user the
documents had been removed. The self-call is gone — all four connector
operations run in-process — and the orchestration returns the real counts.

Also:

- OrchestrationErrorCode gains 'payload_too_large' (413 / PAYLOAD_TOO_LARGE).
  Without it, dropping the storage-limit message match would have regressed the
  documented 413 on knowledge base create and document upload to a 500.
- messageForOrchestrationError renders a route's own wording for an unclassified
  fault, so a driver's message no longer reaches the client on a 500.
- v1 and v2 knowledge base update now forward actorUserId, which the service
  requires for a workspace move; both omitted it.
- The connector DELETE route reads deleteDocuments through parseRequest. Its
  contract declared z.boolean(), which would have rejected the string a query
  param actually is.
- Drop the 409 from POST /api/v2/knowledge/{id}/documents in the OpenAPI spec.
  Nothing on the upload path throws a conflict; it was only ever reachable by
  the message match this change removes.

Behavior change worth noting: a v1/v2 PUT carrying only the workspaceId scope
field and no actual updates now returns 400 rather than 200 with the unchanged
knowledge base.

Deliberately deferred: document update remains internal-only. Extracting
performUpdateKnowledgeDocument makes exposing it on v1/v2 a contract and a route
away, but that is a new public surface rather than part of this consolidation.

* fix(knowledge): make connector create atomic and stop flattening failures

Review round 1 on #6154.

- Resolve the billing payer before the connector is committed, not after. A
  malformed attribution header rejected post-commit left a live connector behind
  a 500, and a retry created a duplicate plus duplicate sync work. Manual sync
  resolves before writing its audit for the same reason.
- Let the source-config validator carry its own failure class. Collapsing every
  rejection to `validation` flattened the connector PATCH route's 401 (stale
  stored credential) and 409 (missing workspace context) into a 400.
- Add `unauthorized` to OrchestrationErrorCode. It is the class that 401 was
  already expressing on this route, and the v2 vocabulary already had
  UNAUTHORIZED; only the shared union was missing it.
- Report a knowledge base that exists but failed to archive as failed, with the
  reason, rather than as not found. The copilot delete loop folded every
  non-not-found failure into `notFound`, telling the user it was never there.
- Route copilot failures through the same message helper the HTTP surfaces use,
  so an unclassified fault's raw text (a driver's failed SQL) no longer reaches
  the agent verbatim while the UI and public APIs get the generic wording.

* feat(api): expand the public v2 files surface (#6160)

* feat(api): expand the public v2 files surface

Adds folder support, rename/restore, move, bulk archive, share, and content
replace to /api/v2/files, so managing files by API no longer stops at
upload + download + archive-one.

Routes are thin: auth -> parse -> perform* -> serialize. Share and content
replace get their orchestration extracted first so the session routes and
the public ones cannot diverge on the effective-authType resolution, the
EE public-sharing gate, or the storage-quota classification.

Presigned upload stays session-only: presign does an advisory quota check
and the real debit happens in the separate register step, so a caller that
never registers leaves unaccounted bytes with no reaper. The buffered
multipart path debits inside uploadWorkspaceFile's own transaction.

* fix(files): classify folder and content failures instead of 500ing them

Bugbot round 1. The v2 routes map errorCode straight to a status, so every
manager failure that arrived unclassified became a 500 for what is really a
caller-fixable 400 or 404.

- Folder manager throws OrchestrationError: missing target/folder -> not_found,
  reparent cycle / self-parent / restore-into-archived-workspace -> validation.
- File manager does the same for the in-transaction 'File not found' paths that
  the earlier pass missed.
- updateWorkspaceFileContent's outer catch re-wrapped everything in a bare
  Error, which stripped the class off StorageLimitExceededError and the new
  not_found alike. It now rethrows a classified failure untouched and attaches
  cause to the generic wrap, so asOrchestrationError can still walk the chain.
- Every remaining perform* gained the asOrchestrationError branch.
- renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a
  stale updatedAt; it now returns the timestamp it actually wrote.

Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching
the in-app uploader. The description claimed 409 and was simply wrong.

* fix(files): surface a failed upload read-back as the real error

getWorkspaceFile swallows a query failure and returns null unless throwOnError
is set, so a transient blip on the post-upload read reported as 'file could not
be read back'. Distinguish the two: a real null after a just-committed write is
an invariant break, a query failure is itself.

* revert(api): drop the dedicated v2 file-folder routes

File folders already live in the shared folder table as resourceType 'file'
(#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining
file-specific folder machinery is being folded into the generic folder engine.
Publishing /api/v2/files/folders/** would pin that transitional split into a
public contract we'd then have to keep or break.

Files stay folder-aware — folderId/folderPath on the projection, folderId on
upload, and the move route — because a folder id is a folder.id and survives
the unification untouched. Folder management belongs on /api/v2/folders once
that surface serves resourceType 'file'; until then there is no v2 way to
enumerate file folders, which is the deliberate gap.

The orchestration classification fixes stay: the internal routes and the
copilot file-folder tools still call those perform* functions.

* fix(files): classify upload failures instead of matching their wording

Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that
updateWorkspaceFileContent did, so a blown storage quota reached the route as a
bare Error and the v2 handler recovered the status by substring-matching the
message. Any rewording silently demoted a 413 to a 500.

- uploadWorkspaceFile rethrows a classified failure untouched and attaches cause
  to the generic wrap.
- FileConflictError is now an OrchestrationError('conflict'), so a duplicate name
  classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no
  readers and is gone; the instanceof checks elsewhere still hold.
- The v2 upload handler uses v2CaughtOrchestrationError, dropping all three
  string matches.

Also documents that bulk-archive is best-effort: unknown or already-archived ids
are skipped rather than failing the call, and deletedItems is what actually
happened. That asymmetry with the single-id DELETE was undocumented.

* feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189)

* feat(api): add search, filtering, and sorting to the v2 list endpoints

One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts:
`search` (case-insensitive substring on the resource's natural name field),
`sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated
resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2
knowledge-documents already ship rather than inventing a third dialect
alongside the Logs filters and the Tables predicate grammar.

Every filter and sort is pushed into SQL. GET /api/v2/files previously read the
whole scope and sorted/sliced it in JS; it now goes through a new
queryWorkspaceFiles that filters, orders, and bounds the page in one query.

Cursors are stamped with the sort they were minted under, so replaying one
under a different sort is a 400 instead of silently duplicated or skipped rows.

* fix(api): validate v2 cursor key values and compare timestamps at ms precision

Two review findings, fixed at the root by making a keyset key own its cursor
codec instead of hand-writing a decoder per sort.

Cursor key values are caller-controlled, and matching the sort stamp and key
count was not enough: an unparseable timestamp or a non-numeric size reached
the query as an Invalid Date or NaN and surfaced as a 500. Each key now type-
checks its own value and rejects a cursor it cannot hold, which both routes
render as the documented 400.

Timestamp keys now order and compare on date_trunc('milliseconds', col).
Postgres keeps microseconds and defaultNow() populates them, but a cursor value
round-trips through a millisecond-only JS Date — comparing the raw column
against the truncated value re-admitted the page's own last row, duplicating it
and stalling pagination outright at a page size of one. Reachable today via
workspace_files.updated_at, which insertFileMetadata leaves to defaultNow().

* feat(api): complete the v2 workflows resource with versions and CRUD (#6184)

* feat(api): complete the v2 workflows resource with versions and CRUD

Adds version listing/detail plus create, update, and delete to the v2
workflows surface, which previously covered only execution and deployment.

- GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first
- GET /api/v2/workflows/[id]/versions/[version] — version + pinned state
- POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id]

All six delegate to the existing orchestration and persistence helpers;
no new domain logic.

* fix(api): check folder containment before lock state; reject malformed version cursors

assertFolderMutable walks a folder's ancestor chain without filtering on
workspace, so inspecting it before containment let a caller tell a locked
folder in someone else's workspace (423) from a nonexistent one (400).
Create and update now assert containment first, matching the ordering
import-workflow.ts already uses.

A version cursor that decodes to JSON without a numeric version filtered
every row out and returned an empty page with nextCursor null, which reads
as a clean end-of-list. Malformed cursors are now a 400.

* refactor(api): page workflow versions in the persistence helper

listWorkflowVersions read every version row and the route filtered and
sliced the result in memory, so the response was bounded but the query
was not. It now takes optional limit/afterVersion, turning the cursor
into a real keyset query; the route asks for limit + 1 and only trims
the has-more probe. Both params are optional, so the internal, v1 admin,
and copilot callers are unchanged.

Also restores the untouched GET handler in [id]/route.ts to its original
formatting — collapsing its signature had re-indented the whole body and
buried the actual additions in whitespace churn.

* feat(api): expand v2 tables with stateless multipart transfers (#6188)

* feat(api): expand the public v2 tables surface

Adds 16 operations so a v2 caller can do what the internal surface can:
rename/move/lock a table, restore it, manage saved views, run enrichment
columns, look up rows, and import/export with observable job control.

Extracts lib/table/orchestration/import.ts (performTableCsvImport,
performCreateTableFromCsv) and lib/table/export-stream.ts from the
first-party routes, then repoints those routes at them, so v1 and v2
cannot drift on what an import or export actually does.

events/stream, metadata and dispatches stay internal — they are editor
state, not public API.

* fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423

Greptile P1: PATCH applied locks, rename and move as three sequential
transactions, so a folder rejected mid-request left the earlier writes
persisted while the response reported failure — and the schema-changed
signal was skipped, leaving open clients on stale state. Every rejectable
condition now runs before the first write, and the signal fires whenever
anything did land.

Cursor: v2TableLockError dropped the lock kind, so async import, column
run, enrichment and table mutations returned a bare LOCKED. A table has
four independent locks, so the caller could not tell which to clear.

* fix(api): report the lock kind on classified 423s too, not just thrown ones

The previous commit named the lock only where the rejection was thrown and
caught at the route boundary. Where it instead arrives as a classified
`errorCode: 'locked'` outcome — delete table, delete row, update column,
and the table mutations — the kind was dropped, so those 423s stayed
unactionable while their neighbours improved.

The orchestration results now carry `lock`, and a shared
`v2TableOrchestrationError` renders both arrival paths into the same
`{ code, message, details: { lock } }` body. `details` is omitted rather
than sent null when the kind is unknown, so a caller branching on it sees
absence instead of a phantom value.

* fix(api): make async table imports observable, not just startable

`POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track
progress, but that endpoint filters to `type = 'export'` — imports are
derived onto the table itself, one write job at a time, and exports get a
separate list precisely because they are excluded from that derivation.
The public Table shape omitted those derived fields, so an async import
could be started and cancelled but never observed to completion, failure,
or progress. That is the gap the import/export/job-control set was meant
to close.

Table now carries `job` — id, type, status, rowsProcessed, error, or null
when idle — and the import-async docs point at the table rather than the
export list.

* feat(api): make v2 table PATCH state which operations landed on failure

Greptile held the PR at 4/5 on the residual non-atomicity and named two
acceptable resolutions: make PATCH atomic, or have the contract adopt and
expose partial-success explicitly. Atomicity would mean threading one
transaction through renameTable, moveTableToFolder and updateTableLocks —
three shared service functions with four non-test callers including the
first-party route and two copilot tools — and deferring their per-operation
audits to commit time. That is a refactor of shared write paths well
outside this PR.

So the contract states it instead. Every rejectable condition is already
pre-validated, so a failure here is a genuine fault; when one follows a
successful operation the error now carries `details.applied` listing what
is live. Absent when nothing applied, so its presence always means "these
changes took effect despite the error". Documented on the operation.

`v2ErrorForOrchestration` gained the optional `details` this needs.

* fix(api): make table lock flags read-only on the public v2 surface

The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated
on workspace admin plus the table-locks feature. That still lets an API
key clear the guard placed there to stop it: `write` is the floor for
the endpoint, and admin keys are ordinary API keys, so a lock is no
longer a boundary the key cannot cross.

Locks stay readable on the table resource and enforcement is unchanged
(a locked verb still returns 423). Changing one is now a first-party
admin action only.

The v2 body is declared here rather than reusing the first-party
updateTableBodySchema, which keeps its `locks` field so the UI can still
toggle them. It is .strict(), so a request carrying `locks` is rejected
with a 400 naming the field instead of silently succeeding without
applying it.

* fix(api): keep reporting applied operations when the PATCH re-read fails

The composite table PATCH promises that `error.details.applied` names the
operations that are live despite an error, but `applied` was scoped
inside the try. A rename or move that committed and was then followed by
a throw in the final re-read — or a re-read finding the table archived —
returned a bare 500/404 with no details, telling the caller nothing had
landed. It would then retry into a duplicate-name conflict or repeat the
move.

`applied` is now function-scoped so every post-write exit carries it: the
404 on a missing re-read, a thrown lock error, a classified orchestration
error, and the generic 500. `v2TableLockError` gains the same
`extraDetails` parameter `v2TableOrchestrationError` already had.

* feat(api): add workflow group writes to the v2 tables surface

v2 exposed GET /groups but none of the writes, so the public API could
run an enrichment or workflow column and read its binding, but never
create one. A caller could add a plain data column and trigger the
machine; wiring the two together still required the UI.

Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is
the unit that fills columns — one group feeds several — so creating one
creates its output columns in the same call, matching the first-party
shape rather than inverting it onto the column endpoint.

Four departures from the first-party body, all public-surface concerns:
- group.id is optional and server-generated. The UI mints an id to render
  optimistically; a public caller has no such need and a client-chosen id
  is a collision waiting to happen.
- outputColumns[].workflowGroupId is dropped from the body and stamped
  from the resolved group, so it cannot disagree with it.
- autoRun defaults to false. First-party defaults true so a UI add fills
  cells immediately; here it would make one POST fan out a metered run
  across every existing row.
- A group naming neither a workflowId (type manual) nor an enrichmentId
  (type enrichment) is a 400 rather than a half-specified group the route
  has to guess about.

Also rejects an outputColumns entry no group output feeds — the two
arrays are joined by column name, and the first-party client builds both
from one picker so it cannot desync, but a public caller can.

Workspace containment on workflowId is asserted before it is persisted,
on create and on any update that re-points the group; without it a table
becomes a way to invoke workflows the key cannot otherwise reach.

* improvement(api): make v2 table import and export async-only

Drops the three synchronous entry points: POST /tables/[tableId]/import,
POST /tables/import-csv, and GET /tables/[tableId]/export.

Sync import tied a write to the lifetime of an HTTP request. The body
*was* the data, so it carried a 10 MB cap that Next silently truncates
past — a partial import reporting success. It also had no job, so a
timeout mid-write left rows in place with nothing to poll and nothing to
cancel. The async path reads the file from storage instead: upload via
POST /api/v2/files for a key, start with POST /import-async, watch
GET /tables/[tableId] -> job, stop with POST /job/cancel.

Sync export carried no such hazard, but one shape per operation beats
two: with both removed the surface has exactly one way to move a table
in or out, and the CLI wraps the extra calls.

This also removes the last multipart handling in v2 tables. Those were
the only routes bypassing parseRequest — form fields were parsed by hand
against separate form schemas, outside the contract system every other
v2 write goes through.

Create-a-table-from-CSV is now two calls: POST /tables, then
/import-async with createColumns. csvImportModeSchema is append|replace,
so there is no single-call create.

Route baseline 1064 -> 1061.

* docs(api): correct the import-async note about upload size limits

The docstring claimed there is no synchronous upload endpoint and so no
request-body size cliff. Both are wrong: POST /api/v2/files is a
synchronous multipart upload with a 100 MB cap, and it is the only v2
upload path (presigned is deliberately absent).

What async-only actually bought: the cap went 10 MB -> 100 MB, it fails
on an explicit size check and a bounded body read rather than a proxy cap
that silently truncates, authorization completes before any body is
buffered, and the table write is a job that can be watched and cancelled.

* feat(api): unify file and table transfers

* improvement(api): make multipart transfers stateless

* fix(api): make table import completion retries idempotent

* feat(v2-tables): paginate the table list

`GET /api/v2/tables` returned every table in the workspace in one response —
it used the cursor envelope but hardcoded `nextCursor: null`, and had no
`limit`. That was defensible when tables were only created through the UI;
`POST /api/v2/tables` is public now, so a script can create them in bulk and
the list has no way to ask for less.

Adds `queryTables` alongside `listTables` rather than changing it, so the
internal callers that genuinely want the whole scope are untouched — the same
split `queryWorkspaceFiles` / `listWorkspaceFiles` already uses. Filter, order
and slice all run in the query, so a `search` never costs a full-workspace read.

A cursor whose values don't bind raises a validation error instead of being
coerced to "no filter", which would have silently served page 1 under a resumed
cursor. The keyset closes on `id` so a page boundary inside a run of equal names
or timestamps stays stable.

The shared `LimitQuery` doc component said "Maximum rows to return"; it now
serves the table list too, so the wording is resource-neutral.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(api): add multipart knowledge document uploads

* fix(api): keep usage admission at knowledge upload session creation

* feat(knowledge): wire knowledge base uploads to multipart sessions

* fix(knowledge): refuse to abort an upload once a document is bound

* fix(uploads): prevent multipart cleanup races

* Unify file creation and signed upload sessions (#6264)

* feat(uploads): unify signed upload sessions

* fix(uploads): preserve attachment storage semantics

* feat(files): add authored file creation

* fix(uploads): omit hoisted S3 metadata headers

* feat(api): add file metadata endpoint

* improvement(api): scope folders to resource paths (#6284)

* improvement(api): scope folders to resource paths

* fix(files): serialize folder resolution with uploads

* fix(files): release folder lock before upload setup

* fix(api): normalize folder paths and unblock resource mutations

* fix(api): make resource cleanup and metadata consistent

* improvement(uploads): persist multipart sessions in postgres

* fix(db): store table row trigger timestamps in UTC

* improvement(api): default folder deletion to non-recursive

* fix(billing): unify chat usage source

* improvement(logs): expose trace spans on log detail

* fix(logs): parse list trace spans

* improvement(api): replace workflow jobs with execution resources (#6294)

* improvement(api): replace workflow jobs with execution resources

* fix(api): preserve legacy jobs while preferring v2 executions

* fix(api): make execution polling resume-aware

* fix(ui): hide async examples for public workflows

* fix(api): bridge resume queue visibility lag

* feat(api): add v2 workflow resume endpoint

* fix(api): project pending resume attempts

* fix(api): prefer terminal logs over stale resumes

* improvement(api): unify v2 resource query layers (#6319)

* improvement(api): unify v2 resource query layers

* fix(api): address v2 review findings

* fix(api): preserve cancelled queue status

* fix(api): guard cancelled job transitions

* fix(api): close v2 resume and log gaps

* feat(api): rename v2 executions to runs

* feat(api): split credentials and secrets

* feat(api): add workspace metadata and email attribution

* improvement(api): consolidate public v2 route handling

* improvement(files): centralize operations across APIs and Copilot (#6392)

* improvement(files): unify rename authorization

* chore(skills): add file operation migration guide

* improvement(files): consolidate file operation authorization

* improvement(files): extract shared operation foundation

* improvement(api): simplify internal route declarations

* improvement(files): centralize application authorization

* refactor(api): share workspace file name validation

* refactor(files): centralize copilot application calls

* docs(skills): generalize application operation migration

* improvement(api): centralize remaining v2 resource operations (#6412)

* improvement(api): centralize v2 resource operations

* fix(api): preserve custom tool conflict errors

* improvement(api): migrate policy-sensitive v2 reads (#6410)

* improvement(workflows): centralize v2 application operations (#6411)

* refactor(api): migrate v2 knowledge operations (#6413)

* refactor(api): migrate v2 knowledge operations

* fix(knowledge): fail upload completion on dispatch errors

* fix(knowledge): preserve upload retry and VFS errors

* improvement(tables): centralize v2 application operations (#6414)

* improvement(tables): centralize v2 application operations

* fix(tables): preserve run validation and signals

* feat(auth): add scoped internal executor delegation (#6459)

* feat(auth): add scoped internal executor delegation

* fix(auth): derive delegation lifetime from one timestamp

* Include share status in file metadata

* feat(auth): centralize delegated identity policy (#6462)

* improvement(copilot): consolidate application adapters (#6450)

* improvement(api): harden application route boundaries (#6451)

* improvement(api): harden application route boundaries

* fix(folders): reject creates at workspace cap

* fix(knowledge): enforce trusted workspace scope (#6452)

* fix(knowledge): enforce trusted workspace scope

* refactor(knowledge): declare v2 body lifecycle

* finish knowledge application migration

* refactor(knowledge): compose copilot batch commands

* fix(knowledge): parse connector query flags

* fix(knowledge): finalize partial batch effects

* fix(knowledge): align merged application boundaries

* fix(knowledge): close application boundary review gaps

* style(knowledge): satisfy branch biome checks

* fix(knowledge): page connector documents in editor

* refactor: enforce Copilot table application boundary (#6453)

* refactor: enforce copilot table application boundary

* fix(tables): finish application boundary migration

* fix(tables): restore scoped copilot imports

* fix(tables): compose copilot commands atomically

* fix(tables): preserve workflow group scheduling

* fix(tables): complete fixed copilot composition

* fix(tables): reject enrichment output mutation

* fix(tables): complete authorized application boundary

* fix(workflows): migrate Copilot application boundary (#6455)

* fix(workflows): migrate Copilot application boundary

* fix(workflows): finish delegated application migration

* fix(workflows): encode VFS folder aliases

* fix(workflows): close application composition gaps

* fix(workflows): preserve VFS validation errors

* fix(workflows): complete application boundary migration

* test(workflows): format canonical binding coverage

* fix(workflows): scope executor metadata reads

* fix(workflows): bind executor metadata targets

* improvement(skills): align application operation guidance (#6532)

* feat(api): expose v2 resource owners

* fix(api): distinguish visible resource authorization failures (#6537)

* feat(api): generate v2 OpenAPI from contracts (#6509)

* feat(api): generate v2 OpenAPI from contracts

* fix(api): preserve string boolean wire defaults

* fix(api): document file download headers

* fix(docs): use TypeScript CLI with Next.js

* fix(docs): avoid client-rendered theme script

* fix(api): document departed audit default

* feat(api): replace legacy core docs with v2

* feat(api): generate v2 OpenAPI from contracts

* feat(api): refine generated v2 OpenAPI docs

* fix(docs): align localized v2 execution examples

* fix(ci): restore Helm diff and sync audit mock

* fix CI regressions after staging merge

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…il claim (#6546)

* fix(auth): let Microsoft sign-in link via Entra's domain-verified email claim

Microsoft is excluded from accountLinking.trustedProviders because the email
claim is attacker-controllable on /common/ (nOAuth). Entra never emits
email_verified for work/school accounts, so Better Auth refused to link a
Microsoft identity onto any existing user row, permanently stranding those
users on account_not_linked.

Derive emailVerified from the xms_edov optional claim, which Entra emits only
when the email's domain belongs to the user's tenant and an admin verified it
— the one email signal a hostile tenant cannot forge. Microsoft stays
untrusted; the guard now passes on its own merits.

The mapper returns an empty object when unverified, so it can only ever
promote unverified to verified, never downgrade.

* chore(auth): drop the unused MICROSOFT_TENANT_ID knob

Hosted Sim serves many Entra tenants, so it must stay on the multi-tenant
endpoint — pinning is only meaningful for a self-hoster restricting sign-in to
their own directory, and nobody is asking for that yet. The xms_edov fix is
independent of the tenant setting, so this removes surface without touching
behavior.

* chore(auth): tighten the Microsoft linking comments
… fetch default (#6547)

* fix(tools): bound internal tool calls by the plan deadline, not Bun's fetch default

* fix(tools): disarm Bun's fetch idle timer instead of passing a numeric deadline

Bun 1.3.14 ignores a positive numeric `timeout` on fetch and honors only the
boolean/zero form, so passing the plan deadline through changed nothing and
internal tool calls still died at the 300s default. Verified against the pinned
runtime: `{ timeout: 1000 }` does not abort a request that takes 3s to answer,
and `BUN_CONFIG_HTTP_IDLE_TIMEOUT` has no effect either — both are `main`-only.

The caller on this path already arms an AbortController with the plan timeout,
so the transport timer is disarmed rather than re-negotiated, leaving one
enforcement point instead of two that disagree.

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

* docs(tools): record the measured Bun 1.3.14 timeout behavior

Replaces the inferred note with the numbers from a probe against the pinned
runtime: no option dies at 300028ms, timeout:false survives 310031ms, and a
numeric timeout is ignored. Also records that bun-types@1.3.14 does not declare
the option even though the runtime honors it, which is why the interface is
declared locally.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
waleedlatif1 and others added 2 commits August 11, 2026 12:11
…t drizzle-orm (#6554)

check:sql-date-binding Babel-parsed all 13,941 source files in apps, packages,
and scripts. A violation can only come from an `sql` tag resolved through a
`drizzle-orm` import, and both the static and dynamic resolvers match the
specifier as a string literal, so a file that never names the module cannot
produce one. Only ~590 files do.

Skipping the parse for the other 92% of bytes takes the audit from ~4.5s to
~0.8s and drops it out of the four slowest audits, taking check:audits from
6.0s to 5.3s wall and 38.0s to 32.9s serial. Output is unchanged.
`/api/auth/forget-password` calls `auth.api.requestPasswordReset` without
headers, which bypasses Better Auth's rate limiter entirely — that limiter
lives in the HTTP router, not the endpoint. The route was an unthrottled
email-send amplifier keyed on any address a caller chose.

Add two dimensions via the existing route-helper family: a per-IP budget
before parsing (cheap pre-parse gate), and a per-recipient budget, since no
per-IP limit can stop a distributed attempt to bomb one mailbox. The recipient
key is normalized and hashed, so the bucket store never holds an address and
long inputs cannot inflate key cardinality. It is enforced before any user
lookup and identically whether or not the account exists, so a 429 is not an
account-existence oracle.

Also throttle `/api/auth/reset-password`, which had none and is an online
token-guessing surface.

Passing headers to `auth.api.*` is deliberately not the fix: Better Auth's
limiter throws an APIError that these routes' catch blocks project as a 500,
and it cannot express the per-recipient dimension.
…6551)

* fix(provenance): make one length floor the whole substitution rule

A literal shorter than eight characters is no longer substituted anywhere. It
was already the floor for matches inside a larger token; below it a second tier
still substituted whenever the hit sat on a word boundary — standing alone,
delimited, or as the whole value — on the theory that those positions made the
hit unambiguous.

Position is not the variable that matters. A hit on `7` is uninformative wherever
it sits, because the value space is ten. That tier rewrote `_raw_idx = 7` into
`_raw_idx = {{WEEKLY_OWNWORK_TTL}}` for the one shard whose index collided with a
TTL variable, and turned 2,000 boolean `had_error` cells into `[REDACTED_SECRET]`
because a `*_ENABLED` variable held `false`. Each was patched with a per-value
exception list; the floor subsumes both, so the lists are deleted.

With no literal below the floor reaching a matcher, the tier's machinery is
unreachable and goes with it: the match-policy type, its classifier, the word
boundary test, and the detect/render mode that existed only to select between
them. One constant now governs the question.

The cost is explicit and accepted: a secret shorter than eight characters is no
longer redacted from logs or model-visible content. Substitution cannot hide a
value that short — an observer who can read the surrounding text can enumerate
it. Two tests that pinned the old tier are rewritten to pin this, rather than
deleted, so the trade stays visible.

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

* test(provenance): restore two tests that the length floor had hollowed out

Greptile caught the byte-limit test: lengthening the catalog fixture without the
`recordResolved` value beside it left them mismatched, so the registry latched and
`projectResolvedSecretModelJsonContent` returned `{ safe: false }` at its
completeness guard — before any alias projection. The test passed while covering
nothing it is named for. It now records the same literal it catalogs, sizes the
limit between the raw bytes and the projected bytes, and asserts both directions
so a limit applied to the wrong side fails it.

Auditing every changed test for the same shape — a `recordResolved` value that
does not match its own catalog entry — found one more, pre-dating this branch: the
legacy-memory test drew its teeth from substituting the one-character secret `x`
inside `Box`, which the floor no longer substitutes, so it too had become vacuous.
Its fixture is now a full-length secret that appears in the message, which is what
makes "not projected" meaningful.

The three remaining mismatches are deliberate: those tests are about a resolution
that fails to verify.

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

* test(provenance): give the label-protection test a live matcher again

Cursor caught the third instance of the same shape: the fixture `TOK` is three
characters, so the floor drops it and the matcher is empty. The assertion was
`'Bearer {{TOKEN}}'` in and out, which an empty matcher satisfies exactly as well
as working label protection — the test could no longer tell them apart.

Use a fixture over the floor whose label still contains its own plaintext, and
project the bare plaintext first. That control fails if the matcher is inert, so
the atomic-label assertion beside it can only pass for the right reason.

Auditing every test on this branch for the shape — all fixtures below the floor —
returned twenty-four, but the rest are sound: most assert that nothing is
substituted, which the floor makes more certain rather than less, and the
remainder run through the resolver's causal path, which never consults a matcher.

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

* test(provenance): make the byte-limit test reject at the gate it names

Three limits can reject this value and my previous fix still tripped the wrong
one. `projectResolvedSecretModelJsonContent` checks the raw encoding, then walks
the content against a running budget, then re-encodes the projected object — and
only the last is what the test is for. At 16 the walk charged the key `a` and
then measured the 17-byte alias against the remaining 15, so it failed before the
re-encoding ran; at 25 everything passed. Neither assertion touched the check.

Twenty is the band that isolates it: the walk admits the alias against its
remaining 19, so a rejection there can only come from re-encoding the 25-byte
result. Asserting the content projection succeeds at the same limit pins that,
and deleting the re-encoding check now fails the test rather than leaving it
green.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…6550)

* fix(files): restore horizontal scroll in CSV and XLSX preview tables

#6125 moved the preview tables onto the markdown table chrome and gave both
surfaces `width: 100%`. That is right for prose and wrong for data: a CSV with
dozens of columns divides the frame between them, and since the same change
added `overflow-wrap: anywhere`, every column was free to break down to a single
character — so headers rendered as vertical columns of letters and the table
never exceeded its frame, leaving `overflow-x-auto` with nothing to scroll.

Split sizing out of the shared rule. Prose tables keep `width: 100%`; preview
tables size to their content and scroll, with column bounds so no column
collapses to a sliver and one long value wraps instead of pushing the rest
off-screen. Chrome (borders, padding, typography, header fill) stays shared.

* fix(files): scroll preview tables from one container, not two nested ones

DataTable owned `overflow-x-auto` while its caller owns the vertical scroll, so
now that preview tables are actually wider than the frame the horizontal
scrollbar rendered at the foot of the table rather than at the bottom of the
viewport — up to 1,000 rows below it for the two callers whose container is a
plain block (xlsx-preview, preview-panel). csv-table-preview escaped it only
because its flex column compressed the wrapper to the frame height.

Drop the inner overflow so the caller's bounded container scrolls both axes.
All three callers now place the scrollbar at the viewport bottom.

* fix(files): correct a stale reference to the removed inner overflow

The sizing comment still credited `.document-table`'s own `overflow-x-auto`
for the horizontal scroll, which the previous commit removed in favour of the
caller's container.
@Sg312 Sg312 changed the title v0.8.0: new workflow UI, cli, v2 API, salesforce JWT, provenance improvements, incidentio tools, block retries v0.8.0: new workflow UI, v2 API, salesforce JWT, provenance improvements, incidentio tools, block retries Aug 11, 2026
…erface (#6556)

* fix(agiloft): align the integration with the documented ewws REST interface

The CRUD tools targeted /ewws/REST/{kb}/{table}/{id} with JSON bodies and
guessed at the response by probing `data.result ?? data` and `id ?? ID`.
Agiloft documents that path as a URL convention only -- no method table, no
example call, and no response shape -- and no known client uses it. The EW*
operation family is specified end to end, including exact response bodies, so
every operation now goes through it and parses the documented
`EWREST_key='value';` assignment format.

- EWCreate/EWRead/EWUpdate/EWDelete/EWSearch/EWSelect/EWGetChoiceLineId are
  form-encoded and parsed via a shared EWREST parser; the /.json suffix is kept
  only on EWAttachInfo, the one operation with a published JSON sample
- EWDelete now sends the deleteRule the docs require, defaulting to
  ERROR_IF_DEPENDANTS so a delete fails rather than cascading
- EWRemoveAttachment uses GET; it does not accept DELETE
- EWSearch accepts the documented `search` saved-search label, so saved
  searches are reachable for the first time
- Search query help taught AND/OR; Agiloft uses && and ||
- Add run_action_button (POST /ewws/async/EWActionButton) for approvals and
  send-for-signature steps
- Drop saved_search: EWSavedSearch has no doc page, so neither its URL nor its
  response could be verified and it could only ever return an empty list
- Add force on unlock, filter read fields locally since $fields is
  undocumented, correct lock status to LOCKED/NO_LOCK, and stop reporting a
  fabricated page size of 25

* fix(agiloft): fail loudly on non-EWREST bodies and keep the retired tool resolvable

- EWSearch and EWSelect report an empty result set as `EWREST_id_length = '0';`,
  so a body with no assignments at all is a refusal Agiloft returned with HTTP
  200, not an empty result. Both routes now surface it as an error instead of a
  successful empty list.
- Re-register agiloft_saved_search as a retired tool. Removing it outright left
  workflows saved with operation='saved_search' deriving a tool id the registry
  no longer provided, which throws "Tool not found" at execution. It now fails
  through directExecution with a message pointing at the Search Records
  operation's Saved Search field, without issuing an undocumented request. It
  stays out of the operation dropdown so it cannot be chosen for new blocks.
- Guard EWCreate and EWUpdate against oversized record data. Those operations
  carry field values in the query string, so a large payload hits the request
  line limit; the tool now explains that rather than surfacing an opaque 414.
…d complete (#6559)

The reason a resolved-secret registry latched is the only thing that names
which guard tripped, and a refusal surfaces many frames later as one fixed
sentence. Two gaps had opened in that set.

`workspace-scope-missing` no longer has a producer: the `!context.workspaceId`
guard in the copilot table tool went away when `importRowsForModel` was
rewritten, and every operation now returns early on a missing workspace before
provenance import is reachable. The literal and its warn-classification test
case go with it — the test constructed the reason itself, so it asserted on
something nothing emits.

ResolvedSecretTraceProvenanceAccumulator had no reason concept at all, so its
three guards latched anonymously. That matters more there than on the registry:
the wire format carries only `complete`, so the consumer can only ever say
`source-provenance-incomplete`, and the guard is unrecoverable. Give it the
same required `reason` and name all three — a file source with no workspace
identity, a workspace file whose sidecar reads unknown, and an MCP tool that
timed out. A latch from `record()` stays silent, since it reflects a bundle
whose own registry already reported and subflow aggregation runs it per
iteration.

Fold the error/warn/by-design split into one `reportIncompleteness`. It was
copied across both registry latches and would have been copied a third time
here, and a copy that can be updated alone lets one reason be a fault in one
place and routine in another.

Also give the async workflow tool path its own import origin instead of
latching with none, and close `UnrecordedDurableProvenanceCause`, which was a
free-form string carrying a TSDoc claim that it was always a static literal.
* feat(blog): Tracking Secrets Through an Agent Run

Technical post on the resolved-secret provenance system: how a credential is
labeled when it resolves into a run, how the label travels through tool calls,
sandboxes and durable storage, and where it is checked on the way out.

Covers activation-requires-proof, the four egress boundary classes, fail-closed
degradation and the named-reason reporting that keeps a refusal explainable, why
the length floor beats an entropy floor, and the two production failures that
retired the word-boundary tier in favour of one constant.

Cover image plus two rendered diagrams: the activation/propagation/projection
lifecycle, and which value classes clear the substitution floor.

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

* docs(blog): rewrite the provenance post for readability

The two failures that retired the word-boundary tier were described with the
internal variable names they happened to involve, which carry no meaning outside
the codebase and read like a bug ticket. They are now described by shape — a
small number stored as a retry limit, a feature flag holding false — which is the
same lesson in a form a reader recognizes.

Style pass over the whole post alongside it: connective tissue between sections
rather than cold starts, second person where the reader is being addressed as a
builder, and longer explanatory sentences mixed back in among the short
declaratives, which had accumulated into a run of pronouncements. Section
headings take the descriptive "Topic: Detail" form the executor post uses.

No technical claim changed.

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

* docs(blog): state the durable-enforcement rollout as opt-out

The note said workspace files refuse an unknown classification and the other
surfaces are being brought up to that bar, which described the effect but not
the mechanism, and left the impression that strictness is something surfaces
gradually earn.

It is the other way round. Omitting a surface enforces, so an unreviewed call
site refuses by default; naming one is how a surface opts into reporting while
enforcement is rolled out. Three sentences, and the design point leads.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): align v2 permissions and resource behavior

* fix(api): refine resource authorization boundaries

* fix(tables): restore large durable imports

* fix(files): classify missing archive targets
* feat(desktop): sync updater and shell refinements

* style(desktop): refine macOS installer layout

* chore(desktop): trigger updated prerelease

* Unify resource panel collapse controls

* Desktop improvemetns

* Update

* browser updates

* fix(desktop): harden browser and terminal tab activity
@github-actions github-actions Bot added the requires-mothership-merge Has a companion PR on the mothership/copilot side — merge in lockstep label Aug 11, 2026
@github-actions

Copy link
Copy Markdown

⚠️ Cross-repo companion check

One or more companion PRs aren't merged into main yet (aggregated across the feature PRs in this release). Merging this without them will leave copilot and sim out of sync — merge them in lockstep.

  • ⚠️ simstudioai/mothership#431 — merged into staging (this PR targets main) — improvement(desktop): desktop app improvements

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

requires-mothership-merge Has a companion PR on the mothership/copilot side — merge in lockstep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants