Commit Graph
509 Commits
Author SHA1 Message Date
Waleed 025ea4d2bd fix(docs): serve JSON-LD in the HTML, fix sidebar spacing, and tighten the CLI guides (#6763)
* docs(cli): use -g for the install, and cut the prose that was not pulling weight

`--global` is valid but `-g` is what every comparable CLI documents, and the
long form only came from the package README. Also drops the yarn tab: it read
`yarn global add sim`, which works on Yarn 1 only — Yarn 2 removed global
installs, so that command fails for anyone on a modern Yarn. Adds `npx sim` for
running without installing.

The guides had accumulated design rationale that belongs in code comments rather
than user docs — why the filter grammar is JSON, why the config section naming
is asymmetric, why an unexpected error keeps its stack trace. Surveying how gh,
Vercel, Turborepo, Deno, Bun and Supabase write theirs, none carry that kind of
justification, and callouts are reserved for content whose absence produces a
wrong result rather than for general asides.

So: 1016 lines to 763, and 12 callouts to 3. The three that remain are the
pairing-code check, that `sim logout` does not revoke the key, and the
`--limit 100` default on `batch-delete`/`batch-update`, which silently truncates
a larger match. Troubleshooting drops the entries whose error message already
contained its own fix and keeps the seven whose cause is not obvious.

* fix(docs): render JSON-LD as native script tags so it reaches the HTML

All four structured-data blocks — WebSite, TechArticle, BreadcrumbList,
SoftwareApplication — were rendered with `next/script`, which never emitted a
script tag. Measured on a production build, `/api-reference/getting-started`
contained zero `<script type="application/ld+json">` elements; the payload
existed only in the `__next_s` client-injection queue and the RSC flight data,
so anything reading the served HTML saw no structured data at all. React was
also logging "Encountered a script tag while rendering React component" on every
page.

`next/script` is for loading and executing JavaScript. JSON-LD is data, and
Next's own guidance is a native `<script>` in the component. `serializeJsonLd`
already escapes the `<` character to its unicode form, which is the
sanitization that guidance calls for, so only the element changes.

Same build, after: three valid tags per page with `WebSite` in `<head>`, and the
injection queue gone entirely.

* fix(docs): scope the flush-separator rule to a container's first separator

`[data-separator]:not([data-separator] ~ [data-separator])` was meant to keep the
first sidebar group flush against the top padding, but `~` only reaches siblings,
so it also matched the first separator inside every expanded folder. Under
Self-Hosting, "Install" lost its top margin and crowded the "Architecture" link
above it — 25px of gap where "Configure" and "Operate" below it had 40px.

`:first-child` expresses the intent directly. Only the four sidebar roots open
with a separator; every nested folder starts with a page, so the intended case
still goes flush and nothing else changes.

* fix(docs): move the flush-separator rule onto the separator component

Keeps the styling with the component that owns it, per the repo standard, and
lets the global rule be deleted outright rather than corrected — `global.css`
now only loses a rule in this PR. Tailwind's `first:` variant compiles to the
same `:first-child` selector, so behavior is unchanged: the build emits
`.first\:mt-0:first-child{margin-top:0}` and the prerendered HTML carries the
class on the separator.
2026-08-15 20:39:34 -07:00
Waleed fed891f69d docs(cli): add a CLI docs section generated from the command tree (#6762)
* docs(cli): add a CLI section, generated from the command tree

The `sim` CLI shipped with no coverage in the docs site. Adds a fourth
top-level tab for it, and moves Academy last.

The command reference is generated. `sim` exposes 147 leaf commands across
33 groups, most of them derived at runtime from the v2 route contracts, so a
hand-written reference would be wrong the week after it was written. The
generator walks the command tree `buildProgram()` hands to commander — the
same tree the terminal parses — rather than re-deriving it from the contract,
which would be a second implementation free to describe commands nobody can
invoke. `check:cli-docs` is a zero-arg `check:*` script, so the existing audit
runner picks it up and stale pages fail CI.

Generating against the real tree surfaced a collision it had been hiding:
`bulkUpdateKnowledgeDocuments` and `updateKnowledgeDocument` both derived to
`sim knowledge documents update`. Commander resolves a duplicate to the first
match, so the bulk form shadowed the single-document one and its flags were
unreachable while still appearing in `--help`. The bulk form is now
`batch-update`, matching how `tables rows batch-delete`/`batch-update` already
handle the same REST overload, and the generator fails on any duplicate path
so the next one cannot land silently.

Five hand-written guides cover install, auth, configuration, output formats,
and scripting. Also corrects two commands in the package README that do not
exist as documented (`tables columns <tableId>`, and `--sort score:desc`,
which is JSON).

* docs(cli): document every flag from the contracts, add troubleshooting and a single-page reference

The command reference was structurally complete but said almost nothing: 223 of
377 flags rendered as "Set sort by" because the CLI only ever read flag help
from its own contract overrides, and fell back to restating the flag name.

The prose already existed. The v2 route contracts carry 931 `.describe()` calls
and the OpenAPI specs publish all of them — 327 parameters and 282 body
properties, 100% coverage — but the generated operation table dropped every one,
carrying only a per-operation summary. It now carries the field descriptions,
the path-parameter descriptions, and positional help, so `--help` and the docs
explain a flag the same way the API reference does. Placeholder descriptions are
now zero, and 147/147 commands, 377/377 flags and 130/130 arguments are
documented.

`check:cli-docs` fails on a request field with no `.describe()` rather than
letting it render as documentation that says nothing.

Also in this pass:

- Commands are root-level sidebar entries under a Commands heading rather than
  a folder, and headings are the command's description, so the table of
  contents distinguishes entries at the first word instead of repeating
  "sim knowledge documents …" fourteen times. A guard fails the build if two
  descriptions on a page collide, since they would share an anchor.
- A single-page `Complete reference` carrying all 147 commands, for in-page
  search and for agents fetching `/cli/reference.mdx`. It keys on exact command
  paths because descriptions are only unique within a group.
- A troubleshooting page, with every message copied from the source.
- Table columns are sized by a local component; the flag column was starved
  while descriptions kept most of the row empty.
- The prerelease install channels are dropped from the docs and the package
  README, which is what npm renders.

* fix(docs): match the CLI tab by path segment, and escape backslashes before pipes

`pathname.includes('/cli')` also matches `/integrations/clickup` and
`/integrations/clickhouse`, so both existing integration pages lit the CLI tab
and unlit Documentation. Matching is now per path segment. Anchoring to the
start would not work either — a non-default locale prefixes the path, as in
`/ja/cli` — so the segment is matched wherever it sits.

Table cells now double a backslash before escaping pipes. A value ending in one
turned `a\` + `|` into `a\\|`, which the table parser reads as an escaped
backslash followed by an unescaped pipe, splitting the cell early. Nothing in
the command surface contains a backslash today, so this was latent rather than
visible.

The reference page's global options table is two-column and was being wrapped in
`CommandTable`, which sizes the second column for the `Required` cell of the
three-column tables and crushed the description into 5.5rem. It now matches the
overview page, which leaves that table unsized.
2026-08-15 19:25:34 -07:00
Waleed 4f722c6439 fix(deploy): resolve the error-output flag from edges on both sides of change detection (#6754)
* fix(deploy): resolve the error-output flag from edges on both sides of change detection

A block can hold `errorEnabled: false` while an error edge still leaves it.
`setBlockErrorEnabled` does not remove existing error edges, both block renderers
draw the port on `errorEnabled || hasErrorConnection`, and the executor never
reads the flag at all — the edge alone decides routing. So the two spellings are
one state, and nothing about the block has functionally changed.

Only the deployed side reconciled them. `materializeDeploymentState` backfills
`errorEnabled: true` for any block with an error edge, while the live normalized
tables are read verbatim. Change detection compared the raw flag, saw `true`
against `false` for a block that had not changed, and no redeploy could clear it:
deploying snapshots the live `false`, which the next read backfills straight back
to `true`. The deploy button sat on "Update" permanently, and the server path
(`checkNeedsRedeployment`, which reads the raw jsonb and skips the backfill)
disagreed with it.

Lift the rule into `@sim/workflow-types` as `collectErrorSourceBlockIds` /
`resolveEffectiveErrorEnabled` so the backfill and the comparison share one
definition, and apply it to both sides of the diff. Compared outside the
structural gate, since the flag can match while the edges disagree.

* chore(deploy): use TSDoc for the error-output comparison comments
2026-08-15 17:48:42 -07:00
Theodore Li 623c30f05c fix(credentials): restore reliable OAuth connections (#6753)
* fix(credentials): restore reliable OAuth connections

* fix(credentials): cover OAuth handoff edge cases

* fix(credentials): preserve reauthorization outcomes
2026-08-15 20:43:34 -04:00
Waleed 4fc0fb4bad refactor(resources): converge Files onto the shared drag hook and batch bulk authorization (#6748)
* refactor(resources): converge Files onto the shared drag hook and batch bulk authorization

Files kept a 280-line copy of the foldered-list drag logic because it also
accepts OS file drops. The copies had already drifted, so the external drop
becomes an option on the shared hook and the copy goes away.

- Add `externalDrop` to `useFolderRowDragDrop`: folder rows highlight and spring
  open for an OS file drag exactly as for a move, while the body and breadcrumb
  decline so the page-level upload overlay owns those regions
- Collapse the three drop-active booleans into one `ActiveDropTarget` union, so
  exactly one affordance is armed by construction rather than by hand-clearing
- Keep drop-target writes identity-stable so `dragover` does not re-render the
  list on every event
- Give each list its own drag MIME again, restoring the cross-surface isolation
  `drag-payload.ts` documents
- Let a folder spring open more than once per drag, so a drag can walk back out
  through the breadcrumb and descend again; the guard against re-entering the
  folder already on screen moves to `useSpringNavigation`, the only layer that
  can state it
- Resolve each bulk item against the workspace context the batch already holds,
  and memoize the effective-permission lookup for the batch, replacing two
  invariant queries per item
- Fill the drop target at `--surface-active`: `--surface-4` is the button-base
  token and is lighter than hover in light mode, so the strongest row state read
  the faintest

* fix(resources): dismiss the upload overlay on a folder drop and re-check permission per item

The drag hook stops propagation on a drop it handles, so the page-level handler
that cleared the upload overlay never ran and the chrome stayed up over the
finished upload. Both consuming paths now share one dismissal.

Drop the batch permission memo: each item in a bulk move or delete commits
independently, so reusing one allow verdict let a revocation part-way through a
batch go unseen by the remaining items. The workspace context is still resolved
once per batch, which was the larger saving.
2026-08-15 16:44:03 -07:00
Waleed 0cd87bad2f feat(resources): multiselect on tables and knowledge, spring-loaded folders (#6721)
* feat(resources): multiselect on tables and knowledge, spring-loaded folders

Tables and Knowledge lists get the checkbox multiselect Files already had —
selection, shift-click ranges, select-all, and a shared bulk action bar for
move and delete.

Dragging a resource onto a folder row and resting there now opens that folder,
so nested filing is one gesture (macOS Finder spring-loading). Works on Files,
Tables, and Knowledge.

Selection, the action bar, the drag payload, the drag ghost, and drag teardown
are extracted to shared modules; Files migrates onto them rather than keeping
its own copies.

Bulk move and delete land as single authorized operations that take folders and
resources together, so a mixed selection commits once instead of fanning out.

Fixes two latent UI bugs: the drop-target outline referenced --accent, an
HSL-channel token only valid via hsl(), so it silently rendered as currentColor;
and rows painted hover and selected with the same surface token, making the two
states indistinguishable.

* chore(audits): re-record route ratchet after merging staging

* fix(bulk): reject a move target inside the moving subtree and report contained folders deterministically

* improvement(resources): neutral drop affordance, longer spring delay, and a body drop target

* fix(resources): drop into the open folder on Files, return on an unused spring-open, and guard bulk caps

* fix(drag): end a drag on pointer resume instead of an idle timer

* feat(resources): drag onto breadcrumbs to move back up, and round the drop ring

* fix(resources): tint the list region on drop instead of ringing it

* refactor(folders): share the spring-navigation lifecycle so Files returns too

* fix(breadcrumbs): accept a drag on the open-folder crumb too

* fix(files): keep the view in a spring-opened folder when an OS upload lands there
2026-08-15 11:53:21 -07:00
337a53f12c feat(cli): Sim CLI with AWS-style profiles and a platform key exchange (#6147)
* improvement(api): pull in the v2 external endpoint surface

Cherry-picks improvement/v2-endpoints (98c85677f5) onto the current base.

The v2 surface standardizes one response family across every endpoint:
`{ data }`, `{ data, nextCursor }`, and `{ error: { code, message, details? } }`,
rendered through apps/sim/app/api/v2/lib/response.ts. v1 auth and rate limiting
are reused as-is; the workspace-access and enterprise-audit checks are split into
`resolve*` cores returning structured failures, with thin v1 wrappers that render
the old `{ error }` body so v1 behavior is unchanged.

The branch's own /api/v2/tables/** is dropped. Staging's tables v2 (#6067,
typed predicate grammar + POST /api/v2/tables/[tableId]/query) supersedes it and
lands in the following merge; the two are reconciled onto the shared envelope
separately.

Conflict resolutions:
- v1/middleware.ts: keeps resolveWorkspaceRequestActor alongside the new
  resolveWorkspaceAccess/resolveWorkspaceScope split
- v1/audit-logs/auth.ts: keeps the newer targetOrganizationId parameter and
  isOrganizationBillingBlocked check inside the structured resolver
- bun.lock: taken from HEAD; the branch's lock churn was unrelated lucide-react
  hoisting

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

* 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(cli): sim CLI with AWS-style profiles and a platform key exchange

Adds `packages/sim-cli` (`@sim/cli`, bin `sim`) and extends the existing CLI key
handoff so it can mint the credential the public API actually accepts.

## Key exchange

The handoff already existed but only minted *copilot* keys, which do not
authenticate `/api/v1` or `/api/v2` — those want a Sim platform key. The
approval now carries a `scope`:

- `copilot` (the default, so terminals built against the original flow are
  unaffected) mints as before
- `platform` mints a Sim API key: workspace-scoped when the approver is a
  workspace admin, personal otherwise

Scope and workspace are fixed at *approval*, not at poll: the poll is
unauthenticated by necessity, so the browser is the only moment a human is
present to consent and the only place a permission can be checked. The poll
echoes back what was granted rather than what was asked for, so the CLI cannot
file a copilot key under a platform profile and fail later with an opaque 401.

Picking a workspace and scoping a key to it are kept separate. The terminal has
no key yet, so it cannot list workspaces — the browser picker is the only place
that choice can be made, and the pick comes back as the profile's default
whether or not the key is bound to it. Otherwise a non-admin would pick a
workspace by name and then have to go find its id by hand.

Personal-key creation moves into `lib/api-key/orchestration` so the settings
route and the exchange share one issuer.

## CLI

Profiles work like the AWS CLI: `~/.sim/config` for settings (`[profile dev]`),
`~/.sim/credentials` for keys at 0600 (`[dev]`), selected via `--profile` /
`SIM_PROFILE`. Each setting resolves flag → env → file → default, and
`sim whoami` reports the winning source so a surprising value is explainable.
CI can skip login entirely with `SIM_API_KEY` + `SIM_WORKSPACE`.

Commands cover the v2 surface pulled in earlier: workflows, logs, files, and
knowledge, with `--output json` passing the API's own shapes through for `jq`.
`sim tables` is deliberately absent — that surface is still in flux.

## Drift fixes

The v2 routes were authored a month ago and had fallen behind their services:
`checkActorUsageLimits(userId, workspaceId)` → the billing-attribution flow
(which also restores correct payer attribution for workspace keys on KB upload
and search), `processDocumentsWithQueue` gained a required argument, and the
deploy/rollback param objects had stale fields. Caught by a cold type-check —
an incremental run had reported these files clean.

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

* 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(cli): generate the CLI's v2 API from the route contracts, add tables

The same endpoint was being described in three hand-maintained places: the Zod
contracts the routes validate against, the OpenAPI documents, and the CLI's own
TypeScript interfaces. Two of those are now derived.

## Generation

`scripts/generate-v2-cli-api.ts` reads `apps/sim/lib/api/contracts/v2/**` and
emits `packages/sim-cli/src/generated/v2-api.ts`: request/response types for all
44 operations plus an operation table (method, path, path params) the client
dispatches through, so a route that moves or changes verb moves the CLI with it.

The contracts are the right source because the routes validate against them — a
shape that disagrees with a contract is a shape the server would reject. Zod
4's `z.toJSONSchema()` handles all 110 schema slots; the JSON-Schema-to-TS
emitter is hand-rolled over that known-narrow subset and throws on anything
unrecognized rather than degrading to `any`, since silence is how a generated
client drifts.

`packages/*` must not import `apps/*`, so the generated file is plain type
declarations with no imports and the script does the crossing at build time.

`check:cli-api` fails CI when the file is stale. The generated directory is
excluded from biome: the pre-commit hook runs `check --write`, which would
otherwise reformat generated output and fail that check with an unrelated
message.

## OpenAPI: checked, not generated

The docs specs carry ~1000 hand-written descriptions and ~400 examples that Zod
schemas do not encode, so generating them would trade real documentation for
mechanical accuracy. `check:openapi-drift` reconciles structure instead — every
v2 path and method must exist on both sides — keeping the prose while still
failing on divergence. Both currently agree on all 44 operations.

## Tables

`sim tables list|get|columns|rows|insert|delete-rows`, built on the generated
types. Rows go through the POST query endpoint even unfiltered, since it is the
only shape carrying the predicate. Row columns are discovered at runtime and
unioned across the page, so a sparse row cannot hide a column.

Deletion requires an explicit `--row`/`--filter` selector *and* `--yes`; an
argument-less call would otherwise empty the table. Path params are
percent-encoded — an id containing `/` or `?` would otherwise retarget the
request.

The four existing command groups drop their hand-written interfaces for the
generated ones.

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

* fix(cli): make the generated v2 API a fixed point of the formatter

The pre-commit hook rewrote the generated file immediately after it was
committed, so `check:cli-api` then failed in CI reporting contract drift that
had not happened — the only difference was quote style.

The biome.json exclusion added alongside it does not help: lint-staged runs
`biome check --write` on explicit paths, which bypasses `files.includes`. It
implied protection it never provided, so it is removed.

The generator now pipes its output through `biome format --stdin-file-path`
instead, making the emitted file conformant by construction. The hook has
nothing left to change, and the check compares like with like. A formatter
failure throws rather than emitting unformatted output, since falling back
silently would reopen the same loop.

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

* fix(cli-auth): wait for the workspace list before allowing approval

The picker fell back to "No workspace (personal key)" while the workspace query
was in flight, and Connect stayed live through that window. A fast click
approved a personal key with no default workspace — when the same click a moment
later would have issued a workspace-scoped key. The fallback read as an answer
rather than a pending state, so the card could promise one outcome and deliver
another.

Connect is now disabled until the list resolves, the trigger shows a loading
label (a placeholder would not show, since the fallback always counts as a
selection), and the explanatory line no longer asserts the personal-key outcome
before it is known.

Failure is treated as degraded rather than fatal: the picker disables but
Connect stays enabled and the copy says a personal key will be issued, so a
transient list failure cannot strand a waiting terminal.

Tests cover the pending, loaded, admin-binding, and error states; the two
loading assertions fail against the previous implementation.

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

* fix(cli-auth): name minted keys by timestamp, not date

A second login on the same day failed with `A workspace API key named "CLI
(2026-07-30)" already exists` — after the user had already approved in the
browser, so the whole handoff was wasted and there was no way to complete it
without renaming the existing key.

Key names are unique per owner, so the name has to be unique per login. Now
`CLI (2026-07-30 15:42:07Z)`: second precision, UTC so it is unambiguous in a
shared workspace key list and sorts chronologically.

The comment claiming a same-day collision was desirable (so logins would reuse
one key) was wrong — nothing reuses the key, the mint just fails. A collision at
second precision now means something genuinely unexpected, so it is still
surfaced rather than retried under a suffixed name.

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

* 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

* feat(cli): CLI contract for the v2 surface, incl. execution

Adds `packages/sim-cli/src/contract` — the declarative definition of how the
terminal maps onto the API — and folds in the v2 execution endpoints that just
landed on improvement/v2-endpoints.

## The contract

Read it as a diff against what is already derivable, not a listing. Method,
path, path params, field types, enum values, defaults and required-ness all come
from the generated operation table (which comes from the Zod contracts), and the
command name derives from `<resource> [sub-resource] <verb>`. 23 of 47
operations therefore need no entry at all.

The 24 that do carry only what a schema cannot express:
- names, where REST overloads one path — `DELETE /rows` vs `DELETE /rows/[rowId]`
  becomes `batch-delete` vs `delete`, and `DELETE /deploy` becomes `undeploy`
- flags, where a field's type misdescribes its meaning — `workflowIds` is
  `z.string()` that the route splits on commas; no generator can infer that
- columns, which are editorial
- confirm, for the 8 destructive operations

## Execution

`executeWorkflow` / `getWorkflowExecution` / `cancelWorkflowExecution` derive
badly (`/execute` and `/cancel` are verbs the deriver reads as nouns), so all
three are named explicitly: `workflows run`, `workflows executions get|cancel`.

`stream` is marked `omit`: it switches the response to SSE, which the JSON
client would try to parse. Advertising a flag that breaks the response is worse
than not offering it — a `--follow` command that renders the stream is separate
and hand-written, like `files download`.

## Also

- Drops `check:openapi-drift`. The branch landed `check:openapi`, which does the
  same path/method reconciliation plus a recursive field diff and validates doc
  examples against the real Zod schemas — mine was a strict subset.
- Surfaces the new v2 rollout gate in the CLI: it answers 404 for callers
  outside the cohort, indistinguishable from a missing resource, so a 404 now
  carries that as a possibility rather than a diagnosis.
- `executor/utils/errors.ts` widens instead of casting through `unknown`, which
  is both more honest (the value is an Error) and keeps the double-cast ratchet
  at 8.

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

* 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>

* feat(cli): yaml and text output formats

`--output` now takes table | json | yaml | text, settable per-command, via
SIM_OUTPUT, or persisted per profile as before.

`yaml` joins `json` in rendering the API's raw values rather than the table's
formatted cells, so a duration stays `1500` instead of becoming `"1.5s"` —
switching format changes the encoding, never the data. Line folding is disabled:
valid YAML, but it breaks line-oriented greps and is miserable to read.

`text` is tab-separated with no header and no colour — the shape `cut -f2` and
`while IFS=$'\t' read` expect, so shell plumbing works on a box with no JSON
tool. It uses the rendered cells rather than raw values, since it is a human-ish
format for pipelines rather than something to parse. An absent value collapses
to an empty field instead of the table's em-dash: `cut` returning a literal `—`
would read as a value to every downstream emptiness test.

A bad `--output` is now an error (commander `.choices`) rather than a silent
fall back to `table`. The environment variable and the config file stay tolerant
— those are ambient and set once, so a bad value should not break every command,
but a flag just typed should not be quietly disregarded.

Uses js-yaml 4.3.0, already a direct dependency of apps/sim, rather than adding
a second YAML library to the monorepo.

Also drops a stale README reference to check:openapi-drift, which the v2-endpoints
merge superseded with the deeper check:openapi.

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

* 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>

* refactor(cli): output format is a profile setting, not a flag

Drops `-o, --output`. Format is set once per profile with
`sim configure --set-output <format>`, or overridden ambiently with SIM_OUTPUT
for a one-off (`SIM_OUTPUT=json sim logs list | jq`) and for CI, which already
runs file-less on env alone.

Both remaining sources are ambient — set once, then read by every later command
— so an unrecognized value falls back to `table` rather than breaking the CLI.
There is no longer a strict tier, because there is no longer anything typed
per-invocation to be strict about.

Frees `-o` for `sim files download -o <path>`, which previously had to share the
short flag with a global that meant something else entirely.

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

* feat(cli): runtime that builds every command from the contract

Turns the CLI contract into working commands. 43 leaves across 7 groups, up
from the 6 hand-written ones — every v2 operation the contract does not hide is
now reachable, including `sim tables upsert`, `sim workflows run`, and the whole
tables surface.

## What the generator now emits

`V2_OPERATIONS` carries a field→slot map per operation: each query/body field's
kind, whether it is required, its enum values, and its server-side default.
Types alone could not drive this — the runtime has to *iterate* fields to build
flags, and everything from argv arrives as a string, so it needs the kind to
turn "50" into 50 and '{"a":1}' into an object.

It also lifts each operation's one-line `summary` from the OpenAPI specs. The
contracts carry validation, not prose, so `--help` had been showing raw URLs;
the specs already hold a written summary per operation and `check:openapi`
guarantees one exists, so this reuses documentation rather than inventing a
second place to describe the same endpoint.

## The runtime

`derive.ts` names a command `<resource> [sub-resource] <verb>` from the route,
covering 41 of 47. `request.ts` assembles the call: path params from positional
args, `workspaceId` injected from the profile into whichever slot declares it,
everything else coerced and validated locally — so a bad enum, malformed JSON,
missing required flag, or absent workspace fails before any network call.
`build.ts` constructs the commander tree, auto-pages cursor lists up to
`--limit` (0 for everything), and renders through the contract's columns or, for
runtime-shaped rows, keys unioned across the page.

Fixed while wiring: `new Command('upsert <tableId>')` makes the *whole string*
the command name, so `sim tables upsert` never matched and fell through to the
group's help. Arguments have to be declared with `.argument()`.

## What stays hand-written

Two leaves, each for a reason generation cannot satisfy in principle:
`files download` streams binary rather than the JSON envelope, and
`tables rows list` discovers columns from user-defined row data nested under
`data`. They attach onto the generated groups, so `sim files --help` lists them
alongside the rest. The five previous command files are deleted.

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

* fix(cli): review round 1 — flag lookup, terminal controls, download safety

## CLI flags silently dropped (Cursor, High)

Commander camelCases every multi-word flag, so `--min-duration-ms` is stored as
`minDurationMs`. `buildRequest` looked flags up by their own kebab name, found
nothing, and dropped the field — no error, it just never reached the API. That
was every multi-word flag on every generated command.

The unit tests passed because they fed flag values already keyed by flag name,
which is not what commander produces — they validated a fiction. Added
`build.test.ts`, which parses real argv through the built commands; three of its
assertions fail against the previous code. The old tests now use camelCase keys
with a comment saying why.

## Terminal control sequences (Greptile, P1 security)

`stripAnsi` matched only SGR (`ESC [ … m`), so a knowledge document, table cell,
or workflow name could carry OSC, non-SGR CSI, or `ESC c` through to an
interactive terminal — setting the window title, moving the cursor to overwrite
what was already printed, or resetting the terminal. Replaced with a `sanitize`
covering OSC (BEL- and ST-terminated), CSI, any ESC + printable, and the bare
C0/C1 range, keeping tab and newline. Applied where API values become display
text, so the colour the CLI adds afterwards still works.

## Downloads (Greptile, P1 ×2)

`createWriteStream` truncated silently, and the destination name usually comes
from the server's content-disposition rather than anything the caller typed —
so a download could irreversibly replace an unrelated local file. Now opens `wx`
and fails with a message naming `--force`, which was added for the deliberate
overwrite.

The stream's error listener was attached after the read loop finished, so an
EEXIST/EACCES/ENOSPC during writing was an unhandled 'error' event that took
down the process. It is now registered before the first write and raced against
the pump.

## Personal-key caption (Cursor, Low)

With "No workspace (personal key)" picked, the caption still promised a default
workspace the approval does not send. It now distinguishes no-pick from
picked-but-not-admin.

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

* fix(cli): review round 2 — body-cursor paging, timestamp sanitization

## `tables rows query` printed nothing (Cursor, High)

`isCursorList` only looked for `cursor` on the query slot, but `queryRows` is a
POST whose whole filter — cursor included — is in the body. It therefore took
the single-request path, which handed an array of rows to `printRecord` and
printed an empty record, and it never auto-paged past the first page.

Replaced with `cursorSlot`, which checks both slots and tells the pager where to
put the cursor back. Added a defensive branch so an array reaching the
single-resource path renders as a list with inferred columns rather than
silently printing nothing.

## Invalid timestamps bypassed sanitization (Greptile, P1 security)

`timestamp()` echoes an unparseable value verbatim, and that value is still
server-supplied — so the branch was a way past every other formatter for the
control sequences round 1 closed. Now sanitized on that path too. Audited the
remaining formatters: no other path returns a server value unsanitized.

Both fixes have tests that fail against the previous code.

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

* fix(cli): review round 3 — poll retry, download flush errors

Both findings are flaws in round 1's fixes rather than in the original code.

## A redeemable login was thrown away (Cursor, High)

`pollForKey` treated every non-429 status as terminal. But the poll route
releases its mint reservation on any mint failure — its own comment says "a
later poll can retry" — so a transient 5xx or a same-second name conflict ended
the login after the user had already approved in the browser, forcing a full
restart for something the server had deliberately left recoverable.

Retryable is now 409, 429, and 5xx. Everything else stays terminal: 400 means a
malformed request id or verifier and 401/403/404 mean the server is refusing on
purpose, so retrying those would just spin to the 15-minute timeout.

## A failed download reported success (Greptile, P1)

`file.end(resolve)` passes the flush error to the callback as its argument, so
the pump fulfilled *with* the error and the command printed "Saved" for a
truncated file. Confirmed against node directly — `end`'s callback receives the
errno. It now rejects on that argument, which is the path an ENOSPC actually
takes, since the bytes may not reach disk until the final flush.

Adds `device-flow.test.ts` (11 tests: the retry matrix, transport failure,
terminal refusals, and that the poll secret never enters the browser URL) and
`hand-written.test.ts` covering the download's overwrite guard and flush
failure. The two retry tests fail against the previous code; the flush test
needs `/dev/full` and so runs in CI rather than on macOS.

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

* fix(cli): review round 4 — repeated flags encode per field kind

`coerce` comma-joined every `list` flag, but that is only correct for the three
fields whose wire type is a `string` the route splits (`workflowIds`,
`folderIds`, `triggers`). The others genuinely want an array:

- `rowIds` and `selectedOutputs` are `array`, so joining sent a string where the
  schema expects a list — `sim tables rows batch-delete --row a b` failed
  validation, and so did a single `--row a`
- `knowledgeBaseIds` is a string-or-array union whose array branch is the right
  one; joining made `kb_1,kb_2` a single bogus id, so multi-`--kb` search
  silently searched nothing

`list` now means only "accept the flag more than once" — the encoding follows
the field's kind, which the generator already records. The two questions were
conflated under one contract field and the `FlagSpec` doc now says so.

Four tests, three of which fail against the previous code.

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

* fix(cli): review round 5 — header sanitization, auth ordering, stale suggestion

## Table headers stayed executable (Greptile, P1 security)

Round 1 sanitized cell *values* but not the column *names*, and a table's
columns are user-defined — so the same control sequences were still executable
one row higher, in the header. Sanitizing is now done inside `renderTable`
rather than at each call site, so a future column source cannot reopen it, with
the two key-derived column builders covered as well.

## Fresh install was told the wrong first step (Cursor, Low)

Generated commands read `profile.workspaceId` directly, bypassing
`requireWorkspace()` — which checks the key first precisely so a new user is
told to log in rather than to set a workspace they cannot use yet. That ordering
was fixed for the hand-written commands earlier and reintroduced by the runtime.
`sim tables list` on an empty profile now says "Not logged in" again.

## A stale suggestion shadowed the fallback (Cursor, Medium)

The picker took `selected ?? suggestedWorkspaceId ?? lastActiveWorkspaceId`. The
suggestion comes from a profile the CLI wrote earlier, so it can name a
workspace the user has since left — and merely being truthy, it blocked the
last-active fallback and left the card on "no workspace" with a perfectly good
one available. It now counts only when it resolves against the loaded list.

Two of the three have tests that fail against the previous code; the third is
verified end-to-end (`sim tables list` on an empty profile).

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

* 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>

* feat(cli): pick up the new v2 domains; discover modules instead of listing them

Merges `v2-api-spec` (#6150 — v2 endpoints for MCP servers, skills, custom
tools, folders, credentials) and the newer `improvement/v2-endpoints`.

## The generator was list-driven, so none of it would have appeared

`DOMAINS` and `SPEC_FILES` were hardcoded. Five new contract modules and a new
`openapi-v2-resources.json` had landed, and the generator would have skipped
every one — silently, with `--check` still passing, because the generated file
matched a generator that never looked. Both are now discovered from disk.

That is the same silent-drop class the review rounds kept surfacing, and it is
the property the whole pipeline rests on: a new v2 domain should reach the CLI
by regenerating, not by remembering to edit a list.

Result: 47 → 72 operations, 13 contract modules, and 25 new commands
(`sim skills list`, `sim mcp-servers get`, `sim folders delete`, …) with no CLI
change beyond the discovery fix. Summaries for the new domains now resolve too,
so their `--help` reads properly instead of falling back to `METHOD /path`.

## Confirmation gates for the new destructive operations

Five new DELETEs arrived ungated. `deleteFolder` is the sharpest — the route
archives the folder *and cascades to its contents* — so its message says so
rather than reading like a single-item removal.

Added a test asserting every DELETE carries a confirmation, with
`undeployWorkflow` the one documented exception (reversible by redeploying). It
fails against this commit's own starting state, so the next domain to arrive
cannot land ungated the way these did.

## One fix outside the CLI

`lib/skills/orchestration/skill-lifecycle.ts`, added by #6150, imports
`OrchestrationErrorCode` from `@/lib/workflows/orchestration/types`, which does
not exist — the type lives in `@/lib/core/orchestration/types`, where every
other consumer reads it. The branch does not type-check without this.

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

* fix(cli): render single-key resource envelopes, and column the new domains

`sim mcp-servers create` created the server, exited 0, and printed nothing.
The v2 route answers `{ data: { mcpServer: {...} } }`, and the record renderer
keeps only scalar fields — one key holding an object left it with none. Unwrap
a lone object-valued key before rendering; a payload with siblings (`{ row,
operation }` from upsert) is a real result and is left alone.

The five domains that arrived with the last generation had no contract columns,
so `mcp-servers list` inferred 20 including `hasOauthClientSecret`. Give each a
column set.

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

* 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.

* fix(cli): stop dropping nested fields, and emit exports as documents

`sim workflows export <id>` printed `version` and `exportedAt` and nothing
else. The record builder kept only scalar fields, so `workflow` and `state` —
the entire export — were discarded with nothing to say they had been. Same for
`workflows get`, which silently dropped `variables` and `inputs`.

Record views now render every field. Nested values serialize to one line and
are cut at 160 chars: visibly partial beats silently absent, and json/yaml
output still prints them whole.

Export is a document, not a record — it exists to be redirected to a file and
fed back to `import`, and table/text flatten and truncate, so neither can
round-trip it. `document: true` in the contract makes those formats fall back
to JSON; yaml is honoured because it round-trips.

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

* feat(cli): JSON flags accept @file and @- alongside inline JSON

A workflow export is hundreds of lines, and `--workflow` only took it inline.
The shell makes that miserable: unquoted `$(cat wf.json)` word-splits into
broken JSON, and nothing in the help said passing a file was an option.

Every JSON flag now reads `@path`, or `@-` for stdin, so the round trip is
`sim workflows export <id> > wf.json` then `import --workflow @wf.json` — or
one pipe. `@` cannot collide with a real value because JSON only ever starts
with `{ [ " -`, a digit, or t/f/n.

Stdin drains with a readSync loop rather than readFileSync(0): a pipe is opened
non-blocking, so the single-read form returned EAGAIN and died with a raw stack
trace exactly when the upstream process had not written yet.

Parse failures that look like a filename now say so — naming @path, or the file
itself when the bare value turns out to exist.

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

* 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(cli): wire the expanded v2 files surface

Regeneration picked up seven new operations (72 → 79), every one of which
derived badly. `/files/move` and `/files/bulk-archive` put a verb where the
deriver expects a sub-resource, so each became a group holding a lone `create`;
`GET /files/[id]/share` fetches one share and was read as a collection and
named `list`; and `PATCH /files/[id]` derived to `files update` while its own
summary said "Rename File".

Named them: batch-archive (matching tables rows batch-delete), move, rename,
restore, set-content, share get, share set. Bulk archive is gated behind --yes
like the other batch destructives.

`files list` gained --scope active|archived, and its rows now carry folderPath —
added as a column, since which folder a file sits in is what distinguishes two
rows sharing a name.

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

* feat(cli): sim files upload

The counterpart to `files download`, and hand-written for the same reason:
POST /api/v2/files is multipart, which the generated flag surface cannot
express, so `uploadFile` has been hidden since the start.

Reads the file with openAsBlob so it stays on disk while the request is
written, rather than buffering the whole upload in memory. Size is checked
against the route's own 100MB ceiling before anything is sent. Content type
comes from the extension, since the stored type decides whether the workspace
later renders a file or offers it for download.

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

* fix(cli): make tables rows query show the rows

Three things stacked up so the command appeared to do nothing.

A row's cells live under `data`, and column inference skips object-valued
fields — so the table came back listing an id and two timestamps per row and
none of the content the query was run for. `expand` names the wrapper whose
keys become columns, unioned across the page like the top-level ones. A cell
key that shadows a top-level field is shown by its full path, so two different
values never share a header.

A cell containing a newline pushed the rest of its row onto the next line and
every column after it lost alignment; in text mode a tab invented a field that
`cut -f` reads as real. Display cells are now flattened to one line. `sanitize`
still keeps \t and \n — json and yaml must round-trip them, and this is applied
only to finished cells.

A single cell holding an LLM response set the column width for the whole table
and pushed everything after it off-screen, so table cells clamp at 60 columns.
text/json/yaml are untouched: those exist for the whole value.

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

* fix(cli): make boolean flags able to say false

`--is-active false` turned sharing ON and reported success. Booleans were
declared presence-only, so the flag meant `true` and commander dropped the
`false` as an argument the command had no use for — silently, because excess
arguments are ignored by default.

A required boolean now takes its value (`--is-active <true|false>`): it is a
state to set, not a switch to flip on, and as a presence flag it could only
ever send one of the two values it needs to express.

Optional booleans stay presence-flags — `--deployed-only` reads better than
`--deployed-only true` — but each also gets `--no-<name>`. Omitting one means
"leave it alone", which is not the same as setting it false; without the
negation there was no way to disable an MCP server or unlock a folder.

Excess arguments are now an error on every generated command, so a value
attached to the wrong flag stops rather than being silently discarded.

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

* 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(cli): pick up v2 workflow CRUD, table transfers, and list search

79 → 111 operations across three merged PRs.

The generator could not read the new contracts at all: a table view's filter is
a recursive predicate, so Zod lifts it into `$defs` and refers to it, and
`toTypeScript` threw on the first `$ref`. Those definitions are now hoisted into
named aliases — recursion TypeScript resolves without complaint — named after
the type that owns them so two operations lifting their own `__schema0` cannot
collide.

Uploading is no longer one multipart POST. `POST /api/v2/files` is gone,
replaced by a presigned handshake, so `files upload` was left calling a route
that no longer exists. It now creates the upload, signs part URLs in batches of
100 (each is short-lived, so signing all of them up front would expire the last
ones), PUTs each part straight to storage, and completes with the ETags —
aborting the upload if any step fails, since a half-finished one holds storage.
Parts are read through `Blob.slice`, so only the part in flight is in memory.
Verified byte-identical on a 24MB round trip.

The rest is naming. `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run`
and the enrichment path each put a verb where a sub-resource was expected, so
each had become a group holding a lone `create`. Transfer steps keep names that
say what they are, since no single command drives a table import yet.

Three new DELETEs needed gates, which the existing guard test caught. Aborting
an upload and cancelling an import or export stop something in flight rather
than destroying something kept, so those are exempt.

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

* feat(cli): sim tables import

Imports a CSV into a new or existing table, driving the same presigned
handshake `files upload` uses — the two are the same protocol against
different paths, so they now share one implementation.

What made this more than a wrapper is that the import carries decisions the
handshake does not: the source is a local file or one already in the workspace,
the target is a new table or an existing one to append to or replace, and
mapping/createColumns are rejected unless the target is existing. Both choices
are required rather than inferred — defaulting to a new table would turn a
forgotten --to-table into a silent second copy of the data — and the
conditional flags are checked here so the error names the flag instead of
arriving as a complaint about the request body.

The transfer only queues the work; rows are parsed afterwards, so returning at
`complete` would report success for an import that goes on to fail on a bad
row. It polls to a settled status and reports the rows written, with progress
on a terminal only. --no-wait opts out.

The handshake steps are hidden now that a command drives them; `imports get`
and `imports cancel` stay, being useful against an import already running.

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

* feat(cli): default tables import to a new table named after the file

`sim tables import people.csv` now does the obvious thing rather than
demanding a target. Requiring one guarded the wrong direction: a forgotten
flag creating a new table is visible and easily undone, while the outcome
worth protecting — writing into an existing table — is the one that now has
to be asked for by name.

--to-table becomes --table-id, and --mode/--mapping/--create-columns apply
only alongside it. Passing one without it is an error rather than a no-op:
silently ignoring `--mode replace` would let it read as honoured while a new
table was created beside the one it was meant to overwrite. The reverse is
also refused, since --table-id already names the destination.

The derived name is sanitized, because table names are identifiers: the
obvious basename would reject most real files, so `2026-quarterly sales.csv`
imports as `_2026_quarterly_sales` instead of failing. --name overrides it,
and is required for --file-id, where there is no file name to take one from.

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

* 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>

* chore(cli): regenerate for the paginated table list

`listTables` gained `limit` and `cursor`, so the CLI's auto-pager now drives it
like every other paginated list — no CLI change, which is the point of
generating this file. Also picks up `isCurrent` on workflow versions and a new
`voice-output` enum member.

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

* fix(cli): make tables rows create and tables columns run usable

Both were dead on arrival, for opposite reasons.

`createTableRows` takes `z.union([batch, single])`. A union has no flat field
list, so the generator emitted no body slot — and slot absence reads the same
as "this operation has no body", so the command offered nothing and sent
nothing. The generator's own comment claimed the runtime fell back to taking
the body as JSON; nothing did. Unions are now marked, the fields every branch
shares are still emitted (both require `workspaceId`, which comes from the
profile), and `--body <json|@file>` carries the rest, merged over them so the
caller still wins on any key it sets. Dropping that merge was my first attempt
and it failed on the missing workspace.

`runTableColumn` takes `limit: { type, max }`. The pager claimed the *name*
`limit` regardless of type, so it became `--limit <n>` with a default of 100
and sent a number the route rejected on every call, whether or not the flag was
passed. The special case now applies only where `limit` is numeric; elsewhere
it is an ordinary field and gets the JSON flag its type calls for.

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

* 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

* fix(cli): improve command usability and structure

* feat(cli): support knowledge document uploads

* 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

* fix(cli): support unified upload sessions

* feat(api): add file metadata endpoint

* feat(cli): accept simple list inputs

* 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

* feat(cli): add path-based resource directories

* feat(cli): add resource mkdir commands

* fix(cli): accept positional folder paths

* 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

* feat(cli): streamline common resource workflows

* feat(cli): standardize resource command syntax

* fix(billing): unify chat usage source

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

* feat(cli): sync unified chat billing source

* feat(cli): expose log detail trace spans

* 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(cli): improve v2 command workflows

* 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

* feat(cli): sync v2 API and personal login defaults

* fix(cli): use profile workspace for workspace get

* fix(cli): use profile workspace for member listing

* 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

* fix(cli): restore nested knowledge document commands

* feat(cli): add interactive Sim chat

* 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

* feat(cli): refine chat and desktop updates

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

* improvement(tables): centralize v2 application operations

* fix(tables): preserve run validation and signals

* style(desktop): refine macOS installer layout

* fix(api): type timestamp cursor parameters

* feat(cli): add saved chat commands

* chore(desktop): trigger updated prerelease

* feat(cli): publish @simai/cli release channels

* fix(ci): include auth package in app prune

* feat(cli): separate file descriptions from content

* feat(cli): add resumable async Sim Chat

* fix(cli): guard file unsharing

* 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)

* fix(cli): stream file content to stdout by default

* 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

* fix(copilot): recover tool arguments lost when a call is checkpointed mid-generation

Tool arguments reach Sim two ways: whole on a frame's `arguments`, or in pieces
as `args_delta` chunks that accumulate into `streamingArgs`. Only the first
populated `params`, so a call checkpointed before any frame carried `arguments`
executed with `{}` and failed its own schema on every required property. The
file subagent's `workspace_file` calls arrive exactly that way, which left the
agent retrying and then routing around the tool entirely.

- executor: hydrate `params` from the streamed deltas before dispatch, covering
  both normal dispatch and the never-dispatched resume path.
- handlers: record the subagent channel at registration rather than only on a
  finalized frame, so the workspace_file -> edit_content intent handoff can find
  its intent instead of reporting "No workspace_file context found".
- preview adapter: pass the frame's tool call id into file delegation, which
  derives its audit id from it. Without it every preview threw and no file
  content streamed at all.
- run: a checkpointed call with no recorded result now reports a failed result
  instead of throwing, which ended the whole turn and cost the user the entire
  response.

Each fix has a regression test verified to fail without it.

* feat(credentials): add v2 OAuth connection APIs

* fix(credentials): preserve active OAuth connection links

* fix(credentials): bind OAuth links to connection intent

* feat(credentials): complete v2 credential lifecycle

* fix(credentials): make disconnect idempotent

* fix(credentials): stabilize oauth draft retries

* fix(credentials): bind oauth callbacks to drafts

* fix(credentials): fail closed on oauth completion

* fix(credentials): bind shopify completion to oauth state

* revert(chat): remove Sim Chat and mothership changes

Reverts this branch's Sim Chat surface and its mothership-view edits.

d4bdb87d04 ("feat(cli): add interactive Sim chat") could not be reverted: it
is a 175-file commit that also introduced the ExecutionContext refactor and
the v2 workspaces API, which 33 files under lib/copilot now depend on.
Reverting it produced 42 content conflicts, 24 of them in the refactor rather
than in chat code. Its chat contribution is removed forward instead.

Reverted:
  b25e7ba0c3 fix(copilot): recover tool arguments lost when checkpointed
  d4c74648b0 feat(cli): add resumable async Sim Chat
  3f0c1fcce0 feat(cli): add saved chat commands
  de263204fd feat(cli): refine chat and desktop updates -- mothership-view
             only; desktop updater, terminal themes, and bridge are kept

Removed forward: the 17 CLI chat modules, /api/v2/chat, /api/v2/chats, the v2
chat contracts, and lib/copilot/headless. lib/copilot/chat/turn-persistence.ts
is kept because the web chat's post.ts imports it.

Left alone: upstream Copilot application-boundary work (#6450-#6462), the
sim-chat billing source, and the v2 workspaces API.

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

* fix(credentials): align custom oauth reconnects

* fix(credentials): centralize application authorization

* fix(credentials): keep OAuth draft intent immutable

* chore(cli): regenerate v2 API for the staging sweep

Additive only, both picked up automatically by the derived command surface:
  - cancelWorkflowRun `reason` gains already_cancelled / already_completed /
    already_failed (#6702)
  - getFile gains `scope` (active | archived), so `sim files describe` grows a
    --scope flag defaulting to active

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

* fix(credentials): allow renamed reconnect targets

* fix(credentials): close OAuth draft edge cases

* fix(ci): green the repo audits after the staging merge

check:import-specifiers — 79 violations, all packages/sim-cli. That package is
"moduleResolution": "nodenext" while the rest of the repo is "bundler": Node's
ESM resolver takes the specifier literally, so `./ini.js` is required for a
file that is `./ini.ts` on disk. Following the audit's advice to drop the
extension would break the CLI at runtime. The checker now reads each
workspace's tsconfig and, for a NodeNext package, resolves a `.js` specifier
back to its source instead of flagging it — still catching genuinely missing
files.

check:utils — device-flow.ts polled with `new Promise(setTimeout)`. It cannot
import sleep() from @sim/utils: that package is private, so a published
@simai/cli would resolve it in the monorepo and fail from npm. Added a local
helpers.ts and allowlisted it, matching the existing packages/cli entry.

check:tool-registry-boundary — knowledge/page.tsx measured +43 against a +42
allowance. tools/registry.ts is not a gateway on any route, so the boundary the
audit exists to protect is intact; the growth is this branch's own v2 work.
Re-recorded per the script's own instruction.

helm — restored staging's networkpolicy_test.yaml. An earlier integration merge
had dropped its trailing newline, which was the only helm delta against
staging and was tripping the chart-version-bump check.

Not addressed: the Security audit step reports high advisories (brace-expansion,
undici, Socket.IO, OpenTelemetry, fast-uri). Every one is transitive and present
in staging's lockfile too; the step is continue-on-error and did not fail the
job. bun audit reads a live advisory feed, so staging's green run predates them.

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

* fix(cli): read share fields from the v2 share object

The file-share columns pointed at a `sharing` wrapper that v2 does not return.
The share travels under `share` on file metadata (null when unshared) and as the
unwrapped body on the share endpoints, and its flag is `isActive`, not
`enabled`. Every one of those columns was therefore rendering an em-dash on
`files describe`, `files share get`, and `files share set`.

A missing field path renders blank instead of failing, so nothing caught this.
Added a rendering test over both surfaces; it fails if a path stops resolving.
`hasPassword` is surfaced on the two share commands while they are being fixed.

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

* fix(credentials): fail closed without breaking auth

* improvement(cli): bundle and publish as @sim/cli

* fix(credentials): preserve migrated route behavior

* fix(cli): confirm before overwriting login profile

* fix(cli): adapt service-account credential fields

* feat(cli): prompt for secret values

* fix(cli): harden login and credentialless chat

* fix(cli): skip existing package releases

* fix(cli): avoid exposing API key fragments

* fix(cli): cancel failed download streams

* fix(cli): sanitize authentication metadata

* fix(cli): rename authentication output metadata

* fix(cli): publish downloads atomically

* fix(cli): harden download destinations and labels

* fix(cli): support dangling download symlinks

* fix(cli): keep new downloads atomic

* refactor(cli): remove unrelated Copilot changes

* fix(cli): publish as sim package

* fix(cli): use staging npm tag

* fix(cli): align vitest lock resolution

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
2026-08-15 04:51:30 -04:00
Theodore Li b198080356 feat(credential-groups): complete managed account enrollment (#6729)
* fix(credential-groups): show reconnect after authorization

* feat(slack): include managed user auth in custom bots

* feat(credential-groups): add enrollment completion page

* fix(credential-groups): align settings order and block color

* fix(credential-groups): delete credentials on access revoke

* fix(credential-groups): use person icon for enrollments

* fix(credential-groups): keep enrollment submit visible

* fix(credential-groups): make enrollment connections optional

* fix(credential-groups): simplify people actions

* fix(credential-groups): preserve pagination after deletion

* fix(credential-groups): delete removed enrollments

* fix(credential-groups): hydrate canvas labels
2026-08-15 04:37:16 -04:00
Theodore Li 8fb571ac54 feat(credentials): add managed credential groups (#6697)
* feat(credentials): add managed credential groups

* fix(audit): sync credential group mock

* fix(credentials): serialize enrollment revocation

* fix(credentials): isolate managed delegation

* fix(credentials): serialize invitation lifecycle

* fix(credentials): preserve enrollment lifecycle

* refactor(credentials): migrate groups to application boundary

* fix(credentials): serialize enrollment readiness

* fix(credentials): preserve completed reconnect state

* fix(credentials): revalidate policy before grant persistence

* fix(credentials): prioritize expired invitations

* fix(credentials): redirect unavailable oauth starts

* fix(credentials): clarify managed oauth boundaries

* fix(settings): complete feature flag test mocks

* fix(credentials): clarify enrollment actions and entitlement errors

* fix(credentials): preserve entitlement failure reasons

* fix(credentials): refine managed oauth flow

* fix(lint): use optional chain for pagination
2026-08-14 21:46:58 -04:00
Waleed 2223b63851 chore(utils): consolidate record guards and remove dead code (#6715)
* refactor: consolidate local isRecord guards onto shared isRecordLike

Nineteen files had re-declared a local `isRecord` guard rather than using the
shared one from `@sim/utils/object`, drift that reappeared after #5061 first
consolidated them. Two more imported the shared guard under an `isRecordLike as
isRecord` alias.

The copies were not interchangeable. Nine matched `isRecordLike` exactly. The
rest omitted the array exclusion (`typeof x === 'object' && x !== null`, or
`Boolean(x) && typeof x === 'object'`), so arrays passed the guard. Each of
those call sites was reviewed individually: in every case the guard is followed
by string/number field checks that an array fails anyway, so the outcome is
unchanged.

The one exception is `isOptionsTagData`, where `Object.values` on an array of
option items really did make an array-form `<options>` tag render. It now
accepts arrays explicitly rather than by accident.

`executor/handlers/pi/search/extension-source.ts` keeps its own copy: it is
source text written into an E2B/Daytona sandbox at runtime and cannot import.

* refactor: replace inline record guards with isRecordLike

103 inline `typeof x === 'object' && x !== null && !Array.isArray(x)` guards
(and the `x &&` / `Boolean(x)` spellings of the same conjunction) now call the
shared guard. Inside a conjunction that already asserts `typeof x === 'object'`,
`x &&` and `x !== null` are interchangeable, so all three orderings are the same
predicate at runtime.

Only exactly-equivalent conjunctions were converted. Matching required the three
clauses to be one adjacent conjunction over the same operand, so a nearby but
unrelated clause cannot be absorbed — generic-handler.ts, where the array case is
handled inside the block rather than excluded by the guard, is correctly left
alone.

Two sites were reverted after type-check rejected them: instagram/server-utils.ts
and workflows/[id]/log/route.ts both cast straight to a specific interface, which
is legal from `object` but not from `Record<string, unknown>`. Their narrowing is
genuinely not identical, so they keep the inline form rather than acquiring a
double cast.

Left as-is: `packages/ts-sdk` (published with no runtime dependencies) and the two
sandbox sources written into E2B/Daytona as text, which cannot import.

* refactor: consolidate duplicate record coercion helpers onto @sim/utils

A second sweep found 28 more local record helpers hiding under names the
previous `isRecord` grep never matched — `asRecord`, `toRecord`,
`toRecordOrNull`, `asObject`, `isJsonObject`. rabbitmq defined the same
`asRecord` twice within one service; dynatrace had three variants in one file.

Fourteen of them were re-deriving the same two shapes, so those shapes now live
in `@sim/utils/object` beside the guards they wrap:

  toRecord(value)        // isRecordLike(value) ? value : {}
  toRecordOrNull(value)  // isRecordLike(value) ? value : null

Both preserve identity on a hit, so no call site starts copying.

Eighteen local definitions are gone. `tools/instantly/utils.ts` keeps its
exported `asRecord` because its return type is the local `JsonRecord` alias, but
its body now delegates. `app/api/mcp/serve/[serverId]/route.ts` had an
`isJsonObject` with zero call sites — deleted outright.

Six helpers were deliberately left alone because they are NOT equivalent:
`pagerduty`/`zendesk`/`gitlab` do `(value as Record) || {}`, which type-checks
nothing at all, and `copilot/resources/extraction.ts`,
`edit-workflow/validation.ts`, `pi/core/events.ts` omit the array exclusion.
Those sit on webhook ingress and copilot paths where tightening is a behavior
change, not a cleanup; they are audited separately.

Two guards were removed rather than substituted, each proven dominated by an
earlier check: the bedrock streaming `toolUse.input` guard was unreachable
(`parseToolInput` already throws on non-objects before the loop builds
`assembledToolUses`), and four `driver.ts` re-narrows follow an
`if (!isRecordLike(x) || ...) throw` that dominates the later use.

* fix(webhooks): guard non-string GitLab ref, and consolidate the last record helpers

Two audits covered the six helpers held back from the previous commit for not
being equivalent to isRecordLike. Five are now migrated; one is deliberately not.

`gitlab.ts` carried a real crash path, independent of the guard work:

    const ref = (b.ref as string) || ''
    const branch = ref.replace('refs/heads/', '')

The cast is unchecked and `|| ''` only catches falsy values, so a truthy
non-string body field — `{"ref": 12345}` — reaches `.replace` and throws
`TypeError: ref.replace is not a function` inside `formatInput`. That runs in the
background worker after the webhook is already 200-ACKed, and GitLab does not
auto-retry, so the delivery is lost silently. Now checks `typeof`.

`pagerduty`/`zendesk`/`gitlab` each defined `asRecord` as
`(value as Record<string, unknown>) || {}`, which type-checks nothing — a string
or array passed through and was then spread into the workflow trigger payload
(`gitlab.ts:114`) as character- or index-keyed garbage. All three now use the
shared `toRecord`. These sit behind `verifyProviderAuth`, so reaching them
requires the shared secret; this is robustness, not authorization.

`copilot/resources/extraction.ts` and `pi/core/events.ts` were array-permissive
but provably inert — extraction.ts has no key enumeration or spread anywhere,
and events.ts only diverges by returning `null` instead of `{type:'other'}` for
an array, which every consumer already no-ops on. Pinned with a test.

`edit-workflow/validation.ts` is left permissive ON PURPOSE. Its `Object.entries`
walk mirrors the unguarded walk in `operations.ts:86,191`, so an array-shaped
`nestedNodes` from the model is currently visited by both. Tightening only the
validation side would stop `collectHostedApiKeyInput` from stripping
platform-managed API keys while the apply side still creates those child blocks.
Both paths have to change together, with tests, in their own PR.

* refactor: delete 31 unreachable module-local functions

Removes 815 lines of provably dead code: module-local (non-exported)
declarations whose identifier appears exactly once in their own file — the
declaration itself. A non-exported symbol cannot be reached by an import, a
barrel, a dynamic import, or a framework convention, so "unreferenced in its own
file" is a complete proof of deadness rather than a heuristic.

Notable removals include whole abandoned code paths: `findWebhookAndWorkflow`
(78 lines), `calculateBillingProjection` and `initializeUserUsageLimit`
(80 lines), `removeCredits` + `deductFromCredits` (54), `sendBatchSMS`,
`executeToolBatch`, and four unused `async-runs/repository.ts` queries.

Spans come from the TypeScript AST, not a regex. A regex cannot find a
declaration's extent — a first-brace scan cuts inside a return-type annotation
such as `Promise<{ canCreate: boolean }>` and silently corrupts the file. The
AST pass also re-derives deadness from identifier nodes, which caught two
wealthbox helpers that a regex export-check had wrongly reported as local.

Note the runtime TypeScript API here is `@typescript/typescript6`; the bare
`typescript` specifier resolves to the native compiler, which exposes no
`createSourceFile`.

* refactor: delete 434 stranded exported symbols

Removes ~5,930 lines of unreachable code across 166 files: exported symbols that
no other file in the repo mentions and that are unused inside their own file.
Whole abandoned surfaces go with them — unused React Query hooks
(useOrganizations, useOrganizationMembers, useUpgradeSubscription, ...), unused
admin route contracts, dead executor constants and reference builders, and the
landing-page StageWorkflow/LandingPreviewMount components. Three files left with
no remaining code were removed outright.

Candidates came from an AST pass; each was then verified individually against an
UNFILTERED repo-wide search plus the reachability paths a name search misses:
string-keyed tool/block registries, dynamic imports, `export *` barrels, and the
docs generator's source-text parsing of tool files.

29 candidates were verified LIVE and kept. Those exposed a flaw in the candidate
generator: it indexed only .ts/.tsx, while apps/docs/content/**/*.mdx imports
React components directly — ActionImage appears in ~190 MDX pages and ActionVideo
in ~115, and both scanned as dead. `app/global-error.tsx` was likewise kept,
since Next.js reaches it by filename and its default export can never have a name
reference. All 434 deleted names were afterwards cross-checked against every
.mdx/.md/.json/.yaml in the repo: no hits.

Verified with turbo type-check (23 workspaces), the full apps/sim suite
(25,219 tests), all 26 audits, and a production `next build` — the last of these
being what actually exercises route- and component-level reachability.
2026-08-14 17:02:02 -07:00
Waleed d5701d5b2b fix(icons): align table block icon and 12-unit icon stroke with the emcn family (#6708)
The table/table_v2 blocks and the table trigger used a local lucide-shaped
TableIcon (stroke 2.0, full-bleed 24 viewBox, 3x3 grid) while every other
table surface used emcn's Table. Consolidate onto the emcn icon and drop the
local copy.

Nested tool-call rows in Chat applied no color class, so a non-brand block
icon inherited body text instead of --text-icon.

redo/undo/zoom-in/zoom-out draw 0.85 stroke on a 12-unit viewBox, rendering
0.992px at a 14px box against the family's 0.904px. 0.775 restores parity.
2026-08-14 14:49:24 -07:00
Waleed 9664e7ec17 Revert "fix(integrations): read every service mark from one registry (#6682)" (#6707)
This reverts commit b7a5f22abd.
2026-08-14 14:40:58 -07:00
Waleed b7a5f22abd fix(integrations): read every service mark from one registry (#6682)
* fix(integrations): read every service mark from one registry

A service looked like itself on the canvas and like nothing in particular
everywhere it was connected. `OAUTH_PROVIDERS` registers 93 icons and no colour
at all, so the surfaces built on it — the connect dialog above all — drew a flat
grey mark for a block whose config already carries its brand icon and `bgColor`.

Bridges the two: `resolveIntegrationBlockTypeForOAuth` maps any OAuth id (a
service id, a provider id, an extra authorization server) to the catalog block
behind it, so a credential surface holding only an OAuth identity can still
reach the registry. The connect dialog now wears the block's tile, and
`ChipModalHeader` takes a rendered mark so a tile can carry its own chrome
instead of being tinted with the header's grey.

Folds in the copies that had grown around the gap: `IntegrationTile` resolved
its fill from the registry but took its icon from whatever the caller passed —
one tile, two sources — and now defaults to the registry, with an override kept
for the family service-account marks that genuinely are not the block's. The
letter fallback it grew alongside was reading the catalog's `bgColor` while the
tile beside it read the registry's; both are the tile now. Two `getProviderIcon`
implementations for the same job (one tinted, one not) become one `ProviderIcon`,
the connector tile duplicated verbatim across two knowledge-base surfaces becomes
one `ConnectorTile`, and the permission rows that hardcoded `text-white` — which
renders white-on-white on a pale brand tile — go through `BlockTile`.

Public pages keep their generated catalog: importing the registry there would
ship 282 block configs to a marketing page, and `integrations.json` is generated
from the same `bgColor`, so the two cannot drift.

* fix(blocks): drop the dead copies of a block's colour

Sweeping the surfaces above turned up colour data nothing reads and colour data
two surfaces disagreed on.

Dead: `BLOCK_COLORS.DEFAULT/LOOP/PARALLEL` in the tag dropdown (only `VARIABLE`
was ever referenced), `BlockIconInfo.color` on table columns — whose consumer
documents that it deliberately ignores the colour, so the `#2F55FF` behind it
could never render — and the `bgColor` threaded into the add-resource dropdown,
whose row renders a bare tinted icon.

Disagreeing: the Variables tile is `#2F8BFF` in the tag dropdown and `#8B5CF6`
in the preview panel, for the same "V" on the same concept. Both now read
`VARIABLE_TILE_COLOR`, and the preview panel's two hand-rolled squares become
`BlockTile` like every other tile.

Four spellings of the neutral fallback (`#6B7280`, `#6b7280`, `#666666`, and a
`cancelled` status that happened to equal it) now point at
`DEFAULT_BLOCK_TILE_COLOR`.

The terminal and logs resolvers stay. They look like duplicates of `accent.ts`
but carry behaviour it does not have — status fills for synthesized
error/validation/cancelled rows, near-black contrast correction, MCP tool-id
parsing, and a model-provider branch — so folding them in is a behavioural
change, not a deletion.

* chore(copilot): delete the mention machinery nothing calls

The workflow panel's copilot tab renders `MothershipChat`, and that component
brings its own input — so `panel/components/copilot` no longer holds a
component at all, only the hook library the old input used. Five of those hooks
have no caller anywhere: `useMentionData`, `useMentionKeyboard`,
`useCaretViewport`, `useMentionInsertHandlers`, `useTextareaAutoResize`.

They are not all of it. `home/components/user-input` still imports
`useFileAttachments`, `useMentionMenu`, `useMentionTokens`,
`useContextManagement`, and `useIntegrationAutoMention` from this directory,
so it survives as a shared hook library rather than dead weight — which is why
this removes the uncalled five rather than the folder.

What they alone reached goes with them: `getFolderData` / `getFolderLoading` /
`getFolderEnsureLoaded` and the `FOLDER_CONFIGS` table describing every mention
folder, `buildMentionHighlightNodes`, the `MentionFolderNav` type, and the
slash-command tables. Of the 266-line constants file only `SCROLL_TOLERANCE`
had a live reader left.

* fix(integrations): never answer with a sibling's tile

Two integrations can share one OAuth id: Google Slides is authenticated by
Drive's `google-drive` service and Jira Service Management by Jira's `jira`.
Indexing first-write-wins made those ids resolve to whichever sorted first, so
the dialog connecting Slides could wear Drive's brand.

An id claimed by more than one block type now resolves to neither, and the
caller keeps the service-specific mark it already had. A wrong brand is worse
than no tile.
2026-08-14 14:40:20 -07:00
Waleed af076a7a34 fix(logger, blocks): log nested errors and stop spurious model-selection warnings (#6706)
* fix(logger, blocks): log nested errors and stop spurious model-selection warnings

Two production defects found in the prod logs, neither release-related.

logger: mergeArgs copied object arguments verbatim, so an Error held under a
key stayed an Error instance and JSON.stringify rendered it {} — message and
stack are non-enumerable on Error.prototype. 399 call sites use the
logger.x('msg', { error }) shape and every one logged error: {}, which is why
BlockOutputs failures (10k/week) were undiagnosable. The colorized path had
the same hole via formatObject.

blocks: router, evaluator and agent resolved config.tool through
getBaseModelProviders(), which deliberately excludes gateway providers
(OpenRouter, vLLM, LiteLLM, Ollama, ...). A valid openrouter/* model therefore
threw "Invalid model selected", as did a model still holding an unresolved
<variable.x> at serialization time — ~140 warnings/day. The value is cosmetic
(every handler re-derives the provider from the resolved model), so the throw
bought nothing.

- unwrap keyed Errors on both the structured and colorized log paths
- keep `error` a plain message string so log queries can group on it
- resolve serialized provider ids through getProviderFromModel, the same
  resolver the executor uses, via one shared helper for all four call sites
- drop the unreachable `if (!model)` checks behind `params.model || default`

* fix(blocks): move the fallback rationale into TSDoc

The repo forbids non-TSDoc comments; the explanation for why recovery returns
a constant instead of resolving again belongs on the declaration anyway.
2026-08-14 14:14:59 -07:00
Waleed b9a70e43f9 fix(execution): give a cancelled async run its terminal metadata (#6693)
* fix(execution): give a cancelled async run its terminal metadata

The staging integration suite has been failing
integ-cancel-async-api-key/async-execution-becomes-cancelled: the run
reports status cancelled with a null endedAt and a null duration, so the
assertion fails and the dependent worker-stop check never runs.

The run resource falls back to the queue job whenever no execution-log
row exists yet, and a cancel that lands before the worker has written
that row leaves exactly that state. Trigger.dev marks a run canceled the
moment it accepts the cancellation but only stamps its finish time when
the worker drains, so for the seconds in between the job is terminal with
no timestamp, and the projection faithfully reports a terminal status
with nothing to date it. The run writes a correct log row when it finally
drains, which is why the endpoint heals itself and the alarm fires
intermittently rather than always.

The backend was discarding the one timestamp that is always present:
the retrieve response carries a required updatedAt beside the optional
finishedAt. A finish time still wins wherever it exists, so nothing that
already reports correctly changes, and the fallback is taken only once
the mapped status is terminal — an active run's updatedAt marks progress,
and reading it as an end would retire a run that is still going. It
records the server's last transition for the run rather than the reader's
clock, so it stays put across polls instead of growing.

Also carries the duration on a fifth cancellation write, in the internal
cancel route, that the earlier pass missed because its sweep covered lib
and not app.

* refactor(execution): derive a cancelled run's terminal fields in one place

The five cancellation paths each hand-assembled the same four-key payload for
the workflow-execution log, and one of them had already drifted: the direct
cancel never cleared `execution_deadline_at`, leaving a cancelled row carrying
the deadline of an attempt that had stopped running. Extract the payload so the
key set cannot vary between them, and leave the paths themselves alone — they
differ in handle, claim predicate, whether they read the row back, and what they
do when the claim is lost, so they stay separate statements.

Bind the end instant through the `started_at` column encoder rather than a
pre-stringified ISO literal. `check:sql-date-binding` exists to enforce exactly
that binding; the literal passed only because it was already a string.

Collapse the duration expression to one `COALESCE` over a valueless-`ELSE`
`CASE`, which builds the elapsed fragment once instead of in both branches, and
reuse it for the stale-execution sweeper, which carried its own copy along with
a second int4 ceiling constant.

Document the invariants the fix depends on where a reader meets them: that
`total_duration_ms` means wall clock for a terminal row and active time for a
paused one, and that a terminal job must carry its transition instant.
2026-08-13 20:07:37 -07:00
Waleed 9aa36a424f fix(icons): match folder icon optical size to sibling resource icons (#6692)
The folder icon read visibly smaller than the table/database icons beside
it in resource rows despite an identical size-[14px] class, because the
class sets the SVG box and not the artwork inside it. The folder outline
only spanned 15 units of the shared -1 -2 24 24 box against 17.5-18 for
its siblings.

Scale the outline to a 17-unit extent on the same .25 grid and quarter
circle corner radii, keeping the body path byte-identical across Folder,
FolderOpen and FolderCode so the sidebar expand/collapse toggle does not
shift. FolderCode's brackets move with the body they are centred on.
2026-08-13 19:47:17 -07:00
Vikhyath Mondreti fa394e5e07 fix(execution): resolve secrets against the acting principal, not the workflow owner (#6690)
* fix(execution): resolve secrets against the acting principal, not the workflow owner

* fix(execution): resolve anonymous public-API runs as the workspace billing account

* fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets
2026-08-13 18:50:15 -07:00
Waleed b9b9c9c9a9 fix(canvas): stop phantom ports and a latched-open action bar (#6688)
* fix(canvas): stop phantom ports and a latched-open action bar

Ports surface on hover from a swell painted on the card border, and that
swell was raised with no regard for whether a handle exists behind it. A
Response block mounts no source handle, so hovering its edge raised a
knob no edge could ever leave from. A trigger mounts no target handle,
yet still swelled under a connection dragged from another card, offering
a drop it cannot accept. Gate each direction on the handle that backs it,
and limit a trigger's own swell to its source edge the way the subflow
start node already does.

The action bar latched open for the same interaction. Leaving the card
arms a retract and installs a pointermove listener to track the pointer
across the gap up to the bar; re-entering the bar's band called
openHover(), which cancelled the retract AND removed that listener. No
further pointerleave can arrive once the pointer is off the node, so
nothing was left to close the bar. Keep the listener installed and re-arm
the retract when the pointer moves back out.

Also clear the magnetized port when the pointer leaves the tracking band
onto the action bar: only the in-band path recomputed it, so the last
knob stayed pinned at hover amplitude with the pointer nowhere near it.

* fix(canvas): scope the receive gate, cover both swell directions

Gating the foreign-drag listener also ran the shared pointer-tracking
reset, which belongs to the card's own hover. A trigger sets
canReceiveConnection false while canStartConnection stays true, so the
reset undid the layout effect's :hover bootstrap and left a card that
mounted under the pointer with no source swell until the pointer left
and came back. Skip the listener instead; the effect's own cleanup
already covers a true-to-false flip.

Drop the trigger-only cursorSwellSides restriction. A swell on a
trigger's input edge resolves to a source handle, so an edge genuinely
can be made there — it was a behavior change beyond the bug, not a
phantom.

Cover both directions of the swell gate, and use the shared sleep helper
in the action-bar test. Hoist the constant connection sides out of the
render body so they stop riding the borderPorts dep array.
2026-08-13 17:50:55 -07:00
Waleed 264d4f3469 feat(canvas): add a setting to turn off auto-focus when clicking blocks (#6685)
* feat(canvas): add a setting to turn off auto-focus when clicking blocks

Clicking a block animates the camera to center it, which zooms in far
enough that you lose sight of the rest of the workflow. Add an
"Auto-focus on click" preference (on by default, so existing behavior is
unchanged) that keeps the camera still on click.

Also re-record the auto-connect and canvas-error-notification tooltip
previews and re-encode all three at a smaller size.

* fix(canvas): keep click-marked framing when auto-focus is off, recut tooltips

Gating the whole click branch on the setting also skipped the
userFocusedWorkflowIdRef write, which is what stops <ReactFlow onInit>
from running fitView over the user's framing. That would have blown away
the framing of exactly the users who turned auto-focus off to keep it.
Mark the workflow as user-framed on any plain node click and gate only
the camera move.

Crop the auto-focus preview to the recording's viewport center so the
blocks are legible at the 240px width Tooltip.Preview renders at, and
trim the 2.45s of empty lead off the error-notification preview.

* docs(canvas): correct useAutoFocusOnClick scope to clicks only

The TSDoc claimed the preference also gated arrow-key navigation, which
calls focusBlockInView without consulting it. State the click-only scope
and why arrow-key navigation and block creation are excluded.
2026-08-13 17:05:54 -07:00
Waleed 85c8451a86 fix(blocks): give the detail headers the same tile as everything else (#6672)
The tile consolidation left the large detail headers on their own treatment,
so a block wore one colour on the canvas and another in the header naming it:
the preview panel behind the deploy modal and the logs trace detail both
painted straight from the catalog `bgColor`, showing Start's catalog blue over
a neutral card, and hardcoded `#2FB3FF`/`#FEE12B` for the two subflows.

Adds the 18px header slot to `BlockTile` and points all three headers at it —
preview panel, trace detail, and the editor header, which had been carrying its
own inline copy of the accent rule. `WorkflowTypeIcon` takes an icon class so
the accent chip can draw the larger glyph the header uses.
2026-08-13 12:34:19 -07:00
Waleed 6de8ba2504 fix(v2): close the correctness gaps an end-to-end audit found (#6655)
* fix(v2): stop a third-party tool description from 500ing MCP discovery

`v2McpToolInputSchema` declared `description: z.string().optional()` inside a
`.catchall(z.unknown())` object, and a declared key beats the catchall. The MCP
SDK's own `ToolSchema.inputSchema` does not declare `description` at all, so any
value — including the JSON `null` a Python server emits for an absent one —
passes its validation and reaches Sim unchecked. The builder's outbound `.parse()`
then threw, and the discovery error policy correctly declines to classify a
Sim-side schema defect, so the endpoint that completes MCP onboarding answered a
bare 500. The key is dropped and left to the catchall; `type`, `properties`, and
`required` stay pinned because the SDK enforces those at least as tightly.

Also in the v2 resources family:

- The single-resource query schemas for MCP servers, skills, custom tools, and
  secrets are now `.strict()`, matching every list in the same family. A mistyped
  flag was silently ignored behind a 200.
- `openapi/resources.ts` re-derived `RESOURCE_ERRORS` and
  `RESOURCE_CONFLICT_ERRORS` inline in 21 of 22 operations. They now import the
  shared constants; the generated spec is unchanged, which is the point.
- The internal MCP refresh route stamped `updatedAt` alongside `lastToolsRefresh`.
  `updatedAt` means "configuration last changed" and is a public keyset sort, so
  a refresh moved rows out from under an in-flight page. `updateServerStatus`
  already held that invariant; the route now matches it.
- The discovery cooldown is a typed `McpServerCooldownError` rather than a
  substring search for `cooldown`. `McpConnectionError` interpolates the server's
  display name into its message, so a server named after the word was reported as
  a transient cooldown when its connection had genuinely failed.

* fix(v2): close correctness gaps in the workflows deployment surface

Deploy and rollback bodies were plain objects, so a misspelled key was
stripped rather than rejected. On rollback that is silent misbehavior:
an omitted `version` legitimately means "reactivate the preceding
version", so `{"versoin": 5}` rolled back somewhere else and answered
200. Both v2 bodies, the run-read query, and the versions cursor are now
strict.

Deployment versions are an `integer` column, but the path param, the
versions cursor, and the v1 body each bounded it differently or not at
all — an out-of-range value overflowed the comparison into an
unclassifiable 500. One exported bound now covers all three.

Resume admission raised bare `Error`s for a stale contextId or an
already-resumed run, which the resume surfaces could not classify and
reported as 500. They now use the sibling `ResumeAdmissionError` already
in that file, carrying 404/409/400 and whether an automatic retry can
clear the refusal.

Docs corrections: rollback publishes the 409 its webhook-path conflict
already produces; deploy/undeploy/rollback reject a workspace key with
403, not the concealed 404 they documented; the workflows OpenAPI module
imports the shared error sets instead of re-deriving them; import and
the folder ops explain their folder-tree 413. The export route is marked
`headSafe: false` so a HEAD probe stops filing a WORKFLOW_EXPORTED audit
event for an export that never happened. `runId` is one bounded schema
across the run and log resources.

* fix(v2): conceal knowledge upload existence, tighten knowledge/files bounds

Security: the four knowledge document-upload routes rendered a bare upload
error policy with no resource concealment, while every sibling knowledge route
uses one. Because the use case resolves the knowledge-base context before
workspace authorization, the unconcealed 403 told any valid API-key holder that
a knowledge base exists in a workspace it cannot reach — the exact signal
GET /api/v2/knowledge/{id} withholds by answering 404 either way. All four now
use the composed concealing policy, which also renders the 415/402/413 the
route-local renderer already handled; that duplicate renderer is deleted.

Contracts:
- POST /knowledge/search is strict. It was the only non-strict v2 request body,
  so a mis-cased rerankerEnabled or topK returned 200 with the key stripped,
  changing what the caller was billed and silently disabling reranking.
- The document list takes limit, cursor, and search from the shared v2 schemas.
  search was an unbounded, empty-accepting v1 string, so ?search= answered 200
  with a full page here and 400 on GET /knowledge, and the term reached an
  unindexed filename LIKE scan with no ceiling.
- The 16 non-strict single-field workspace query slices across both families are
  strict, matching GET /knowledge/{id}/tags.
- GET /audit-logs takes workspaceIdSchema instead of a bare string (?workspaceId=
  was forwarded as a filter and returned zero rows) and the shared run-window
  bounds for startDate/endDate.

Documentation:
- listAuditLogs drops the 404 it has no code path to emit.
- upsertFileShare describes its workspace-key refusal as the 403 it renders;
  the operation denies the key by principal kind, which the concealment policy
  does not rewrite.
- The 12 body-reading knowledge and files operations publish the 413 their
  pre-validation body read raises, and the file list publishes the folder-tree
  413 its now-capped path index raises.

Correctness: queryWorkspaceFilePage loads its folder path index under
MAX_FOLDERS_PER_WORKSPACE like the workflow, table, and knowledge lists. An
uncapped index does not fail on truncation, so a real folder outside the read
rows resolved to undefined and answered "Folder not found".

* fix(v2): publish the reachable 413 on body-carrying resources ops

`parseRequest` buffers a JSON body through `parseJsonBody` under
`DEFAULT_MAX_JSON_BODY_BYTES` before any schema runs, and the v2 builders supply
`V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so every operation whose contract
declares a body already answers 413 above the cap. The resources family
published it on none of them. A status a caller cannot see in the spec is a
status they will not handle.

Adds `RESOURCE_BODY_ERRORS` and `RESOURCE_CONFLICT_BODY_ERRORS` to the shared
sets and applies them to the seven affected operations: createMcpServer,
updateMcpServer, createSkill, updateSkill, createCustomTool, updateCustomTool,
and setSecret. All seven are `defineV2JsonRoute` handlers on non-GET methods
with no `parseOptions` override, so the 413 is genuinely reachable on each. The
new sets are opt-in rather than folded into the base sets precisely because
reachability is not automatic — an operation with no body, or one whose payload
reaches it through an uncapped path, would be publishing a response that can
never arrive.

A sweep test pins the invariant across the resources, billing, and logs
documents. It is one-directional by construction: several bodyless operations
publish 413 for their own folder-tree and render ceilings, so the converse would
flag correct documentation.

Also completes the shared-constant consolidation started in cd3efefab9:
`openapi/billing.ts` and `openapi/logs.ts` each re-derived `RESOURCE_ERRORS`
inline in two operations. Both now import it, and both regenerate byte-identical.

* fix(v2): head-safe binary downloads, coded 403s, and truthful surface docs

Adds `headSafe` to `defineV2BinaryRoute`, mirroring the JSON builder: a HEAD
on a route that declares itself unsafe is authenticated and rate-limited, then
answered bodiless before parsing or executing. `GET /api/v2/files/{fileId}` is
the one binary v2 route and it records a `FILE_DOWNLOADED` audit event, so a
HEAD probe used to fabricate a download that never happened.

Names the cause of five refusals that reached the wire as codeless 403s
(billing principal-kind, personal-keys-disabled and role, secret admin and
write, the workspace table quota, and public sharing), adding three members to
the closed `FORBIDDEN_DETAIL_CODES` set. The billing cross-tenant refusal is
concealed as a 404 instead of coded, and the credential-list and knowledge
file-ownership refusals stay codeless deliberately, documented at the site.

Makes `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` reachable: an operation that
denies workspace keys also omits them from `principalKinds`, so the kind guard
always fired first and callers got `PRINCIPAL_KIND_NOT_PERMITTED` instead of
the published code.

Drops the unused 410 response, shares one `order` schema between the two run
reads so both specs spell the enum the same way, and corrects the false
statements about 403 codes, 413 causes, cursor schemes, and full-set lists in
the conventions skill and the contract TSDoc.

* fix(tables): close the v2 tables correctness and contract gaps

- updateColumnOptions was the only column mutator with no lock assert: an
  options-only PATCH applied on a schema-locked table, and an option REMOVAL
  cleared cells on a delete-locked one. Assert schema always, escalate to the
  destructive gate only when options are dropped.
- GET/DELETE /tables/imports/{id} 500'd on a first-party import job (null
  payload) or an unrepresentable status. Both now read as absent, so the answer
  is the 404 it always was.
- Offset cursors stamped the sort but not the filters, so a page-2 cursor
  replayed under a different predicate paged an unrelated sequence silently.
  Offsets now carry a filter fingerprint and refuse a mismatch.
- Publish 413 on every tables operation that accepts a request body: the v2
  JSON builder reads the body under a byte ceiling before validation, so the
  status is reachable on all of them. Derived at document assembly so a new
  route cannot regress it.
- Enforce MAX_VIEWS_PER_TABLE on view create, making the list contract's
  "small bounded set" claim true.
- Accept the upload control token on the import read, so an upload-backed
  import is readable during the phase its own 201 reported; drop the `queued`
  status the reads can never return.
- Declare the Find search-term cap, the Find match cap, and the run row-id
  ceiling the domain already enforces.
- Uniform 201 on the row and column creates.

* docs(v2): record why the two migrate-on-read GETs stay head-safe

An enumeration of side-effecting v2 GETs flagged these two for issuing a
workflow_blocks update. The write is convergent and would be issued by the
next ordinary read, and headSafe: false answers 200 unconditionally, so
declaring it would cost HEAD its existence check to prevent nothing.

* fix(api): classify the caller input that reached the driver unvalidated

Four families of caller-reachable 500s share one shape: a value the
contract admits, the application forwards, and the database rejects.
An unclassified driver throw renders as INTERNAL_ERROR, so a bad
request came back as a server fault — on pure reads as well as writes.

NUL bytes are rejected at the contract boundary, in parseRequest, not
per field. A shared string primitive only protects the fields somebody
remembers to build on it, and it cannot protect the values that have no
string schema at all: a table cell and a predicate value are z.unknown()
because their type belongs to the column, not the wire, and those are
exactly the values found reaching the driver. One scan over the already
validated params/query/body covers every field including the ones nobody
has enumerated. Only U+0000 is rejected; every other control character
is ordinary content that Postgres stores verbatim.

Date bounds on a filter are now parsed, not merely type-checked, with
the same normalizer the date column type uses to store cells — so the
filter grammar and the storage grammar agree, and gt/gte/lt/lte on both
JSONB date columns and the createdAt/updatedAt system columns answer an
unparseable bound with 400 instead of an invalid-input-syntax 500.

An afterRowId/beforeRowId anchor that does not exist is a classified
not-found rather than a bare Error, and a zero-byte knowledge document
is refused at admission: every parser rejects an empty buffer outright,
so the upload could only ever consume storage and quota on its way to
processingStatus failed.

* fix(v2): stop six endpoints from returning a confident untruth

Six defects that share a shape: a 200 that misrepresents what happened,
which is the one class a caller cannot detect from the response.

Knowledge search silently degraded. Reranking is implemented and does
run, but a deployment with no Cohere credential, a provider error, or a
timeout was swallowed into a warning log and answered 200 with plain
vector ordering and no `rerankerScore` anywhere — indistinguishable from
a reranker that ran and agreed with the vector order. The fallback stays
(an outage should not take search down) and is now reported:
`rerankerStatus` is required on every search response. v2 also omitted
the `rerankerModel` default the internal contract supplies, so
`rerankerEnabled: true` alone failed the use case's model guard and
returned unreranked results after paying for the widened candidate
retrieval; it now defaults like its sibling.

`GET /billing/logs` accepted `startDate`/`endDate` with any relative
period and dropped them, answering over the default 30-day window — a
caller reconciling charges got real rows that were not the rows it asked
for. Both bounds are now rejected outside `period=custom`, take the same
strict UTC form as `GET /logs` via the shared `v2RunWindowBoundSchema`,
and reject an inverted window instead of returning an empty page.

MCP registration stamped `connectionStatus: 'connected'` and
`lastConnected: now` at insert without contacting the endpoint, and did
the same on any non-OAuth re-registration while leaving `lastError`
stale. `tool-validation` gates tool availability on that column, so an
unreachable server read as healthy. Both paths now leave the columns at
their honest defaults for `mcpService.updateServerStatus` to move after
a real discovery; the client-side optimistic copy matches.

`skills.create` allowed a workspace API key while every other skill
write denies one, so a key could only ever accumulate skills it could
never remove — and the row it left was attributed to the workspace's
billing owner, minting an editor grant for a human who did not act.
Creation now denies a workspace key, making the lifecycle symmetric on
the per-skill editor model that authorizes the rest of it.

`runCount` counts successful non-paused runs and is never decremented by
retention, so it disagrees with the runs list in both directions; the
description now says so rather than claiming "total recorded runs". Run
retention itself was undocumented — free-plan runs are hard-deleted after
30 days, which is why a workflow reports runs beside an empty list — and
is now stated on both reads over the execution-log table.

* fix(tables): refuse the writes v2 was silently discarding

- Uncoercible cell values were stored as null under a 200 on any optional
  column: "abc"/true/[1] into number, "yes"/1/{} into boolean, "not-a-date"
  into date, an undeclared option into select, an object into string. The
  read side already 400s on the same mismatch in a predicate, so the two
  halves of the API disagreed about the same value. `coerceRowValues` /
  `coerceRowToSchema` now take an explicit policy and default to `reject`;
  `null` is passed only where a machine produced the value for a cell no
  caller typed — a computed (workflow/enrichment) write and a CSV import,
  neither of which has anyone to answer with a 400.
- A multi-select coerced `["green"]` to `[]` — the drop was inside the
  registry, so no policy above it could see it. It now refuses any part that
  matches no option, which is what the single branch and the bulk retype gate
  already did.
- A bare number in a date cell was read as epoch milliseconds, so the far more
  common Unix-seconds shape stored a timestamp 50 years early. The unit is not
  recoverable from the value and both readings are in range, so a bare number
  is refused in both directions and the retype gate no longer needs an
  override to be stricter than the write path.
- Unknown column names were dropped by the name→id remap: an insert of
  {"nosuchcol":"x"} created an empty row under a 201, and a patch of
  {"zzz":"x"} answered updatedCount:0, indistinguishable from an empty match.
  The v2 row boundary now names them and refuses.
- The table ceiling was enforced only inside createTable, which for an
  upload-backed import does not run until the CSV has crossed the wire: a full
  workspace got a 201 and a presigned PUT for up to 5 GiB, then a 403 at
  complete with an orphaned object left behind. The advisory check now runs
  when the session is created; the authoritative one stays in the transaction
  because the quota can move mid-upload.
- Cap workflow groups per table. GET /tables/{id}/groups is published as a
  full-set list, and the group count had no bound of its own — the indirect
  one does not survive an update path that adds no columns.
- Present a group's outputs/dependencies/inputMappings by column NAME. They
  are created by name, stored by id, and were read back as ids on a surface
  that is otherwise name-keyed, so a group could not be round-tripped.
- Publish the predicate grammar: the operator set, the per-type restrictions,
  and that `*` — not `%` — is the wildcard. It was true only in the SQL
  builder's own comments, so the natural guess matched zero rows under a 200.
- Stop advertising a `workflowId` default of "" on group create; a manual
  group that omits it has always been refused.

* fix(v2): bind every paged list's cursor to its filters, not just its sort

A v2 cursor names a position in one sequence, and a list decides that
sequence from its sort AND its filters. Only the sort was stamped on the
shared keyset codec, so a cursor from an unfiltered walk was accepted
under a changed `search`, `scope`, `deployedOnly`, or folder and answered
from a sequence the caller never asked for. The two offset lists already
stamped both; nothing else did.

The failure differs by scheme but is silent in both. An offset lands at
an unrelated ordinal. A keyset stays internally coherent — correctly
ordered, duplicate-free — and drops every match sorting before its
position, which a caller holding an opaque token reads as "almost
nothing matched".

One mechanism, shared with the table-row codec: canonical JSON plus a
SHA-256 fingerprint (`lib/api/cursor-binding.ts`), stamped by
`cursorFilterScope` alongside `cursorSortKey`. The two stamps stay
separate so the 400 names which half changed. `limit` is never bound —
it selects how much of the sequence to return, not what it is.

The three lists whose token is minted by a domain codec (`/logs`,
`/audit-logs`, `/billing/logs`) get the same binding by wrapping that
token in a query-stamped envelope; the domain cursor is untouched.

`present` now also receives the parsed request, so a presenter reads the
filters it stamps straight from the query instead of the use case
carrying an HTTP cursor concern back out — the `cursorSort`/`cursorScope`
round-trips through three application services are removed.

`list-pagination.test.ts` now declares each paged list's binding and
checks it against the contract in both directions, so a new list, or a
new filter on an existing one, fails until its binding is decided.

* fix(v2): authorize HEAD probes and declare every v2 query schema

Two ways the v2 surface answered a request it had not checked.

`headSafe: false` exists so a HEAD cannot fire the side effect its GET
performs — an outbound MCP discovery, a FILE_DOWNLOADED audit event, a
WORKFLOW_EXPORTED audit event. The short-circuit sat between admission
and parsing, so it returned a bodiless 200 before resource authorization
ran at all: authorization lives inside the use case, and the use case was
exactly what the short-circuit skipped. Any valid API key drew 200 for a
denied principal kind, a nonexistent id, another tenant's workspace, and
a request missing a required param, while the GET beside it answered 403
or 404. That is an existence oracle over MCP server ids, file ids, and
workflow ids.

`OperationUseCase` gains an optional `authorize()` that runs the phase
before the business transaction — allowed-principal check, canonical
load, asserted-scope comparison, current access check — and stops.
`defineAuthorizedWorkspaceUseCase` shares one implementation between it
and `execute`, so the two cannot answer differently. A HEAD on a
not-head-safe route is now admitted, parsed, and authorized like the GET,
rendering refusals through the route's own error policy, then answered
bodiless. The builders refuse at definition time to pair
`headSafe: false` with a use case that has no `authorize`, so the next
such route is a boot failure rather than a silent 200.

Separately, `parseRequest` validates the query slice only when the
contract declares one, so an omitted `query` means "never look at the
query string" rather than "takes no query params". 69 v2 contracts
omitted it and accepted anything: `?bogus=1` was a 200 on
`GET /workflows/{id}` and a 400 on every list. They now declare
`noInputSchema`, and 8 more contracts that declared a query without
`.strict()` are tightened. A sweep over the contracts tree is the
enforcement — a compile-time gate on `defineRouteContract` was tried and
reverted because the required intersection collapses inference of the
sibling generics.

Four route tests appended `?workspaceId=` to a PATCH/PUT that reads it
from the body; that copy was being silently dropped and is now a 400.

The generated specs are byte-identical: the OpenAPI generator learns that
a slice declaring no keys publishes no parameters.

* test(tables): pin the multiselect paste on the refusal, not the silent empty

cleanCellValue runs the same registry coercion the server does, so tightening
multiselect on the server changed this helper too. The case asserting an empty
array was pinning the silent-drop the tightening removed.

* docs(v2): make the API-key security description render as plain prose

The description was already published on every spec but did not appear in the
rendered Authorization block. It carried a raw > and backticks, which the
markdown pass in the docs renderer does not survive; the operation description
on the same page renders fine. Reworded to plain prose with the same substance.

* fix(v2): bind the query cursor to its filter on every shape

Two agents each fixed half of this: the shared list codecs gained filter
binding, and the table codec gained a fingerprint, but the pure-keyset shape
stamped it on neither encode nor decode. A keyset position is absolute in
(order_key, id), which is why it was left unbound — but absolute ordering is
not completeness. Replaying the cursor under a wider filter silently omits
every match sorting before it, so paging predicate A then B returned rows 7,9
where the full B sequence is 1,3,5,7,9.

Also answers a lost create race with the conflict it already documents, and
shortens three descriptions that dwarfed their siblings — the forbidden-code
catalogue now lives on the error envelope's details field, published once per
document instead of on all 135 operations.

* fix(tables): make a saved view's column references survive the write

A view config stores every column reference as a stable column id, but two
things wrote it in different vocabularies and nothing translated between them.

`config.sort` was pruned on read against the live column ID set while the
contract defines `sort[].field` as a column NAME, so every name-keyed sort —
the only kind the v2 surface can express — pruned to nothing and the view came
back with `sort: null`, on both create and PATCH, with no warning. The same
prune dropped a sort on `createdAt`/`updatedAt`/`id`, which are sortable row
columns that simply are not in `schema.columns`. `config.filter` had the
opposite failure: it was stored verbatim, so a predicate naming a column that
does not exist saved happily and then 400'd on every `/query`, `/query/count`,
and `/rows/find` that tried to use it.

The write path now canonicalizes a config before storing it: every column
reference (layout keys, `sort[].field`, each `filter` leaf `field`) is resolved
to the column's stable id, and `filter`/`sort` are validated against the live
schema so a reference that can never resolve is refused instead of saved. The
v2 read presents the config back keyed by column name, matching
`presentV2WorkflowGroup` and every other v2 row/data surface — a caller never
sees a `col_…` id, and what it wrote is what it reads. Resolution is a lookup
with pass-through, so the id-keyed first-party UI is unaffected.

Column LAYOUT stays unvalidated on write and pruned on read: it auto-saves as
the user drags, so racing a column delete must self-heal, not fail the drag.
The read path still never prunes a predicate, for the reason already documented
there — a pruned condition silently widens the view's row set.

* fix(storage): validate at the decode and multipart boundaries, bound derived keys

Four caller-reachable 500s shared one shape: input passed boundary
validation, then failed in the storage/key layer. Each is fixed at the
boundary that owns the transformation, not at the call sites.

Percent-encoded NUL in a canonical folder path. `parseRequest`'s NUL scan
sees `%00` as three ordinary characters; the NUL only exists after
`parseFolderPath` decodes it. Reads survived as 404s, writers carried the
decoded name into an INSERT and the driver threw. The rejection now lives
in `encodeFolderPathSegment`, the single chokepoint both building and
parsing funnel through, so it covers every escape a caller can spell.

NUL in a multipart field. A multipart route declares no body contract, so
its fields never reach contract validation at all — the knowledge-document
key was sanitized while `original_name` was not, and the object landed in
storage before the insert threw. `readFormDataWithLimit` is the shared
multipart reader every such route already funnels through, so the scan
goes there and runs before a caller holds a File to upload, which removes
the orphan rather than cleaning it up.

Storage-key overflow at 225 characters. Every generator embedded the file
name in a path component it also prefixed with a timestamp and a
uniquifier, so the effective limit was 255 minus that prefix while the
contract advertised 255 — a 225-character name produced a 256-byte
component and ENAMETOOLONG from local storage, and the upload session
handed out a transfer URL that could never succeed.
`buildStorageKeySegment` reserves the prefix out of the component's budget,
making the key independent of name length and the declared limit honest.

The NUL predicate is now shared from `@sim/utils/string` by all three
boundaries instead of being restated at each.

* docs(v2): make the published spec describe the API it has

Three descriptions asserted behavior the code no longer has, and three rules
the code enforces were published as unconstrained strings.

`downloadFile` and `listMcpServerTools` still told callers a `HEAD` on a
not-head-safe route "is answered with an empty 200 ... reports only that the
endpoint exists and the caller is authorized". That was true of the old
short-circuit, which sat between admission and parsing and therefore returned
200 for an id the same caller's `GET` refused. The builders now authorize a
HEAD exactly as the GET, so the spec said the opposite of a security fix. One
`HEAD_MIRRORS_GET` constant replaces both sentences and is added to
`exportWorkflow`, whose `headSafe: false` was never documented at all. A test
walks the `app/api/v2` tree for the declaration and fails on any operation that
carries it without the sentence, or that resurrects the old claim.

`createMcpServer` promised that re-registering an existing URL "rewrites the
configuration and returns the server to the same unverified state"; it is a
409 pointing at PATCH. `authType` claimed Sim "detects it from the server when
omitted" — registration deliberately never contacts the server, and the column
defaults to `headers`. The default stays: `headers` and `none` are
behaviourally identical (only `oauth` branches), so changing it is a migration
with no caller-visible payoff, while the sentence was simply false.

`predicate` was the API's most consequential gap: a `pipe` over `z.unknown()`
documents from its input, so the leaf keys `field`/`op`/`value` appeared
nowhere in the contract and `{column, operator, value}` was a 400 a caller
could not correct against. Both predicate schemas now publish a real recursive
JSON Schema through `.meta()`, self-referencing so the recursion resolves from
one `$defs` entry, with every bound read from the constant that enforces it.

Also published: the canonical folder-path rule and its 4096-byte cap on the
four path components (the `superRefine` contributed nothing to JSON Schema);
the closed 12-value `recursive` vocabulary on a destructive delete; and the
null-matching behaviour of the negating operators. The clamping `limit` branch
drops `minimum`/`maximum`, which in JSON Schema mean "rejected outside" and
made SDKs refuse locally what the server clamps.

`deleteFile` stops publishing a 409 nothing in its path can raise. `restoreFile`
and `abortFileUpload` keep theirs — the report called them unemittable, but
restore raises `FileConflictError` after exhausting its rename retries and
abort refuses a completed session.

Description tail, across the seven specs: p99 733 to 465, max operation 1643 to
1114, over 700 chars 31 to 13, over 400 70 to 61. Constraints moved from
operation prose onto the fields they constrain rather than being deleted.

* fix(v2): make upload completion, blank query values, search, and folder filters answer correctly

Four defects on the v2 surface, each reproduced before it was fixed.

Upload completion dispatched document indexing from inside the completion
transaction, so a queue or processing failure returned 500 after the object was
stored, the document row was created, and the session was marked completed —
and the only recovery, replaying the request, answered 200. The dispatch is now
a follow-on step that runs after the session is durably completed and is logged
rather than raised. Its outcome stays visible on the document itself (`failed`
with an error, or `pending` when it was never picked up), and the recovery path
re-queues a `pending` registration instead of keying off a message left on the
session.

A query parameter sent with no value was read as `0`, `false`, or the parameter
default: `?limit=` became `LIMIT 1` on the three lists that clamp, and
`?minCost=` on `/logs` became a live `cost >= 0` filter. `search` and `cursor`
already rejected a blank and documented "omit the parameter instead"; that rule
now applies to every v2 parameter, enforced on the raw query before coercion so
a parameter added later inherits it.

The document list matched `_` and `%` in `search` as live LIKE wildcards while
every sibling list escaped them through `searchFilter`, so the documented
substring match returned everything for `a_itest`. It now uses the same helper.

A `folderPath`/`folderPaths` naming no folder answered 404 on `/logs`, `/files`,
`/workflows`, `/tables`, and `/knowledge`, while every other filter answers an
empty page and the sibling folder lists already do. All five now return an empty
page. Mutations keep their 404.

* chore(v2): regenerate the specs from the merged sources

The four spec conflicts in the wave-3 merge were resolved by taking one side,
which left them describing neither branch. Regenerated so the published
documents match the contracts they are built from.

* docs(v2): give a built-in skill's id its real form

The contract said a built-in skill uses its name as the id. The ids are
`builtin-` plus the name, so a client following the description asks for
/skills/research and gets a 404 where the spec promises the skill.

* fix(uploads): keep local upload artifacts inside NAME_MAX

`POST /api/v2/files/uploads` accepted a name of up to 255 characters,
returned 201, and handed back a transfer URL that could never succeed:
the PUT against it 500'd and `complete` then reported the object missing.

The local provider named its staged object after the destination —
`{key}.{uploadId}-{uuid}.tmp` plus a `.upload-metadata.json` sidecar — so
the staged component was the key's length plus ~99 bytes of fixed
overhead. Past roughly 125 characters of name that crossed POSIX
`NAME_MAX`, and `ENAMETOOLONG` is not a `LocalUploadBodyError`, so it
escaped as a 500. Multipart `complete` built the same name and failed the
same way. Only local storage is affected; S3, Azure, and GCS have no
per-component limit.

`buildStorageKeySegment` already budgeted the key to 255, one layer above
where the overflow happened. Two changes close it at the layers that own
each suffix:

- Staged artifacts move to a `.staging` root and are named from the
  upload id alone. A name derived from the destination inherits its
  length and then adds to it; a fixed-width one removes the arithmetic
  instead of re-budgeting it, so no suffix added here later can depend on
  the caller's file name. The staging root is a cleanup sweep root, which
  also reclaims artifacts that used to be orphaned beside the
  destination.
- The durable sidecar is reserved out of the key budget centrally.
  `LOCAL_UPLOAD_METADATA_SUFFIX` moves next to the budget that must
  account for it, and the budget is derived from a list of sidecar
  suffixes, so adding one shrinks every key builder at once.

The declared `maxLength: 255` stays honest: a 255-character name now
completes PUT and `complete` end to end.

* fix(uploads): budget every key built from a caller-supplied name

Auditing the rest of the codebase for the shape that broke the
upload-session PUT found five more key builders that put an unbounded
name into a path component local storage writes directly.

Three are on the same route as the original bug: `table_import`,
`profile_picture`, and `workspace_logo` built their key inline with
`sanitizeFileName`, which maps characters and never truncates, while
their sibling purposes went through `buildStorageKeySegment`. A
255-character name broke `table_import` at the metadata sidecar and the
other two at the object write itself.

The other two are local-storage writers reached from elsewhere:
knowledge-base connector sync capped the document title at 200 and then
appended a timestamp, a uuid and `.txt` on top of the cap, landing at
exactly 255 with no room for the sidecar; the Mistral-OCR staging and
chunk keys inlined the sanitizer with no bound at all; and inbound email
attachments went into a key with neither sanitizer nor bound, on a file
name an outside sender chooses.

All now derive their component through `buildStorageKeySegment`, so the
reservation is stated once. The upload-session test asserts it for every
purpose the contract admits, which is what keeps a newly added purpose
from reintroducing the hand-built form.

* fix(v2): stop the logs and billing reads answering 500 or a silent restart

Four caller-reachable failures on `GET /logs`, `GET /logs/{runId}`, and
`GET /billing/logs`, each fixed at the layer that owns the guarantee.

`minDurationMs`/`maxDurationMs` were published as `number` against an
`integer` column, so `1.5`, `-0.5`, `2147483648`, and `1e30` all reached
Postgres as bind parameters it refuses to parse. They are now whole
milliseconds bounded to int4, and the generated spec says so.

`0000-01-01T00:00:00Z` satisfies the published `date-time` pattern but
names no instant Postgres can store, since the proleptic Gregorian
calendar has no year zero. `v2RunWindowBoundSchema` now rejects it, which
covers both log families and the files-audit read that share the schema.

A scoped cursor whose inner token was the empty string passed the
`typeof === 'string'` envelope check and then read as falsy in every
domain reader, so both lists silently served page one again with a
`nextCursor` inviting another lap — the exact failure
`UNKNOWN_CURSOR_MESSAGE` exists to make visible. An empty inner is now
unreadable, and the sibling `decodePublicLogCursor` gets the same
treatment for its `id` half. The rejection message no longer names
`sortBy`/`sortOrder`, which neither operation accepts.

`GET /logs/{runId}` reported `folderPath: null` for both a workflow at
the workspace root and a folder it could not resolve, so a caller could
distinguish neither, and `null` is not a value `folderPaths` takes back
as a filter. The root is now `/`, matching the workflow resources.

Also, from the same audit: comma lists reject an empty entry the way
`folderPaths` already did instead of dropping it; a query param sent
twice is named as duplicated rather than reported absent; and the
`triggers=all` sentinel, the detail-level promotion by
`includeTraceSpans`/`includeFinalOutput`, and the 403/404 split against
the billing family are documented where each is decided.

* fix(v2): pin naive timestamps to UTC and close six contract divergences

Application-written timestamps reached the wire as a local wall clock
labelled `Z`. Every column in `schema.ts` is `timestamp without time
zone`, so the instant a value denotes was decided by whoever wrote it and
whoever read it, and the writers disagreed: `now()` renders in the
session's TimeZone, drizzle's `mapToDriverValue` is `toISOString()`, and
a raw `Date` bound through postgres.js is cast down in the session's
TimeZone. The read side disagreed the same way — postgres.js parses oid
1114 with `new Date(x)`, which is the process's local zone, while a value
it hands back as a string is read as UTC by drizzle. The result passes
every `date-time` check, so it silently corrupts sorts and range
predicates and can place `updatedAt` before its own `createdAt`.

`packages/db/timestamps.ts` removes the ambiguity at the driver boundary
rather than at the call sites: the session TimeZone is pinned to UTC so
all three write paths store the same wall clock, and oid 1114 is parsed
as UTC so every read path recovers that instant. `withUtcTimestamps`
merges both into a client's options, because `connection` is nested and a
pool setting its own `application_name` would otherwise drop the
TimeZone. Production already runs both in UTC, so nothing changes there;
every other environment now behaves the way production does.

Alongside it, six places where the published contract and the code
disagreed:

- Multi-select `ncontains` was documented as "the exception" that
  excludes nulls. It never did, and no test claimed it did — `data` is
  never NULL, so containment is false for an absent key and the negation
  is true, exactly like every other negation. The sentence was wrong.
- `recursive` published twelve lowercase spellings while `z.stringbool()`
  folded case, so the server honoured `recursive=True` as a destructive
  recursive delete that a generated client would have refused to send.
  Narrowed to case-sensitive: accept exactly what is published.
- The upload data plane answered with a bare `{ error: string }`. Being
  absent from the OpenAPI documents is a statement about addressability,
  not about behaviour; both PUTs now use the canonical envelope, and what
  the transfer step promises is published on `transfer.url`.
- Full-set lists told callers to "send it back as `cursor`" on a
  `.strict()` query that rejects `cursor`. `v2CursorListResponse` now
  takes `paged`.
- A `HEAD` on a download skips the read that produces `Content-Length`,
  so it cannot size a download; the description says so.
- The upsert conflict-target rejection echoed the storage id a name-keyed
  surface had already translated to, and the scoped-cursor 400 named
  `sortBy`/`sortOrder` params `/audit-logs` does not accept.

* improvement(v2): cut the extraneous half out of the published descriptions

The v2 spec's description median was already healthy at 42 characters; the
tail was not. 174 descriptions ran past 200 characters and 13 past 700,
almost all of it rationale, cross-references, and constraints restated on
the wrong object.

Trim the shared error, folder-path, retention, pagination, and workspace-key
constants first, since each is published on between two and twenty-seven
operations. `FOLDER_TREE_TOO_LARGE` dropped the clause explaining why the
tree has to load, `FULL_SET_LIST` dropped a second sentence restating its
first, `RUN_RETENTION` dropped the `runCount` caveat that already lives on
`runCount`, and the 503 and 499 descriptions dropped the paragraphs
narrating why they are documented at all. That reasoning belongs in the
TSDoc beside each constant, which is where it now is.

Then the operations. Execute Workflow and List Runs each restated a rule
their own parameters already carry — the `X-Run-Id` uniqueness claim and the
`order` sort deviation — so both moved to the parameter that owns them. The
run-status enum sent a caller to `paused.automaticResumeWaitingReason` and
then explained that field in place of describing it; the explanation moved
onto the field, which previously said only that it was "the reason automatic
resume is waiting".

Align the parameter vocabulary a caller meets in every family. One `cursor`
description had forked on the table row query, one `sortBy` on knowledge
documents, and the table row `limit` published neither its bounds nor its
default. `nameSortCollation` is now a function of the column it names, so
the knowledge document list can state the caveat about `filename` without
claiming a `name` field it does not have. `scripts/openapi/documents.test.ts`
pins `cursor` and `sortOrder` to one string each, and the retention window to
both reads that publish it.

Distribution over the seven documents: mean 71 to 67, p95 223 to 199, p99 453
to 370. Over 200 characters 174 to 147, over 300 94 to 59, over 400 54 to 20,
over 700 13 to 9. The median is unchanged at 42.

* fix(v2): keep one unreadable-cursor message

Two branches each added the constant, in cursor-binding and list-query. It
belongs beside its sibling REFILTERED_CURSOR_MESSAGE, so the list-query copy
and its importers move there.

* fix(v2): bind a cursor to what a set filter means, not how it was spelled

workflowIds, triggers and folderPaths are comma lists the query treats as
unordered sets, and tagFilters is an object whose key order carries no meaning.
Fingerprinting the raw spelling bound the cursor to the spelling, so a caller
who reordered an equivalent filter mid-walk got a 400 for a page that was
genuinely the next one.

* fix(v2, db): make two unfalsifiable tests observable and document strict query

Three follow-ups on the w5 policy work: one decision recorded, two tests that
could not fail.

The `query: noInputSchema` sweep is kept. It is a real tightening — 69 v2
operations that ignored an unknown query param now answer 400 — so it was
weighed rather than assumed. The v2 body slice on those same endpoints was
already `.strict()`, and every v2 list already rejected `?bogus=1`, so the
split was arbitrary rather than a promise: the same typo was a 400 on
`GET /workflows` and a silent 200 on `GET /workflows/{id}`. A parameter the
server drops without saying so is the bug class the lists' rule already exists
to prevent. No first-party caller is affected — the two SDKs send only
`includeOutput`/`selectedOutputs`, both declared; the UI and the desktop app
make no v2 calls at all; `requestJson` appends nothing implicitly and no v2
cache buster exists; every docs example uses a declared param. A third-party
caller appending a tracking tag does break, which is why the behavior is now
documented in the API reference with the exact 400 body rather than left to be
discovered, and why the reasoning sits in the v2 conventions skill next to the
rule instead of only in a commit message.

`packages/db/timestamps.test.ts` asserted that `withUtcTimestamps` registers a
UTC parser on oid 1114 by reading it off a bare postgres.js client. Every real
client is then handed to `drizzle()`, which overwrites that entry with a
transparent parser, so the assertion held whether or not the parser had any
effect. The mechanism is fine and stays: drizzle's own `PgTimestamp` mapper
appends `+0000`, so the read is UTC-correct either way and the session
`TimeZone` pin — the write-side fix — is untouched by `drizzle()`. The test now
resolves the parser both before and after `drizzle()`, pins the clobbering it
depends on, and asserts the instant recovered through the full composition, so
a regression in either layer is red. `timestamps.ts` records why the inert
entry is kept.

`nul-byte-boundary.test.ts` embedded a raw U+0000, so git classified it binary
and rendered it as `Bin 0 -> 4102 bytes` — the test proving the NUL hardening
works was the one file a reviewer could not read. The escape is byte-for-byte
equivalent at runtime. Two older files had the same defect and are fixed the
same way. `check:source-text` now fails the build on a raw NUL in any tracked
source file, and `.gitattributes` forces source files to diff as text so the
next one is visible in review rather than hidden by it.

* fix(w5): narrow three fixes that reached past the harm they were fixing

The workflow-create `23505` handler answered for the whole transaction, which
also runs `saveWorkflowToNormalizedTables`. `workflow_blocks.id` is a global
primary key, so a block-id collision — an integrity fault already seen in
production — surfaced as `A workflow named "X" already exists in this folder`.
Match on the constraint name; any other unique violation propagates unchanged.

Moving the knowledge dispatch out of the completion transaction was right, but a
dispatch failure then committed the session as `completed` and left the document
at `pending`, which nothing sweeps and `retryProcessing` refuses. Record the
failure on the document instead, so it lands on the existing failed-document
path, and describe what the code does rather than a recovery branch that cannot
fire for this state.

The MCP re-registration reset stopped a registration claiming a connection it
never made, but reset for any re-registration. `isServerEligibleForDiscovery`
skips an OAuth row that is not `connected`, so a rename removed every tool the
server published with no path back. Scope the reset to url, transport, headers,
auth type, OAuth credentials, and revival.

* fix(tables): confine the write-policy tightening to what the caller sent

The null-policy work made `reject` the default for caller-supplied writes,
which is right, but it landed on the wrong values.

- A partial update coerces the MERGED row, so an untouched legacy cell failed
  an unrelated column's update — and failed a paged bulk job after its earlier
  pages had committed. The merged-row callers now name the patch's keys; every
  other key follows the `null` policy, in the in-memory copy only (the write
  sends the patched keys alone).
- A multiselect whose members do not all resolve returned `{ok:false}`, which
  on the machine paths that pass `'null'` — CSV import, computed writes, the
  cell-write snapshot — erased the whole cell. Those paths now consult a new
  `salvage` hook and keep the members that do resolve; a caller-supplied write
  still 400s on an unknown option.
- Refusing a bare number in `date.coerce` reached the executor, v1, copilot and
  the grid. The refusal stays where there is a caller to tell, and `salvage`
  restores the milliseconds reading where the only other answer is a blank cell.

Also: the cursor docblocks claimed pure-keyset cursors were left unbound while
the code and its tests bind them; a saved-view create took the table's SCHEMA
advisory lock, so it queued behind column rewrites whose statement timeouts run
past its 3s lock_timeout, and now takes a views-scoped lock instead; and a view
whose column was deleted could not be saved at all, because the Save chip always
resends the filter — references the stored config already carries are now exempt
while a newly introduced one is still refused.

The cursor version is deliberately not bumped: the stamp is additive, unfiltered
in-flight tokens keep working, and a filtered one fails with the accurate
"restart paging without the cursor" rather than a generic unreadable-cursor 400.

* test(db): narrow the mapped timestamp to Date

mapFromDriverValue is typed unknown, so the composition assertions did not
type-check outside the test's own runner.

* fix(v2): correct four stale contracts and clear the merge debris behind them

Five of the reported defects were real and four of them were documentation
that had stopped describing its own code.

`cleanCellValue` said only "coerce a raw input value"; it also answers `null`
for anything the column type refuses, and since the multiselect write path
started refusing partial matches that is the difference between a paste
storing one option and blanking the cell. It deliberately does not consult
`salvage`, which would read the same paste as the option that did resolve —
that reading is for writes with no caller to answer, and a typed cell has one.
The pairing is now asserted, so a future helper that "improves" the paste by
salvaging it fails.

`EXECUTE_OPTION_CONSTRAINTS` carried two stacked TSDoc blocks, the second
explaining that the enumeration had moved onto the fields; the body schema
still told a reader the six combinations were enumerated in the constant. The
deployment route's second block orphaned the endpoint documentation above it,
and `list-query.ts` kept the TSDoc for a cursor message that now lives, with
its own rewritten doc, in `cursor-binding.ts`. Two agents left near-identical
essays arguing the same 400-vs-403-vs-409 question about the table ceilings
and concluding that neither status changes; the decision is recorded once, in
`billing.ts`, and `service.ts` points at it.

The credentials use case echoed `sortBy`/`sortOrder` back with a TSDoc
explaining that the presenter needs them, which it no longer does — it reads
`query.*`. The local upload roots move from the data-plane provider to
`core/storage-key.ts`, beside the sidecar suffix, so the cleanup sweep can name
what it reclaims without importing the transport that writes it.

`documents.test.ts` justified sweeping only knowledge and files for the 413 by
saying the same sweep over the other five documents still reported gaps. It
does not: widened to all seven, every body-carrying operation publishes it.

Three reports did not survive checking, and the evidence is recorded where the
next reader will look. An empty rerank result is not the reranker matching
nothing — `rerank` asks for `top_n` over a non-empty document list, so an empty
array means the response carried nothing usable, which is what `unavailable`
already promises. The zero-byte knowledge document is refused on the
upload-session path too, by `validateFile`, under both boundary contracts;
that parity is now pinned, and it fails if the guard is removed. The MCP
re-registration reports exactly the connection fields its SET clause writes,
and the create mutation already drops both caches — what lags is the status
badge, not the tools, because discovery is gated on `connected` for OAuth rows
only.

* fix(v2): de-duplicate a set filter before fingerprinting it

The filters compile to inArray, which is set membership, so workflowIds=A,A,B
selects exactly what A,B does. Sorting alone still bound them to different
pages, so an equivalent filter with a repeated member 400d mid-walk.

* fix(w6): close a head-authorization hole, a TZ leak, and five tests that could not fail

Six risks an adversarial read of this week's diff raised, verified one at a
time. Two of the six were already correct and are reported as such rather than
changed.

`v2HeadAuthorizationResponse` optional-called the use case's authorization
phase, so a use case without one would have answered the bodiless 200 that
`headSafe: false` exists to prevent. The definition-time guard does cover both
builders that reach it — they are its only callers — but an optional call turns
a missing phase into that leak silently, so the responder now refuses instead
of skipping.

`packages/db/timestamps.test.ts` assigned `process.env.TZ` at module scope and
never restored it. `TZ` is process state: a worker running files back to back
carried Asia/Tokyo into every file that followed, and only when the ordering put
it after this one. The zone is now set and restored around the file, with both
properties the suite depends on intact.

Upload publication moved its staging area out of the destination's own
directory into a shared `.staging` root, which makes the publishing `link` a
cross-subtree one. A volume mounted under part of the uploads tree puts the two
on different devices and `link` answers `EXDEV`, which the same-directory link
could not. Publication now copies onto the destination's device and links from
there, keeping the create-or-fail step that stops a replay from overwriting a
stored object.

Five tests that passed regardless of the code:

- `resolveFolderPathFilter` was only ever exercised through hand-written
  reimplementations in the suites that mock it out, so widening a miss to
  unfiltered — every filtered list answering with the whole workspace — left
  them all green. The real helper is now tested where it lives.
- The only measurement of `generateWorkspaceFileKey` asserted the key's last
  component against `NAME_MAX` rather than the component plus the sidecar
  written beside it, so it passed with the sidecar reservation removed.
- `GET /logs` asserted only that a rejected cursor does NOT name `sortBy`,
  which almost any wording satisfies, including one saying nothing at all.
- The skills lifecycle test asserted that the four writes agree on a
  workspace-key policy, which a lifecycle uniformly allowing one also
  satisfies; it now pins the policy they agree on and the kinds they admit.
- The v2 skills create test lost `expect(capture).not.toHaveBeenCalled()` when
  the create path moved to a personal key. The behaviour it pinned is gone —
  the workspace-key create is refused now — so it is re-homed as the refusal
  reaching the caller as a 403 with no analytics behind it.

Two claims did not hold. `CURSOR_VERSION` is correctly left at 1: the filter
stamp is additive, a pre-stamp token still decodes, an unfiltered read still
resumes, and only a filtered replay fails — with a conflict that names the
filter, where a version bump would answer a generic unreadable-cursor 400 to
every in-flight token. Tests pin all three, plus the minted version itself. And
the upload-session key-budget cases do exercise the real shared budget through
the real segment builder; only the workspace-key prefix is the stub's, which is
now stated where the stub is declared.

* refactor(v2): collapse two names for the cursor scope key onto one helper

`cursorFilterScope` in the v2 response module was a one-line pass-through to
`cursorScopeKey` in `lib/api/cursor-binding`, so the same function was reachable
under two names from two modules. Routes now call `cursorScopeKey` directly, the
way they already import `unorderedScopePart` and the cursor messages from that
module, and the wrapper plus its duplicated doc comment are gone.

Also folds the `id -> name` column map in the v2 tables presenter onto
`buildColumnNameById`, which the same file already imports and calls thirteen
lines above; restores two doc comments that had drifted onto the wrong
declaration; and replaces three `as Date` casts in the timestamp test with
`toEqual(new Date(...))`, which needs no cast and additionally fails when the
mapped value is not a Date at all.

* refactor: delete three pieces of surface this branch added with no consumer

`v2CursorSchema` had one caller, `v2PaginationFields`, in the same file, and its
only parameter was a default nobody overrode — so the export and the parameter
were both unreachable. Inlined into the pair it belongs to; the emitted schema
and its description are byte-identical, so the generated OpenAPI does not move.

`PatchedKeys` was declared `ReadonlySet<string> | readonly string[]`, but all
four callers pass `Object.keys(...)` and no test passes a set, which left the
`instanceof Set` arm of `policyResolver` unreachable. Narrowed to the array form
the callers actually use.

`NUL_CHARACTER` was exported from `@sim/utils/string` and imported by nobody —
every boundary imports `containsNulCharacter` instead. Kept as the module-local
constant the predicate reads, dropped from the package surface.

* docs(v2): state why the local upload data-plane routes bypass the builders

Both local-storage PUT routes use raw `withRouteHandler`. The global rule
allows that only for documented protocol or lifecycle exceptions, and their
TSDoc explained the OpenAPI exemption and the error envelope but never the
builder bypass itself. Record the actual reason: a signed `upload-token` is
the credential, so there is no API key, `Principal`, or semantic operation
for a builder to authenticate and authorize against, and the body streams
straight to storage rather than being parsed.

* test(v2): pin cursor-to-filter binding on the tables and runs lists

The branch binds every paged cursor to the filters it was minted under, but
the binding was enforced end-to-end on only 4 of 16 paged lists. The
contract-level CURSOR_BINDINGS sweep looks like the safety net and is not:
it checks each contract against a hand-maintained map of param names, never
against what a route actually stamps into cursorScopeKey, so it stays green
for a route that dropped the stamp entirely.

Confirmed by deletion. Removing tableCursorFilters from both call sites on
GET /v2/tables left all 8 tests passing, and the runs route was worse — its
one relevant assertion was weakened from toEqual to toMatchObject in this
same branch, leaving the new filter field unpinned.

Adds a mint-then-replay test to each: a cursor minted under one filter set
and replayed under another is a 400 that never reaches the use case, with a
same-filter resume case as the control so the 400 cannot be satisfied by
blanket rejection. Restores toEqual on the runs cursor payload, pinning that
a filter is stamped without hardcoding the fingerprint.

Both new guards were verified to fail: removing the binding reddens the
refiltered test on tables, and both the refiltered and the re-armed toEqual
test on runs.

* fix(tables): keep the v2 write strictness inside v2

The write-path tightening on this branch changed shared code that every
first-party surface reaches, so the workspace grid, the internal
`/api/table` routes, `/api/v1`, the Copilot table tools, and the executor's
Table block all inherited a contract only `/api/v2` publishes. Each of them
now behaves exactly as it does on staging again, and v2 keeps the strictness
by opting into it.

- `coerceRowValues`/`coerceRowToSchema` default to the `null` policy again —
  an uncoercible optional cell is blanked and the row is written. `reject` is
  reached through `RowWriteOptions.uncoercibleValues`, which the v2 row
  routes set via `strictWrite` on the application input.
- The same `strictWrite` scopes the unknown-column refusal to v2. Copilot
  feeds the model's raw arguments in unfiltered, so a hallucinated key, an
  echoed `id`, or a name left over from a rename had begun refusing the whole
  write.
- Multiselect and bare-epoch values land again for first-party callers
  through the registry's existing `salvage` hook, which the `null` policy
  already consults; the grid's `cleanCellValue` consults it too, so a paste
  naming one live option and one deleted one keeps the live one instead of
  erasing the cell.
- The saved-view name→id remap no longer rewrites a ref that already means
  something else, so a user column named `id`/`createdAt`/`updatedAt` cannot
  hijack a view's system-column sort or filter.
- `createTableView` tolerates the refs its own config carries unless the
  caller is strict, so "Save as view" stops 400ing on a dangling filter the
  Save chip accepts.
- The bulk update runner is byte-identical to staging again.

The 100-view cap stays: the list read is unpaginated, so the promise it makes
only holds if the write side enforces it, and it refuses a new view rather
than an existing config.

* test(v2): pin cursor-to-filter binding on seven more paged lists

Extends the mint-then-replay guard from tables and workflow runs to the
remaining paged v2 lists the audit found with no route-level coverage:
credentials, audit-logs, custom-tools, mcp-servers, secrets, knowledge
bases, and knowledge documents.

Each gets a cursor minted by driving GET under one filter and replayed
under another, asserting a 400 carrying REFILTERED_CURSOR_MESSAGE that
never reaches the use case, plus a same-filter resume control so the 400
cannot be satisfied by blanket rejection. The three cursor schemes are all
covered: keyset (readSortedCursor), the scoped wrapper audit-logs uses for
its domain token, and the offset cursor on knowledge documents.

The documents suite had no GET coverage at all, so its list use case gains
a real mock and the route's GET export a describe block.

All fourteen were verified to fail: dropping the cursor-filter argument
from both call sites on each route reddens exactly that route's refiltered
test and leaves every other assertion in the file green, which is the
failure mode the contract-level CURSOR_BINDINGS sweep cannot see.

* test: cover four untested behaviors and drop five tests that cannot fail

Adds coverage that goes red when the behavior is reverted:

- `rejectDuplicateQueryValues` through `parseRequest`, not just the pure
  helper — the existing blank-query tests stay green even when parseRequest
  ignores the flag entirely.
- `failUndispatchedDocumentProcessing`'s pending + not-deleted WHERE guard,
  asserted on the condition tree so removing it fails.
- The widened `present(result, request)` signature, so dropping the second
  argument stops being a silent no-op.
- The NUL scan on `readFormDataWithLimit`'s content-length branch — the
  branch every ordinary browser and curl upload takes, and the one the
  existing multipart tests never reached.

Removes tests verified incapable of failing: the credentials projection row
(the outbound `.parse()` strips unknown keys either way), the per-document
413 sweep (vacuous on two of three documents, subsumed by the sweep in
scripts/openapi/documents.test.ts), the two upload-session rows that assert
their own `generateWorkspaceFileKey` stub, the storage-key row whose 20-byte
name never reaches the budget, and the views-lock assertion against a
function `views/service.ts` does not import.

* fix(v2): parse a bound list filter once, so the scope matches the query

The logs list fingerprinted `workflowIds`, `triggers`, and `folderPaths`
through unorderedScopePart, which trims each member, then split the same raw
values itself with `.split(',').filter(Boolean)`, which does not. So
`?workflowIds=A,B` and `?workflowIds=A, B` produced one fingerprint and two
different result sets: the second selects on a member with a leading space
that matches no row. A cursor minted under one was accepted under the other,
which is the exact failure the filter binding exists to refuse.

Extracts parseUnorderedList as the single parse. unorderedScopePart now
derives from it, and the route passes the array to the query and the joined
form to the scope, so the members fingerprinted are by construction the
members filtered on. Also drops three inline splits.

Reported by Greptile.

* fix(v2): bind an AND-conjoined filter array as a set, not a sequence

The knowledge documents list fingerprinted tagFilters through canonicalJson,
which sorts object keys but preserves array order. Each filter compiles to a
condition in and(...whereConditions), and AND is commutative, so the same
clauses written in a different order select the same documents — and got a
different fingerprint, refusing a cursor for a page that was genuinely the
next one.

Adds unorderedJsonScopePart beside parseUnorderedList: members are
canonicalized, de-duplicated, and sorted, so `A AND A` binds like `A` and
clause order stops mattering. A non-array or unparseable value still binds
by its raw spelling, since that request fails validation anyway.

Replaces the route-local canonicalTagFilters, and corrects the claim on
canonicalJson that array order only ever costs a restart — for a set-valued
filter it costs a spurious 400.

Reported by Greptile.

* fix(v2): bind list filters by the value the query acts on, not its spelling

Third report of one root cause, so this fixes the cause rather than the case.
A cursor scope must fingerprint what the query filters on; every place it
fingerprinted the caller's raw text instead, two spellings of one filter got
two scopes and a valid next page got a 400.

Knowledge documents: tagFilters bound the raw query text while the route
already parsed it two lines below for the use case. The schema defaults
operator to 'eq', so {tagName,value} and {tagName,value,operator:'eq'} are
one filter to the query and were two scopes to the cursor. The scope now
binds the parser's output, which also subsumes the clause-order fix — both
route tests go red against the raw-text form.

Logs and workflow runs: startDate/endDate bound the raw text, but
z.string().datetime() admits every sub-second spelling of one instant, so
`…00Z` and `…00.000Z` name one window and got two scopes. New
instantScopePart binds the parsed instant.

Replaces unorderedJsonScopePart, which took raw text and could not see a
schema default, with unorderedScopeOf over the parsed value.

Swept all fourteen routes that build a cursor scope for the same divergence;
these were the only ones where a scope part is derived differently from the
value reaching the use case.

Reported by Greptile.

* fix(v2): bind the audit and billing window bounds by instant

The previous sweep for this defect looked for a transform in mapInput, so it
missed the two routes that pass their raw bounds to a use case that parses
them deeper. Both fingerprinted startDate/endDate as text while their
predicates convert to a Date, so `…00Z` and `…00.000Z` name one window and
got two scopes, refusing the genuine next page.

Billing keeps stamping the raw params rather than resolveDateRange's output,
for the reason already recorded there: a relative `period` resolves against
the clock, so hashing the resolved window would reject every next page.
Normalizing the explicit bounds is compatible — instantScopePart is a pure
function of the caller's own text and resolves nothing.

Re-swept all fourteen cursor-scope routes by scope part rather than by
transform site. Every temporal and structured part now binds canonically;
the rest are enums and identifiers with one spelling per value.

Reported by Greptile.

* fix(v2): drop an inert field from the document tag-filter scope

resolveKnowledgeTagFilters builds every structured filter with the stored
definition's fieldType and never reads the caller's — not for resolution, not
for validation, not in its output. Fingerprinting it made a field the query
ignores decide whether a cursor resumes, so adding or removing a matching
fieldType refused a page that had not moved.

Swept the other twelve cursor-scope routes for the same shape. No scope part
is absent from its mapInput, this was the only scope carrying a structure
resolved against stored state, and knowledge/search has no cursor at all.

Reported by Greptile.

* refactor(v2): derive the body 413 from the contract in every document

Two mechanisms encoded one rule. `withRequestBodyErrors` derived the 413 from
`route.contract.body` for the tables document, while the resources document
hand-picked RESOURCE_BODY_ERRORS / RESOURCE_CONFLICT_BODY_ERRORS at nine
sites. The cross-document sweep caught drift, but only after the fact: a new
body operation that forgot the _BODY_ variant published a reachable 413
nowhere until a test failed.

Hoists the mapper to openapi/shared.ts and applies it in both documents, so
the rule is derived rather than remembered. The two hand-picked sets and
their shared TSDoc are gone.

Regenerating all seven specs produces zero drift, which is the proof the two
mechanisms were computing the same thing.

* refactor(v2): collapse duplicated cursor and validation mechanisms, drop dead exports

One rule, one implementation:

- `parseRequest` hand-inlined the "caller envelope or default" validation-error
  projection four times. Extract `projectValidationError` and route all four
  through it.
- Nine keyset lists hand-rolled the `present` half of the cursor pair that
  `readSortedCursor` already owns the read half of. Add the symmetric
  `writeSortedCursor` and use it everywhere.
- `GET /workflows/{id}/runs` re-derived `readSortedCursor`'s invalid/refiltered
  ladder from `decodeSortedCursor`; it now calls the shared reader and keeps
  only the key-arity check that is genuinely its own.

Files and exports that no longer earn their place:

- Inline `credentials/utils.ts` into its single consumer.
- Delete symbols with zero references repo-wide: `v2CustomToolWriteError`,
  `secretCredentialTypes`, `v2CursorList`, `v2WorkspaceAccessError`,
  `resolveFolderPathIdentity`, `folderPathForId`, `v2FolderPathMutationError`,
  and seven of twelve `tables/utils.ts` exports.
- Drop `export` from symbols used only inside their own module.

No behavior change; every response body and error message is byte-identical.

* docs(v2): cut duplicated and non-load-bearing comment prose

Five rationales were written three to five times each by parallel agents
that could not see one another. Each now has one home and the rest point
at it:

- HEAD existence oracle -> the headSafe option on defineV2JsonRoute
- cursor query binding -> cursorScopeKey in lib/api/cursor-binding.ts
- storage-key prefix budget -> buildStorageKeySegment
- NUL / U+0000 -> the containsNulCharacter predicate
- blank and duplicate query values -> their own implementations

Also drops changelog-in-source (prose narrating what the code used to
do), anchorless module headers attached to no declaration, rejected-
alternative essays, and @param tags that only restate the signature.

Comments only: the diff contains no executable-code change.

* fix(v2): name the undecodable-cursor failure on the two sortless lists

GET /workflows/{id}/versions and GET /workspaces/{id}/members threw a bare
'Invalid cursor' literal where every other v2 list uses a shared constant.
The right one is UNREADABLE_CURSOR_MESSAGE, not INVALID_CURSOR_MESSAGE:
both lists take only limit and cursor, so naming sortBy/sortOrder would
answer one 400 with advice that earns a second.

Their missing filter scope is correct and stays. Neither contract accepts a
filter — v2PaginationFields is the whole query — so there is nothing to bind,
and limit is excluded from a scope by design.

Pins the message on the versions route, verified to fail against the literal.

* test(openapi): give the determinism check a chosen timeout

`serializes all documents deterministically` serializes all seven published
documents twice — roughly 2MB of JSON — under vitest's 5s default, which is
not a budget anyone picked for it. The published specs grew 3.3% on this
branch (961KB -> 993KB) from richer descriptions, which is far too small to
move a comfortable test and is enough to tip one already sitting just under
the cap. Measured at 5.1s in isolation with nothing else running.

Raises it to 30s for the openapi suite rather than trimming a real assertion.

* fix(v2): make the NUL path scan linear, and force a write surface to choose

Two findings from a simplify pass, both in code this branch added.

findNulBytePath copied `[...path, key]` per child, which is O(nodes x depth).
A caller controls that depth directly: v2 row cell values are `z.unknown()`,
so nesting passes Zod untouched and reaches the scan. Measured on Node 22 --
JSON.parse accepts a 200KB body nested 100k deep in 9.8ms, and the scan then
blocked the event loop for 27.7s. Frames now carry a parent link and the path
is materialized once, for the node actually reported: 27.7s -> 5ms, with
byte-identical paths across nested arrays, records, NUL keys and clean input.
The always-run first pass drops Object.entries for Object.keys, which halves
its cost on large bodies by not allocating a pair array per object.

`strictWrite` was optional with the lenient default, so a v2 write route added
tomorrow would silently inherit first-party behavior -- unknown column dropped
under a 201, uncoercible cell stored as null -- defended by nothing but five
copies of a literal. It is now required on the five write-shaped inputs, so
omission is a compile error. The type-checker named every caller: the five v2
routes already passed true, and the three Copilot sites now say false
explicitly, which is the behavior they already had.

* refactor(v2): apply the body-413 mapper to every OpenAPI document

The earlier unification wired withRequestBodyErrors into two of the five
content documents and left files-audit, knowledge and workflows hand-writing
the entry, so the helper's own claim that "a new body route cannot forget it"
held on 40% of the surface while reading as global.

Regenerating all seven specs produces zero drift, which is the useful proof:
the mapper agrees with every hand-written entry today, so the gap was never a
missing 413 — it was a missing guarantee for the next body route added to
those three documents.

The existing hand-written entries stay. The mapper is one-directional and
several bodyless folder reads publish 413 for the folder-tree ceiling, so
stripping them by hand would risk removing one the mapper cannot restore.

* refactor(v2): fold the v2 validation renderer into the shared parse defaults

V2_PARSE_DEFAULTS calls itself "the parse failures every v2 route renders the
same way", but the option deciding how a v2 validation failure renders sat
outside it and was re-stated at seven sites. A raw route that spread the
defaults and stopped emitted a non-v2 error envelope.

Removes the redundant line from the five sites that only restated it. The two
builders keep theirs: theirs sits after `...options.parseOptions`, so it is a
deliberate override that stops a caller swapping the v2 renderer, not a copy.

Also adopts the mandated `filterUndefined` in cursorScopeKey in place of the
Object.fromEntries/Object.entries form CLAUDE.md forbids, and collapses a
one-element `as const` array plus a Math.max over it to the single `.length`
they computed.

* test(persistence): keep the wire round trip without tripping the utils audit

check:utils forbids `JSON.parse(JSON.stringify(...))` and points at
structuredClone, which is right for a deep clone and wrong here: this test
exists to prove the schema accepts a `deployedAt` that arrived over HTTP as a
string as well as an in-process `Date`. structuredClone preserves the `Date`,
so adopting it would leave the test asserting nothing about the wire form.

Splits the serialize and the parse into two statements. The round trip stays
lossy — verified `JSON.parse(JSON.stringify(...))` yields a string where
structuredClone yields a Date — and the pattern the audit matches is gone.

Arrived from staging in #6660, so `check:audits` is red on origin/staging too,
not only here.
2026-08-13 10:52:20 -07:00
Waleed c49751b32e perf(prefetch): stop calling our own API over the wire during server render (#6657)
* perf(prefetch): read the data layer instead of calling our own API over the wire

Four server-render prefetches went out over HTTP to our own routes. With
INTERNAL_API_BASE_URL unset in prod, getInternalApiBaseUrl() falls back to the
public base URL, so each was RSC -> public HTTPS -> load balancer -> back into
the app, awaited inside the render with a second round of auth.

- /home fetched the workflow folder list that the workspace layout had already
  fetched, under the identical query key. Since getQueryClient() builds a new
  client per call on the server, the two never deduped: same data, twice a
  request, once directly and once over the wire. Dropped; the layout's entry
  already hydrates it.
- /home cached raw route JSON under workspaceFilesKeys.list, while
  files/prefetch.ts seeds that same key from listWorkspaceFilesWithShares. The
  contract declares the date fields z.coerce.date(), so consumers hold Dates —
  a file record's type depended on which page the viewer landed on. Now reads
  the same function files/prefetch.ts does.
- tables and knowledge folder reads now call listFoldersForWorkspace, matching
  the sidebar prefetch.

These reads carry no authorization of their own, so each surface proves the
viewer through getWorkspaceHostContextForViewer first and caches nothing when
it fails, leaving the client fetch to reach the route for the real 403. Both it
and getSession are cache()d and already resolved by the layout, so the proof
costs no extra queries.

Left on the wire, deliberately: the tables and knowledge lists, whose cached
shape is the serialized wire shape, and pinned items and members, which have no
exported data-layer function.

* improvement(prefetch): skip the viewer proof when there is no session

Passing an empty-string userId ran a real permission query that could only
return null. Take an optional userId instead and skip straight to the
unauthorized path, matching how the home prefetch is called.

* perf(prefetch): finish removing self-HTTP prefetches and delete the legacy helper

Converts the last four server-render prefetches that called our own API over
HTTP, and deletes prefetch-internal-fetch.ts now that nothing imports it.

- knowledge bases: runs the route's own listInternalKnowledgeBases use case
  with a principal from the same internalSessionAuth policy the route declares,
  then projects through the same presenter and contract. Not a bypass of the
  application boundary — the same path, called in-process.
- tables: extracts the route's list projection into lib/table/wire.ts as
  toTableListItem, which the route and the prefetch now both call. This matters
  because listTablesContract's response schema is a passthrough z.custom, so a
  client fetch caches the route's JSON verbatim. Seeding listTables() directly
  would have put Date objects and the server-only metadata field under a key the
  hook never sees them on.
- pinned items: extracts the route's inline query into lib/pinned-items/queries.ts
  as listPinnedItemsForUser, which the route now calls too.
- workspace members: getWorkspaceMemberProfiles already existed; the prefetch
  calls it directly.

normalizeColumn moves from app/api/table/utils.ts to lib/table/wire.ts with ten
importers repointed. That also removes a pre-existing lib/* -> app/api/* boundary
violation in lib/table/import-runner.ts. No response shape changes: the v1/v2
edits are import-path moves only.

Every converted read proves the viewer first and caches nothing when that fails,
so an unauthorized viewer's client fetch still reaches the route for the real
403. Authorization equivalence was checked by unfolding both paths to
checkWorkspaceAccess rather than assumed.

* improvement(prefetch): collapse the duplicated folder prefetch and unify the call shape

- Extract prefetchResourceFolders. The same eight-line folder prefetch was
  written three times, varying only by resourceType, with the key, stale time
  and mapper kept in sync by hand.
- Adopting it removes the conditional spread from the tables and knowledge
  prefetches. Tables can now early-return, matching prefetchFilesBrowser:
  prefetchResourceListChrome already self-guards on the same cached host
  context, so a null context meant the function did nothing either way.
- Take userId as string | undefined everywhere and guard inside, so every
  prefetch module has one calling convention rather than two.
- Export toWireTimestamp and use it for the create-table response's own copy of
  the same idiom, and drop a cast that the extraction made dead: the parameter
  is already TableDefinition, whose schema is TableSchema.
- Read params and the session concurrently on the tables and knowledge pages,
  matching the files page, and drop TSDoc that restated each prefetch's own.

* fix(prefetch): keep the tables list on its route and cut the executor edge

Reading listTables from a page prefetch put the executable tool registry into
the Tables page server graph — ~4,700 modules, which check:tool-registry-boundary
rejects. lib/table/service reaches workflow-columns by several independent
paths (directly, and through jobs/service and rows/service), so severing one
edge is not enough; untangling that belongs in its own change.

- The tables list goes back through GET /api/table, with the reason recorded so
  the next person does not repeat the attempt. Folders and chrome on that page
  stay on the data layer.
- stripGroupDeps moves to its own leaf module. It is a pure projection over a
  WorkflowGroup, but living beside the group runtime meant every importer of
  lib/table/service paid for the executor to get it.

Net effect on the Tables page graph: 2,186 modules to 1,742.

* perf(prefetch): finish the migration, delete the legacy helper, ratchet page graphs

Answers the question the previous commit left open: the tables list did not have
to stay on HTTP. lib/table/service reached the executor through
jobs/service -> rows/service -> workflow-columns, for one symbol.
pendingDeleteMask is a delete-visibility SQL clause with no executor
involvement, so it moves to its own leaf and that chain is cut. The tables
prefetch now reads the data layer like every other one, and
prefetch-internal-fetch.ts is deleted: nothing in the app calls its own API over
HTTP during a server render any more.

stripGroupDeps likewise moves to a leaf rather than being re-exported through
workflow-columns, so its importers no longer pull the executor to get a pure
projection.

React Query mechanism fixes, all found by audit:
- settings/[section] fired two prefetches without awaiting them. Only a settled
  query is dehydrated, so those were shipped mid-flight; a rejection hydrated
  into an error state retryOnMount: false never retries, leaving the panel
  broken for the session. Awaited now, and the pending-dehydration opt-in is
  removed since nothing streams.
- The viewer profile was prefetched by both the layout and the settings page.
  Separate server QueryClients mean that was a real second read per request.
- prefetchSubscriptionData was dead, and hand-rolled an unannotated raw fetch.
- retry is scoped to the browser. Query core defaults it to 0 on the server;
  stating one value for both opted awaited prefetches into a retry backoff. The
  gcTime default is dropped entirely — 5 minutes is already the browser default,
  and setting it explicitly overrode the server's Infinity, leaving a live timer
  and payload per request.

check:tool-registry-boundary now also ratchets per-page module counts against a
committed baseline, attributing a regression to the import that caused it via a
dominator tree. It caught a +444 regression in this branch by hand; it would
have caught it in CI. Its import regex also missed bare side-effect imports,
so `import '@/tools/registry'` could have slipped past it entirely.

Prefetch guidance added to .claude/rules/sim-queries.md.

* fix(prefetch): correct the extracted module's db imports and stale rationale

Audit findings from the migration.

- pending-delete-mask imported its schema tables from @sim/db rather than
  @sim/db/schema, which the module it came from was careful to split. The
  global test mocks are bound per-entrypoint and only the schema mock exports
  tables, so every suite that reaches pendingDeleteMask would have failed on a
  missing mock export. Restored to the original convention, and the same split
  applied to the new pinned-items queries module before it grows a test.
- The settings prefetch and page justified awaiting with a mechanism this
  branch removed — pending queries being shipped with their promise. Only a
  settled query is dehydrated now, so an unawaited prefetch is dropped from the
  payload entirely. Same conclusion, correct reason, and no longer contradicting
  the rule this branch added.
- Removed the doc block left orphaned above validateSchema when stripGroupDeps
  moved out of workflow-columns.

Skill projections regenerated after trimming the boundary skill.

* chore(table): drop a section separator comment

Separators like these are non-TSDoc decoration that CLAUDE.md already rules
out. This is the only one in a file this branch touches; the rest of the repo
is swept separately.

* chore: remove section separator comments

CLAUDE.md already rules these out ("No ==== separators. No non-TSDoc
comments"), but 546 of them had accumulated across 48 files. They decorate
rather than explain, and they drift: a separator says "Validation" while the
code beneath it moved elsewhere, as one in workflow-columns already had.

Pure deletion — no source line was touched, and lines inside template
literals were skipped so nothing in a generated string changed.
2026-08-13 00:12:45 -07:00
Vikhyath MondretiandClaude Opus 5 e8d278b6e4 fix(files): stop the collaborative editor rewriting and reflowing a document on open (#6652)
* fix(files): stop the collaborative editor rewriting and reflowing a document on open

Opening a file rewrote it. Binding an editor to a seeded document emits a Yjs
update of its own — ProseMirror appends an empty paragraph to any doc that does
not end in one — which the relay saw as a real edit and persisted. Every open
therefore uploaded the file under a FRESH storage key and deleted the old one,
404ing the page's own in-flight content read, bumping "Last Updated" just from
viewing, and churning a blob per open. Worse, a trailing blank line cannot
serialize, so the file never recorded that paragraph and nothing reconciled the
two: each client that seeded without seeing another's contribution stacked one
more. A real document reached 18 against the placeholder's 1 — measured as the
pane growing several hundred pixels the instant the live editor took over.

- Seed and merge through the editor's own normal form (`editorNormalForm`), so
  binding is a no-op and `canonicalizeYDoc` collapses an accumulated run back to
  one. Placed at the collab boundary, not in `parseMarkdownToDoc`: only the CRDT
  has to agree with the editor — every other consumer of the parse renders
  through a real editor that normalizes itself.
- Skip a persist whose projection already matches the durable bytes. Byte length
  is the free reject, so the compare read only happens when a no-op write is
  actually on the table.
- Revoke collaborative readiness on a fatal join. The sticky `syncedOnce` latch
  outlived the document: after a readiness timeout the provider drops `synced`
  so the gate closes, but the latch re-opened it on the offline fallback's seed
  flag — handing back an EDITABLE editor on a document the provider had
  abandoned, with client autosave gated off because collaboration is nominally
  on. Keystrokes went nowhere and vanished on reload, with no error shown.
- Recover from a superseded storage key instead of stranding the reader: a 404
  re-resolves the file record, so the read re-keys onto the current object. And
  do not focus-refetch durable bytes while the relay owns durability.
- Prefetch the workspace file list in the layout, where the sidebar already
  reads it. `HydrationBoundary` defers an already-seen query to an effect that
  SSR never runs, so a page-level prefetch of that key could not reach the
  server render — the file route rendered a spinner and disagreed with the
  client about the header's markup (a hydration mismatch).
- Load the document font with `display: block`. A swap repaints prose in
  metric-adjusted Arial first, so paragraphs re-wrap when the real face lands.

Also: a detail-route `loading.tsx` (the segment was inheriting the list chrome),
`normalize.ts` renamed to `field.ts` now that it holds only the field constant,
and the duplicate `COLLAB_DOC_FIELD` in the streaming path folded into it.

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

* fix(files): serve a whole document on join, and stop persistence deadlocking

Opening a file right after an edit replayed it — the block you had just moved
moved again, in front of you. A room rebuilds itself from the file's Redis
stream one entry at a time, into the same Y.Doc that fans every update out to
its room, and the join attached the socket before that finished and before the
server seed. So a client was never sent the document; it was sent the
document's history, and it watched that replay. The new join-readiness test
reproduces it exactly — ['AAA', 'BBBAAA'] where one state was due — and fails
without the fix.

Underneath it, persistence had deadlocked. The If-Match token is a REMEMBERED
timestamp: held in the room, which dies with it, and in a cluster key written
fire-and-forget. A relay that exits in the moments after a successful write
comes back holding a version older than the file's, every persist then fails
the CAS, and because a conflict neither writes nor advances the token, that
document can never be persisted again. Measured on a live file: token
1786594348911 against a content version of 1786594418409, with 103 unpersisted
entries still in the stream. The durable markdown froze there — and the
editor's placeholder is built from it, so every reload painted the pre-edit
document and the live one corrected it on screen.

- Assemble a room before attaching a client to it. The join awaits the stream
  catch-up and the seed, so the first sync IS the finished document, in one
  message. The seed is memoized on the room, so concurrent joins wait for the
  same one instead of the second being served an empty doc; a task that loses
  the seed lock PULLS the winner's seed from the stream rather than waiting for
  the tailer to push it, which is what left a freshly uploaded file read-only
  until its readiness deadline lapsed.
- Drop the client-side quiet-frame gate that was standing in for this. It was
  unsound in both directions: it delayed a document that was already correct,
  and it opened mid-flight anyway whenever updates arrived more than a frame
  apart, which is what a cross-region Redis and a long room history produce.
- Await the cluster version write after a successful persist. It is the only
  record that survives a teardown, and one round trip after a blob write is not
  a cost worth a wedged document.
- On a version conflict, ask the CONTENT, not the clock. If the file still
  holds the bytes this document last projected — the tag written with every
  persist — then nothing out-of-band exists to protect, so re-sync the token and
  write. Any other bytes and the conflict stands exactly as before.
- Actually run the stale-storage-key recovery. It was requested from inside the
  failing read's own queryFn, where react-query drops it, so nothing re-resolved
  the record and the reader sat on a dead key showing "Failed to load file
  content" until something unrelated refetched. It now runs off that cycle and
  cancels a read already in flight, which could only hand back the dead key.
  While the record is re-resolving the surface reports loading, not failure.
- One document per file, for its whole life. A document rebuilt from markdown is
  a DIFFERENT document to Yjs — its items carry new client ids — so a client
  still holding the old one merges the file into itself, twice, on both sides.
  The seed now stores what it builds (until that row existed, a file opened but
  never edited was rebuilt on every open) and resumes it with a CRDT diff when
  the markdown moved on out-of-band. A document also carries an identity the
  join ack names, so a tab that outlived its room is refused rather than merged.
- Let a browser keep an embedded image. The inline route sent no-cache with no
  validator, so every open re-downloaded the whole image — measured at ~1 MB per
  open of a real document, with the image area blank until it landed. A `key`
  names one storage object and a content write never rewrites one, so those
  bytes are immutable; a `fileId` names the file, whose bytes move, so that form
  still revalidates.

* fix(files): name every collaborative document, and only cache what the URL names

Two findings from review, both real.

A document stored before identities existed is returned by the seed's fast path
on every open, and that path never named one — so those files could never
acquire an identity, and the join-ack guard could never fire for them. That is
the population most likely to have a tab that outlived its room, which is the
case the guard exists for. The fast path now names an unnamed document and
stores it, once: minting without storing would name it differently on every
open and the guard would start refusing clients that hold the very same
document.

The inline route marked a response immutable whenever the caller passed a key,
but a key is resolved to a FILE and the file's current key is what gets
streamed. A content write landing between those two reads would serve the new
bytes under a URL naming the old object — and cached for a year, that is wrong
forever. The flag is now what it always meant: the URL names the exact object
that was streamed.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:59:19 -07:00
Waleed e56b28857c fix(workflow): draw a highlighted edge over the ordinary ones (#6642)
* fix(workflow): draw a highlighted edge over the ordinary ones

An edge's z came from the nesting depth of the container it belongs to, and a
highlighted edge kept that depth like any other. A line one level deeper
therefore sat above it and painted straight through the highlight, cutting it
in half wherever the two crossed.

Give a highlighted edge — selected, or connected to the selected card — the top
tier of the edge band instead. Depth only ever ordered edges against each
other, and once the user has picked one out, being drawn whole matters more
than which container it came from.

The tier stays inside the band, below the cards, deliberately: highlighted
edges were elevated over the cards once before and drew across the chrome of
their own endpoints. A line belongs behind cards, knobs and the action-bar
swell whether or not it is highlighted, so ordinary edges give up the top of
the band rather than the band being widened into the cards.

* fix(workflow): elevate the connection preview edge with the rest

It renders highlighted — its data carries `isConnectedToSelection` — but it was
the one call site left taking a depth tier, so the line being drawn could be
crossed by an ordinary edge in a deeper container. Highlighted now means
elevated with no exception.

Also drop the export on the highlighted tier: nothing outside the module reads
it, and the band's tiers are an implementation detail of `getEdgeZIndex`.

* fix(workflow): give the edge highlight one definition

The z-index elevation I added checked canvas selection only, while the edge
darkens for panel focus too — a block open in the editor lights its edges, and
those stayed depth-tiered, so an ordinary edge could still cut through the
highlight. The bug I set out to fix, on the path I had not covered.

The condition already existed in two places and the second one carries a comment
saying it must mirror the first exactly, because a knob checking fewer
conditions than the line leaves a dark line running into a light knob. Adding
the z would have made a third copy, and the finding here is what the third copy
gets you.

One predicate now, in `edge-highlight`, used by the line, the knobs, and the z.
The canvas subscribes to the panel store rather than reading `getState()`, since
the z has to be recomputed when the open block changes.
2026-08-12 15:27:18 -07:00
Waleed 128054ea51 fix(workflow): stop subflows resizing themselves after every load (#6639)
* fix(workflow): stop subflows resizing themselves after every load

A container sized itself from its children, and when a child had not yet
reported a height it used `estimateBlockDimensions` in its place — a guess of
`ceil(subBlockCount / 2)` rows, which read a 39-field Gmail card as 276px tall
against the 112px it draws. The container painted that number, the real height
arrived a frame later, and it visibly resized between the two. Nothing is
persisted, so it happened on every refresh.

A card's height depends on what it actually renders — which rows survive its
conditions, whether it draws a summary sentence, and for a reactive field even
a credential it has to fetch — so the card is the only thing that can know it.
Size only from heights the children have themselves reported, and hold the
container at its current size until they have. `getBlockDimensions` keeps the
estimate for the callers that only need a rough box (clamping a drag, placing a
paste) and is now that same lookup plus the fallback.

Also stop `calculateContainerDimensions` counting the container's chrome twice.
Child coordinates are relative to the container's own origin and are already
held clear of the header by `clampPositionToContainer`, so a child's far edge is
the distance to cover and only the trailing padding is owed on top. Adding the
header and leading padding again left every container 66px taller and 16px
wider than its contents.

* fix(workflow): gate container sizing on this session's reported layout

Two holes in the measurement gate, both from reading the wrong field.

`height` is a persisted column and `data.width` / `data.height` persist a
container's last size, so a block that has not reported yet can still carry last
session's numbers — reachable through paste, import, and checkpoint restore. The
gate treated those as reported and sized from them.

Nested containers had it worse: an inner container with an unreported descendant
handed back its 500x300 default as though it were measured, so the outer
container sized to that and resized again once the descendant filled in — the
same two-step this change exists to remove.

`layout` is in-memory only and written by exactly the two places that know: a
card through `updateBlockLayoutMetrics`, a container through
`updateNodeDimensions`. Reading it means "reported during this session" and
nothing else, and an inner container that is still waiting reports null, so the
outer one waits with it.

`getBlockDimensions` keeps the persisted height and the estimate as fallbacks —
its callers only need a rough box, where a stale height still beats a guess.

* Revert "fix(workflow): gate container sizing on this session's reported layout"

This reverts commit 3cce724832.

* fix(workflow): size containers from a state-aware child estimate

The gate in the reverted commit held a container at its current size until its
children reported. That is worse than it sounds: the size it holds is the
persisted default of 300, the child needs 335, and so the child hung outside
the container until something forced a resize.

Estimate accurately instead of waiting. `getBlockMetrics` derives a card's
height from the block's own state — the sub-blocks its values leave visible, the
summary sentence, the error row — through the same
`calculateWorkflowBlockDimensions` the card calls, and lands on the height the
card goes on to render: 112px for the Gmail card the type-only estimate put at
276px. The pass before the cards report and the pass after now produce the same
container, so there is nothing to gate and nothing to correct.

This also fixes the guess everywhere else it was painted rather than only in the
container path — `estimateBlockDimensions` fed React Flow's node height for
unmeasured blocks, so selection bounds were 276px around a 112px card.

* improvement(workflow): even out the gutter inside a container

Left, top and bottom were 16 and the bottom read tighter than either, because
the 50px header sits above the top gap and gives that edge visual weight the
other two do not have. Taking them to 24 leaves the three gutter-only edges
matching and the bottom no longer pinched.

Right stays 80. The container's output handle sits on that edge, so a child
needs clearance there it does not need anywhere else — chrome rather than
gutter, now said so in the type.

Only reachable as a single constant each because the paddings mean what they
say: each is the gap between a child's edge and the container's, counted once.
While the sizing math added the header and leading padding a second time, the
effective bottom gap was spread across three constants and tuning it meant
reasoning about all of them.

* improvement(workflow): give a container one source for its own gutter

The four paddings and the header height existed twice: as
`CONTAINER_DIMENSIONS`, which sizes a container and clamps its children, and
again as Tailwind literals in `subflow-node-view`, which draws the header and
the content box. Nothing kept them in step and they had already drifted — the
view rendering a 40px header against a constant claiming 50, so children were
clamped 10px below where the header actually ends.

The view now renders from the constants, and the constant follows the DOM at 40.

Match the bottom gutter to the right at 80. The two edges that carry chrome are
now the two that are wider: the container's output handle sits on the right, and
the resize grip in the bottom-right corner spans 40px in from both, so a child
at the 24px gutter width could sit underneath it. Left and top are only gutter
and stay at 24.

* test(workflow-renderer): assert the subflow header's height, not its class

The header renders from `CONTAINER_DIMENSIONS.HEADER_HEIGHT` now, so the class
it used to carry is gone. Assert the rendered height against the same constant
the layout math measures against — the two drifting apart is what this whole
change is about, and a utility-class assertion cannot catch that.

Also set `IS_REACT_ACT_ENVIRONMENT`, which these tests have always needed. React
only treats `act` as supported when it can see the flag, so every render logged
"The current testing environment is not configured to support act(...)" — around
forty lines of it per run, burying the actual failure output.

* fix(workflow-renderer): declare the act-environment global

`vitest.setup.ts` is inside the package's tsconfig, so assigning an undeclared
property on `globalThis` failed type-check (TS7017) even though the tests ran.

* fix(workflow): size a container from one snapshot of the store

`calculateLoopDimensions` took child positions from the live store but child
dimensions from the hook's render snapshot, so it was reading two ages of the
same data. `resizeLoopNodes` walks deepest-first: an inner container resized
earlier in the pass was already updated in the live snapshot and still stale in
the closed-over one, so its parent sized against the old inner box and only
caught up on a later render — a nested container visibly resizing twice, which
is the symptom this branch set out to remove.

Take both from the snapshot the function already reads.

* fix(workflow): size an unmeasured note as a note

Routing every non-container block through `getBlockMetrics` sent notes through
the workflow-card estimate, which counts sub-block rows and an error row a note
does not have. A note that had not reported a height yet got a card's box, so a
container holding one sized itself around the wrong shape.

Give a note its own branch, as the estimate it replaced did: measured height
when there is one, and the height an empty note paints when there is not.
2026-08-12 13:26:23 -07:00
Waleed 7022e2ef4f fix(workflow): stop the coloured knob leaving a barb at each shoulder tip (#6632)
The connection knob is a recolour of one stretch of the card outline, so its
path has to be the outline's own path. Two things pulled it off:

Its span was cut at the exact point the bulge falls under the visibility
threshold, which is not one of the points the silhouette sampled — so the knob
sat on a grid of its own. And a span clamped its first and last control points
to the bare perimeter tangent, where the silhouette derives every control point
from the samples either side of it, so those two segments bowed differently
from the curve they were painted over. The knob was left still flat where the
silhouette had already begun its descent, and the uncovered sliver of dark
stroke read as a small spur poking off the shoulder. It is clearest on the
Error output's outer shoulder, against the card's bottom-right corner.

Measure the span against the interval the silhouette resamples the bulge over
rather than in whole pixels, and sample a step wide on each side before
trimming back, so every emitted segment has the neighbours the silhouette had.
The knob's commands now come out identical to the silhouette's, which the tests
pin — including for merged intervals and odd tab lengths, where measuring in
whole pixels would still have landed half a step off. The painted footprint is
unchanged.
2026-08-12 11:38:06 -07:00
Waleed 366829b6b0 fix(v2): derive the log and run status enums from the persisted status list (#6612)
* fix(v2): derive the log and run status enums from the persisted status list

`GET /api/v2/logs` and `GET /api/v2/logs/{runId}` parse the raw
`workflow_execution_logs.status` column against a six-value enum that omits
`paused`, so a run holding that value returns 500. The list response is
validated whole-page, so one such row 500s every page it lands on, and the
row is durable until the run is resumed, cancelled, or failed.

`paused` is not written by an ordinary human-in-the-loop pause — that path
persists `pending` (logging-session.ts:1180). It is written by
`PauseResumeManager.markResumeAttemptFailed`, which fires on any
`ResumeAdmissionError`: a workspace over its usage limit, an archived or
undeployed workflow, or a concurrent resume losing the claim race. That is a
routine business path.

The enum was supposed to be protected by an `AssertNever` exhaustiveness gate,
but the gate was vacuous: it compared against `PersistedWorkflowExecutionStatus`,
a hand-written union that was itself missing `paused`, because the write goes
through a raw `sql` CASE fragment Drizzle cannot type-check. Adding `paused` to
both lists would leave the same vacuous gate in place for the next status.

Instead, `PERSISTED_WORKFLOW_EXECUTION_STATUSES` becomes the single runtime
source of truth, `PersistedWorkflowExecutionStatus` is derived from it, and both
v2 contracts derive their enums from the const rather than re-declaring them.
Both surfaces pass the column through verbatim, so their reported set is the
persisted set by definition — there is no editorial choice for a gate to force,
only the question of whether a newly persisted status should be public, which
the option-list tests now pin. The `[...V2_PERSISTED_RUN_STATUSES, 'paused']`
append on the runs contract is deleted rather than adjusted; it would otherwise
be a duplicate.

Alternatives rejected:
- A `.catch()` or `safeParse` in the presenters is dead code:
  `v2-json-route.ts:271` re-parses the whole body with the same schema.
- Normalizing `markResumeAttemptFailed` to write `pending` would remove the
  distinction the resume claim query at human-in-the-loop-manager.ts:973 relies
  on, and leaves the contract wrong for any other future status.
- Typing the Drizzle column does not help: the offending write is a raw `sql`
  fragment, and `packages/db` cannot import the app's status list.

The v2 workflows spec changes are reordering and description only — the value
set there already contained `paused`. The v2 logs spec gains `paused`, which is
additive and safe while the whole `/api/v2` surface is behind the off-by-default
`v2-api` flag; it must land before v2 GA, after which it would be breaking.

* fix(v2): document both provenances of a reported paused run status

* fix(v2): stop promising a paused discriminator the response cannot always provide

* fix(v2): describe the paused discriminator as the code actually records it
2026-08-12 01:51:55 -07:00
Waleed 34d65df7d6 fix(sdk): make the 0.2.0 SDK release safe to publish (#6616)
The v2 SDK migration (#5273, #6564) shipped five breaking changes in both
SDKs but got the release mechanics wrong in three separate ways, and left
one of the two rewrites unable to complete a single successful call.

Versions. packages/ts-sdk/package.json read 0.1.3 -- a patch digit added
inside an unrelated compatibility commit, never deliberated. npm expands
^0.1.2 to >=0.1.2 <0.2.0, so every existing consumer would have picked the
break up on a lockfile refresh: AsyncExecutionResult.jobId renamed to
runId, executionId dropped from that interface, a failed sync run now
throwing instead of resolving {success:false}, the request body reshaped,
and the endpoint moved to /api/v2 with no fallback. 0.2.0 excludes every
existing range, so the upgrade becomes opt-in. packages/python-sdk carries
the identical break and was never bumped at all, so its publish job would
have skipped green at the "version already exists" gate and left the repo
and PyPI silently divergent; it moves 0.1.2 -> 0.2.0 in lockstep, along
with the __version__ string in simstudio/__init__.py, which tracks
pyproject and would otherwise have started lying. setup.py is left at
0.1.1: it is unchanged from main and demonstrably unread (0.1.2 published
from pyproject while setup.py already said 0.1.1). It wants deleting, in
its own commit.

A 404 fallback was considered and rejected. The legacy 202 body's statusUrl
points at /api/jobs/{jobId}, so mapping jobId onto runId would hand the
caller an id that getWorkflowRun cannot resolve against that same old
server -- a successful execute followed by an inexplicable failure on the
next call is a worse contract than a clean 404. Both READMEs instead state
the minimum server version and name the endpoint to check for.

Cancelled runs. packages/python-sdk computed success as status != 'failed',
so a run cancelled out of band reported success=True. The TypeScript SDK
uses a closed whitelist and reports False, and before the migration both
SDKs read the server's own value, which was False -- so this was a Python
regression, not merely an inconsistency. Fixed by mirroring the whitelist.
The v2 contract enumerates exactly completed|failed|paused|cancelled, so
narrowing the blacklist to a whitelist cannot drop a live value, and a
status added later now defaults to "not successful" rather than silently
reporting True. WorkflowExecutionResult gains a status field because
Python, unlike TypeScript, does not throw on 'failed' -- so success=False
alone is ambiguous there in a way it is not in the TypeScript SDK, which
is why status is not added to both.

Rate-limit header. Found while auditing the two SDKs for further
divergence, and the reason the Python bump could not have shipped as it
stood: every authenticated v2 response now carries X-RateLimit-Reset as an
ISO 8601 timestamp (recorded by v2RateLimits.publicApi, stamped by
withRouteHandler). The Python SDK parsed it with int(), raising a bare
ValueError that no handler in execute_workflow catches -- so every
successful v2 execution raised instead of returning. None of the legacy
endpoints the SDK previously called record a rate-limit snapshot, which is
why the latent int() survived until the v2 move. The TypeScript SDK
already branches on the format; _parse_reset_header mirrors it, including
degrading an unrecognised value to 0, because a quota hint must not take
down the call it rode in on.

Timing metadata. The v2 rewrite stopped forwarding startedAt/endedAt, which
main passed through and the TypeScript SDK still reports; restored under
the same startTime/endTime keys the TypeScript SDK uses.

Tests: cancelled/failed/paused status coverage, the ISO reset header, and
the restored metadata keys, each verified red against the unfixed line
first. The TypeScript suite gains matching cancelled/paused and ISO-reset
pins -- they pass against today's source by design, and were confirmed to
fail against a deliberately degraded copy so they are not toothless.

Deliberately not included: a CI guard failing a PR that changes SDK source
without a version bump. It would have caught this twice over, but it is a
new script and workflow rather than a fix to the defect at hand.

Review revision. bun.lock recorded packages/ts-sdk at 0.1.3 and was left
stale by the first pass, so the repo asserted two versions for the same
workspace package -- in a change whose whole thesis is that the version
strings had diverged. It does not break CI (bun 1.3.14 accepts the
mismatch under --frozen-lockfile, confirmed here), but 092311ea68 bumped
the lock in lockstep with package.json, and the next unfrozen install
would otherwise drop the line into an unrelated PR.

_parse_reset_header gated the numeric branch on str.isdigit(), which
accepts characters int() rejects ('²'.isdigit() is True, int('²')
raises) -- and that int() sits outside the try, so the one function added
to stop a quota hint raising could still raise, contradicting its own
docstring. str.isdecimal() is exactly the set int() accepts. The
tolerates-unparseable test is parametrized over both forms and was
confirmed red on '²' against isdigit.

Docs and docstrings: apps/docs api-reference/python.mdx mirrors the
README's dataclass block and was the only copy left without the new
status field. RateLimitInfo now names its units, because reset is epoch
seconds for the legacy integer and milliseconds for the ISO form that v2
sends. execute_workflow's Args entry still described the pre-v2 body
shape ("spread at root level"); every input is nested under input now,
and this is the commit that ships that help() text to PyPI. The
"declared last so positional construction keeps working" sentence was a
maintainer's note that belongs in this message, not in every user's
help(WorkflowExecutionResult).
2026-08-12 01:48:37 -07:00
Waleed 8f85ded35f fix(workflows): pin a stored block retry policy when loading it (#6614)
`workflow_blocks.retry` is a jsonb column written verbatim. Three of its
writers never validate what they store: the realtime batch-add and
replace-state ops take untyped block records, and the admin/superuser
import routes persist externally-authored workflow JSON. `load.ts` then
asserted the blob was already a `BlockRetryConfig` and handed it straight
to the HTTP boundary, where `workflowBlockStateSchema` bounds `maxTries`
to 2..5 and `waitBetweenTriesMs` to 0..5000.

That schema is shared between the PUT `/state` body, where the bound is
right, and the GET `/api/workflows/[id]` and `/state` responses, where it
is fatal. The response `.parse` in the shared route builder throws a
ZodError, which is not an `OrchestrationError`, so the error policy
declines it and it falls through to a 500. One out-of-range or partial
stored value therefore made a workflow permanently unopenable, with no
in-product repair — every UI write path reads the workflow first.

The feature already declares clamp-on-read as its contract: the commit
that added it says bounds are clamped on read rather than rejected, the
TSDoc on `resolveBlockRetryConfig` says the same, and `block-retry.test.ts`
asserts it. Execution has always honoured that. Only the read boundary
disagreed, so that is what this fixes: the loader now constructs a real
`BlockRetryConfig` from the blob through `normalizeBlockRetryTries` /
`normalizeBlockRetryWaitMs`, filling defaults for missing fields and
carrying `enabled` across unchanged.

`loadWorkflowFromNormalizedTablesRaw` is the single read choke point for
both apps — `@sim/workflow-persistence` for the Next app and the realtime
server's full-state emit — so one edit repairs every reader, including
rows that are already out of range, and the row self-heals on the next
save. It matches `clampParallelBatchSize` a few lines below, which already
pins a stored subflow value on the same path.

Alternatives rejected:

- Validating on write. It leaves every existing bad row fatal forever, and
  it would have to be repeated across three realtime ops plus roughly a
  dozen `saveWorkflowToNormalizedTables` callers, none of which share a
  validation seam.
- Bounding `BlockRetrySchema` in `@sim/realtime-protocol`. Its own TSDoc is
  correct that batch-add and replace-state bypass it, so this closes one
  writer and leaves the 500.
- Relaxing the response contract. It stops the 500 but leaves the editor
  rendering a number execution will never run. A test now pins the write
  bound so that shortcut fails loudly.
- `resolveBlockRetryConfig`. It returns null for a disabled policy, which
  would erase the numbers a builder configured every time state is read.

Tests: six cases in `packages/workflow-persistence/src/load.test.ts` (four
red before this change) covering out-of-range enabled, out-of-range
disabled with `enabled` preserved, missing fields, a non-boolean flag, an
untouched in-range policy, and NULL meaning "runs once"; plus a contract
test that the write bound still rejects out-of-range input.
2026-08-12 01:42:11 -07:00
Waleed d96f3f95c8 perf(db): drop 0287's two zero-row scans from the ACCESS EXCLUSIVE hold (#6609)
drizzle-orm 0.45.2 runs every pending migration inside ONE transaction
(node_modules/drizzle-orm/pg-core/dialect.js:60-71), and migrate.ts:212
sets `statement_timeout = 0`. So the ACCESS EXCLUSIVE that 0287:2's
ADD COLUMN takes on `workflow_blocks` is held, unbounded, through 0288
and 0289 to COMMIT — `lock_timeout` at migrate.ts:213 bounds acquisition
only, exactly as that file's own TSDoc at :76-78 says. Every editor load,
workflow save, executor block read, and realtime canvas op queues behind
it platform-wide, and migrations run before image promotion
(ci.yml:113-133), so the stall lands on 100% old-version traffic.

Two of the three statements inside that hold did nothing. `data.errorEnabled`
never existed in a released version — `git log origin/main -S errorEnabled`
returns zero commits across main's entire history — so both statements
filtered on it match zero rows, and the file's own comment said as much.
There is no index on the `data` expression, so each was a full sequential
scan of the whole table.

Measured on PostgreSQL 17.9 against a 328 MB / 200k-row fixture built to
the same bytes-per-row shape as the reported production table:

  0287:2  ADD COLUMN     0.5 ms   metadata-only, takes AccessExclusiveLock
  0287:11 edge backfill   21 ms   Nested Loop -> Index Scan on the PK
  0287:20 (deleted)       47 ms   Seq Scan, 200,000 rows removed, 0 matched
  0287:22 (deleted)       47 ms   Seq Scan, 200,000 rows removed, 0 matched

A concurrent primary-key SELECT started 50 ms into the transaction was
blocked 53-219 ms before and 22-25 ms after — the latter indistinguishable
from the 21-33 ms control with no migration running at all. The two deleted
statements accounted for 80,520 buffer accesses (~629 MB of in-lock I/O on
that fixture) and 81% of the transaction's work.

Editing an already-merged migration in place is safe here specifically
because drizzle writes `hash` but never reads it back: the skip test at
dialect.js:56-63 compares `created_at` against `folderMillis` only. The
edit is therefore a no-op on every database that already applied 0287
(staging, dev, branch DBs) and takes effect only where it has not run.
`meta/_journal.json` and the snapshot prevId chain are untouched.

The only casualty is a stale `data.errorEnabled` key on branch databases a
developer created it on. Nothing reads it: save.ts:50 and load.ts:89 both
read the `error_enabled` COLUMN, and load.ts passes `data` through
untouched.

Alternatives rejected, each checked against the code rather than assumed:

- An embedded `COMMIT;` to end the transaction early. 0289's four
  CREATE TYPE, its CREATE TABLE, and its three CREATE INDEX all lack
  IF NOT EXISTS, so a mid-batch failure in autocommit leaves them
  applied-but-unjournaled and the replay dies on 42710 — which
  migrate.ts:219 only retries for 55P03. That turns a transient stall
  into a wedged deploy.
- Moving the statements to a new file after 0289. All pending files share
  one transaction, so it buys exactly zero lock reduction.
- Rewriting the surviving backfill as
  `WHERE id IN (SELECT source_block_id FROM workflow_edges WHERE ...)`.
  Measured both: planner-equivalent. `source_handle` is unindexed, so both
  forms seq-scan `workflow_edges` (7.5 ms, identical) and then index-scan
  `workflow_blocks` on the primary key — it never scans that table. The
  IN form adds a HashAggregate and 1,170 more buffers, so it is marginally
  worse. Left as shipped.
- Promoting the `data-backfill` lint from warn to annotate. `readAnnotation`
  only requires a non-empty reason and 0287 already supplies one per
  statement, so the rule would fire zero findings.

0288's nullable `retry` column is correct as-is and unchanged. Both
delete-and-reinsert save paths on the deployed version (save.ts:30-63 and
the realtime REPLACE_STATE handler) reset `error_enabled` to false and
`retry` to NULL for a workflow saved by an old replica during the rollout;
the ordinary realtime block upsert does not, because its `set` clause
omits both columns. That residue is tolerable: everything the surviving
backfill writes is re-derived from the edge set at
workflow-block.tsx:754 and lib/workflows/persistence/utils.ts:196-201, and
`retry` ships in this same release so it has no installed base.

`bun run check:migrations origin/main` drops from three data-backfill
warnings to one. The real migrator applied all 289 journal entries to a
fresh PostgreSQL 17.9 database with the edited file, producing
`error_enabled boolean not null default false` and `retry jsonb`.
2026-08-12 01:32:00 -07:00
Waleed 068422b3ff refactor(audit): derive updatedFields through one shared helper (#6604)
* refactor(audit): derive updatedFields through one shared helper

Six copies of Object.keys(updateData).filter(k => k !== 'updatedAt') across
four files decided, independently, which columns an audit row reports. The
exclusion set is an audit convention, not a local detail, so it moves to
@sim/audit as auditUpdatedFields and the exclusion becomes a single edit.

The admin organizations route evaluated the expression twice in one handler
and filed it under the metadata key `fields` while every other site uses
`updatedFields`, so any consumer filtering on updatedFields silently missed
org updates. It now computes once and uses the shared key; nothing reads
metadata.fields.

auditMock carries the real implementation rather than a stub, since callers
under test derive their audit metadata through it. The two suites that
hand-roll an @sim/audit factory source it from there.

* test(audit): pin the testing mock's copy of auditUpdatedFields

@sim/audit devDepends on @sim/testing, so the mock cannot import the real
helper without closing a package cycle. Assert parity from the audit side
instead, where the dependency already runs the safe direction, so a change to
the exclusion convention cannot leave mocked callers validating behavior the
deployed helper no longer has.
2026-08-11 22:11:57 -07:00
Siddharth Ganesan 380aca2b61 fix(desktop): fix background tab spawning (#6590)
* Update tab spawning

* update import
2026-08-11 20:32:14 -07:00
Waleed 7c05e36049 fix(api): close five defects found auditing the v2 migration against main (#6575)
* fix(tables): stop a column retype from nulling empty-string cells

A type conversion rewrote every cell holding '' to null. Main only nulled a
blank the target type could not read; '' is a real stored value that both
string and json columns accept, so string->json and json->string silently
destroyed those cells.

Worse on a required target: countEmptyCells matches only a missing key, SQL
NULL, or '[]', so '' passes the required guard and the rewrite then wrote null
behind a constraint that had just succeeded.

The per-cell decision is now the pure retypeCellRewrite, restoring main's rule:
null a blank only when the target cannot read it, otherwise coerce.

* fix(execution): release the concurrency slot when a group cancel is refused

The stop-the-work effects (durable Redis abort record, queue-job cancel,
in-process abort) all fire before the workflow-group sidecar is consulted, and
none can be undone. When the sidecar refuses the claim we throw a conflict,
which skipped releaseExecutionSlot and stranded the plan concurrency
reservation until it expired.

Every conflict return is a terminal-or-absent state - a missing log row, a log
already completed or errored, or a terminal cell - so a refusal never means the
run is still executing. The slot is released before the throw, keeping the exact
success && !isPausedCancellationPath predicate rather than a blanket finally
that would free reservations for live runs.

* fix(uploads): recover an ambiguous PUT instead of discarding the object

Main recovered an upload whose bytes committed but whose response was lost, via
a verify endpoint. The session client retries the PUT instead, but every
provider now signs a create-only precondition, so the retry returns 409/412,
is classified non-retryable, and the session aborts - deleting the object that
had already landed. A transient blip on the final ack cost the whole upload.

A conflict on a retry attempt is now treated as our own earlier PUT having
committed, and completion proceeds. That is safe because completeUploadSession
independently verifies the object through assertObjectIdentity, which rejects on
uploadId mismatch before anything durable is registered. A first-attempt
conflict still fails loudly.

* fix(folders): enforce the workspace folder ceiling on the create path

Readers bound the active path index at MAX_FOLDERS_PER_WORKSPACE and throw once
a workspace exceeds it, but POST /api/folders reached createFolder, which has no
maxFolderRows field and never counts. A workspace could therefore be driven past
the ceiling, after which the 27 capped read sites failed on a state the product
had allowed.

createFolder now asserts room inside its transaction, right after the mutation
lock, so the count cannot be raced. The refusal is a typed conflict rendering
409 with an actionable message rather than a 500. The check counts rows directly
instead of loading the path index, so an already-over-cap workspace gets a clean
refusal rather than a read error, and no reader gained a cap.

folderMutationStatus also gained the payload_too_large mapping it was missing,
which had been rendering a delete-cascade cap breach as an unexplained 500.

* fix(skills): route internal skill writes through the shared use cases

The internal route made the workspace authorization decision itself, never
consulting the skills operation policy, never loading canonical workspace
context, and recording an audit entry with no operation id or actor projection.
v2 and Copilot already went through the use cases; only this surface did not.

GET/POST/DELETE now authenticate, parse, call the shared use case, and present.
Request and response shapes are unchanged. Two behavior changes fall out: a
write against a deleted workspace is now refused with 404 rather than accepted,
and permission-denial text matches the rest of the platform.

Legacy internal-JWT auth is dropped because no principal kind expresses that
caller and nothing calls it: the whole repo references /api/skills only in two
comments, no tool declares an internalRoute to it, and the executor reads skills
through a direct listSkills call rather than over HTTP.

* fix(folders): enforce the workspace ceiling on the remaining create paths

Folder duplication, admin workspace import, and workspace forking all inserted
folders without consulting the ceiling that 27 read sites enforce, so any of
them could leave a workspace whose reads then fail.

Each now asserts room for the rows it is about to add rather than one at a
time: duplication measures the whole subtree up front, forking counts its bulk
insert, and import counts per segment because that is genuinely one row.
assertFolderCollectionHasRoom gained an additionalRows notion for the bulk case,
and short-circuits when nothing is being added so an over-cap workspace still
reads and still syncs.

Duplication deliberately does not take the folder mutation lock. Holding it
across the copy would block folder creation workspace-wide for an unbounded
time - there is no cap on workflows per subtree and duplicateWorkflow runs
sequentially - and narrowing it is impossible because an advisory transaction
lock cannot be released early; splitting the transaction would leave a
half-copied tree on failure. A rare few-row overshoot near the ceiling is the
better trade, and it matches what forking already does. A test asserts the lock
is absent so re-adding it is a visible decision.

Admin import gained the transaction and lock it never had. Its folder-full
refusal escapes the per-workflow result list, because a full tree is a property
of the workspace and would otherwise be buried as N failures behind a 200.

The fork and promote routes had no catch at all, and withRouteHandler only
classifies HttpError, so a refusal rendered as an opaque 500 - twice over, since
drizzle wraps the throw. Both now project a classified conflict as 409 and
rethrow anything unclassified.

* fix(uploads): bound the signed PUT lifetime and advertise its real expiry

A single-PUT transfer was signed for the whole 24h upload-session TTL, because
expiresAt was reused as both the session lifetime and the signing lifetime.
Multipart part URLs in the same file kept 1h, and the pre-migration presigned
route signed every PUT for 1h, so the widening was unintended rather than a
policy change. No provider clamps below 24h.

The PUT presign is now clamped at the provider boundary by a shared
UPLOAD_URL_TTL_MS, which the part-URL path also uses so the two cannot drift.
An expired PUT URL is deliberately not recoverable: unlike multipart, which
re-signs per part call because its progress is durable, a PUT is not resumable,
so an expired URL and an interrupted PUT have identical recovery. Nothing leaks,
since every provider signs a create-only precondition.

Clamping alone would have made the contract lie: the URL would die an hour
before the session's advertised expiresAt, with nothing telling an integrator
why the 403 happened. The PUT transfer now carries its own expiresAt, mirroring
the multipart part-URL field. It is provider-dependent on purpose - cloud
transfers report the clamped signature expiry, while the local data plane has no
signature and admits against the session, so reporting an hour there would have
been a new inaccuracy in the other direction.

* chore: delete the dead presigned-upload and skill-adapter paths

The presigned upload routes and the internal skills adapters were both replaced
during the v2 migration, leaving their implementations behind with no callers.

Removed generatePresignedUploadUrl and verifyPresignedUploadReceipt with their
three provider helpers, QUOTA_EXEMPT_STORAGE_CONTEXTS and the types it orphaned,
and the performCreateSkill/performUpdateSkill/performDeleteSkill adapters with
recordSkillEvent and statusForSkillOrchestrationError. Each was verified
unreachable across apps, packages, scripts and ee, including barrel re-exports
and string access, not just direct imports.

recordSkillEvent needed the closest look, since deleting an audit writer can
silently drop coverage. The use cases declare the same action, resource, and
description, and the framework adds the operation and actor the old helper
lacked; recordAudit back-fills actorName and actorEmail from the user table
when both are omitted, so the one field the helper passed is not lost.

The self-hosting architecture doc described a directUploadSupported flag on an
endpoint that no longer exists, and now describes the upload-session flow that
replaced it.

* refactor(folders): keep the cheap resource facts out of the schema graph

Reading a folder resource type's label or its lock support meant importing
folderResourceConfig, which imports the db schema for every table it serves and
from there reaches lib/table/service, the executor, and the tool registry.

That mattered as soon as lib/folders/queries needed a label: queries is reached
from workspace-file-manager, which is reached from the files and chat pages, so
one import edge put roughly 4,700 modules into those page graphs and broke the
tool-registry boundary audit.

Labels and lock support now live in a leaf module that imports only a type, and
config composes them so there is still one source of truth. The three folder
routes that pulled the whole config in for a single boolean read the leaf
instead.

* fix(skills): apply an upsert batch in one transaction

The internal skills route looped the batch, calling an independently committing
use case per item. A rejection on a later item left the earlier ones written and
audited while the request reported failure - the compound-mutation rule in
CLAUDE.md exists for exactly this.

No new transaction plumbing was needed: upsertSkills already wraps its whole
item loop in one db.transaction, so the partial commit came from calling it N
times rather than once. upsertSkillBatch now validates and per-skill authorizes
every item before issuing a single write, and createSkill and updateSkill became
thin wrappers over it so v2 and Copilot keep one authority for the rules.

The compound operation declares the read floor that skills.update already used,
and the use case additionally authorizes skills.create when any item lacks an id,
still ahead of every write. A read-only member who is a skill editor keeps their
edit, and creates are not authorized more loosely than before.

Audit projects one entry per committed skill, and analytics moved after the
commit so nothing is reported for a rolled-back item. Note metadata.operation
for these writes is now skills.upsert rather than skills.create/update; the
action field still carries the distinction.

* fix(security): close two disclosure gaps and finish the slot-leak fix

The payer-pool gate only covered the workspace branch. A personal API key that
omits workspaceId takes the account branch, where getHighestPrioritySubscription
resolves an organization subscription from any member row regardless of role -
so a plain member read the organization-wide credit and storage pool by dropping
one query parameter. The account branch is now gated by the same authority, and
the storage pool is not queried when it may not be disclosed. Forcing that
branch self-scoped instead would have downgraded plan, period, and status, which
is what a member needs to see whether the org is blocked.

GET /api/v1/logs/executions/[executionId] emitted the workflow snapshot raw,
carrying password sub-block values and oauth-input credential ids. It now shares
the sanitizer the v2 read already used, extracted so there is one implementation
rather than two. Env-var references are still preserved.

cancelWorkflowGroupExecution itself was unguarded, so an unexpected throw from
its transaction escaped ahead of every release site - the same reservation leak
this branch set out to close, still open on the adjacent path. It now releases
through the shared predicate and rethrows, because a failed transition means the
cell state is unknown and a success-shaped answer would be a lie. The comment
claiming the abort record cannot be taken back was false and now states the real
reason: a refusal is always a terminal-or-absent state.

* fix(v2-api): cover every persisted run status and every capped body

The workflow-runs endpoints carried the same omission the logs contract had:
the execution logger persists redacting, the run schemas did not list it, and
because validation is whole-response one such row returned 500 for an entire
page. Both schemas now derive from PersistedWorkflowExecutionStatus behind the
same AssertNever gate, so a future status is a type error rather than a
production 500. The single-run read keeps its extra queued value, which only it
can observe.

That schema was also serving as the run-list status filter. Widening it would
have accepted a filter value the application input cannot express, so the
reported set and the accepted filter are now separate schemas.

Routes declaring maxBodyBytes without payloadTooLargeResponse fell back to a
bare string with no error code and no private cache header. Rather than patch
the four, the default moved into the builders - all three had the hole - which
covers 58 body-bearing handlers, and a route override still wins. The five
per-route overrides that merely restated the default are gone.

Also documents the 413 on the one knowledge route that has a real body cap, adds
the rollout gate's 404 to the last v2 operation missing it, and rewords the
nextCursor description, which read as though every list were a full-set list.

* fix(api): make the shared traits and lifetimes single-sourced

The folder resource-traits leaf composed labels into config but restated lock
support independently, so the routes reading the trait and the orchestration
reading the config could disagree about which resources lock. Config now
composes both, and supportsLocking is required rather than optional so a new
resource type cannot silently omit it.

The upload commit claimed the PUT clamp and the multipart part URLs shared a
constant and could not drift. That was true only of the advertised expiry - the
three provider signers each hardcoded an hour, so changing the constant would
have moved what we advertise while leaving what we sign, recreating exactly the
mismatch the clamp removed. Each provider now receives the lifetime in its own
unit from the one constant.

Table restore hand-rolled its status map and returned the driver message
verbatim at 500, leaking the failed statement and its bound parameters - the
same defect this branch closed at nine other sites. It and import-csv now use
the shared projection; import-csv's result type also had to carry the lock the
classifier already set, so a 423 can name it.

Deletes v2RowWriteError, which had no callers and would have rendered a locked
table as 400 by discarding the 423 it was handed.
2026-08-11 19:37:40 -07:00
Siddharth Ganesan 2a5e29ad3f improvement(updates): validate update path and fix native mac help (#6580)
* Desktop update check

* Update script
2026-08-11 19:16:48 -07:00
Siddharth Ganesan e7590c828c improvement(desktop): help menu 2026-08-11 18:06:40 -07:00
Theodore Li 092311ea68 fix(api): restore migrated endpoint and SDK compatibility (#6564)
* fix(api): restore migrated endpoint compatibility

* fix(api): close remaining migration regressions

* fix(files): validate ensured folder paths

* fix(files): project folder path validation

* fix(files): align archive regression fixture

* fix(tables): avoid partial bulk update failures

* fix(auth): project legacy knowledge audits
2026-08-11 20:36:37 -04:00
Siddharth Ganesan cb2809001e fix(mship, desktop): fix bugs (#6572)
* fix bugs

* fix tests

* fix(tests): stabilize archive and client boundary coverage
2026-08-11 17:16:52 -07:00
Siddharth Ganesan 1dd85eb688 improvement(desktop): add apple signing, fix some bugs (#6519)
* 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
2026-08-11 14:53:16 -07:00
263e3ca67e improvement(external-endpoints): v2 versions with clean signatures + 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>
2026-08-11 05:45:25 -04:00
Waleed 2f43148d60 improvement(ui): align terminal with the workflow design system, fix 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.
2026-08-11 00:28:08 -07:00
Vikhyath MondretiandClaude Opus 5 507def6685 fix(workflows): port the canvas card icons off lucide-react (#6523)
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>
2026-08-10 19:49:44 -07:00
245ad1bd46 feat(workflows): new workflow block card, progress indicator, colors, 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 066e18ac28. 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 7934df7f88 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 5ece9f9e7e. 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>
2026-08-10 19:21:32 -07:00
Bill LeoutsakosandBill Leoutsakos fb6e18f5db refactor(tiktok): align webhook routing with shared dispatcher (#6261)
* refactor(tiktok): route webhooks by account key

* refactor(tiktok): remove custom webhook ingress jobs

* fix(tiktok): constrain webhook routing migration

* test(tiktok): remove redundant assertions

* fix(tiktok): preserve routing backfill state

* fix(tiktok): preserve legacy webhook routing during rollout

* fix(tiktok): preserve webhook delivery guarantees

* refactor(tiktok): remove rollout compatibility

* refactor(tiktok): remove unused webhook backfill

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
2026-08-08 15:33:33 -07:00
Waleed 9883543eb2 fix(knowledge): stop listing workspace knowledge bases on stale creator identity (#6454)
GET /api/knowledge without a workspaceId ORed on knowledge_base.user_id with no
permission check, so a user removed from a workspace kept seeing metadata for every
KB they created there. Scope the creator fallback to legacy KBs with no workspaceId,
matching the workspace-filtered branch and the detail path.
2026-08-08 15:08:09 -07:00
Waleed 107748d356 improvement(ui): consolidate the disclosure chevron and order resource menus like the sidebar (#6447)
* improvement(ui): consolidate the disclosure chevron and order resource menus like the sidebar

#6400 aligned the workflow-tree folder chevron to the sidebar section header
(16px/100ms -> 14px/150ms) but left the literal hand-copied in three files, so the
next edit could drift them apart again. Extracts `disclosureChevronClass` into
chip-chrome as the single source and points the section header and both sidebar
trees at it. Adds `chipIconSlotClass` for the 16px slot that ChipChevronDown,
ChipSelect, ChipDropdown and ChipDatePicker each re-derived.

Both Back buttons rendered a ChevronDown rotated 90deg at 10px — wrong glyph, and
4px under every other chevron in the app; they are now ChevronLeft at 14px in that
shared slot. The sidebar file tree drew hand-inlined SVGs at strokeWidth 2 next to
the workflow tree's emcn icons at 1.55, so the two trees showed different folder
glyphs; both now use the emcn set. Its file rows also indented by the chevron's
width alone, landing their icon 4px left of a folder's and 2px smaller despite the
comment claiming they lined up — they now clear the chevron plus the row gap at
the same 16px size.

Separately, the chat `+`/`@` resource menus rendered every foldered family as a
submenu and only then the flat ones, which pinned Workflows to the top regardless
of any ordering. Adds `RESOURCE_MENU_ORDER` mirroring the sidebar top-down and
merges both kinds into one ordered pass, so the menus read Integrations, Chats,
Tables, Files, Knowledge Bases, Logs, Workflows, then Browser and Terminal on
desktop. Documents the general rule in .claude/rules/sim-list-ordering.md.

* improvement(ui): order the remaining resource menus the way the user reads them

An audit of every menu, tab strip, and context menu against the surface the user
already reads those items on turned up six more divergences.

The table row context menu put View execution before the Run/Re-run/Stop cluster
while the action bar puts it after, so the same four actions read in two orders;
the menu now follows the bar. The Files, Tables, and Knowledge empty-space create
menus were each the exact reverse of their own page header, and each named the
same action differently ("Create table" vs "New table", "Add knowledge base" vs
"New base") — reversed to match, labels unified.

Recently Deleted led with Workflows and buried Chats last, and put Knowledge Bases
ahead of Files; it now follows the sidebar after the default All tab. The command
palette hoisted Workflows from last to second for the same reason, and is now in
sidebar order too. Chats stays first in both, so the palette's Enter default is
unchanged.

Drops the unused RESOURCE_TYPES export: nothing consumed it, and its declaration
order was nearly the inverse of RESOURCE_MENU_ORDER, so the first consumer to map
it would have silently rendered a backwards menu.

Not included: buildUnifiedSettingsNavigation never applies its own unified.order
and returns registry declaration order instead. Nothing user-visible today (the
sidebar re-sorts before rendering) but navigation.test.ts pins the unsorted order
as canonical, so fixing it means changing that test — worth doing deliberately
rather than inside this PR.
2026-08-08 14:12:59 -07:00
Waleed 9cd2e7f5fd fix(cli): validate each install secret against its own requirement (#6440)
Every secret was held to the 64-hex AES form, but only the encryption keys
need it — BETTER_AUTH_SECRET and INTERNAL_API_SECRET are HMAC secrets that
just need 32 characters. An operator-chosen passphrase was silently replaced
on the next run, invalidating every sign-in.

Reject the repository's published example values at any length: they are
longer than the 32-character minimum and would otherwise pass as real.

Back the file up and say so when replacing a value that was already there,
rather than reporting it as a routine generation. Generate and pass
API_ENCRYPTION_KEY, without which the app stores workspace API keys in plain
text; values predating it lack the encrypted shape and are still read as-is.

Resolve secrets before any container starts, so a filesystem problem reports
a usable message instead of leaving a half-started stack behind.
2026-08-08 13:59:24 -07:00
Waleed 33fe043b6b fix(cli): generate per-install secrets instead of using fixed values (#6433)
* fix(cli): generate per-install secrets instead of using fixed values

The launcher passed the same built-in BETTER_AUTH_SECRET and ENCRYPTION_KEY
to every install. Generate them once per install, persist them 0600 at
~/.simstudio/secrets.env, and reuse them on later runs so data already in
the Postgres volume stays readable.

Also passes INTERNAL_API_SECRET, which the realtime container requires and
never received.

* fix(cli): reassert owner-only permissions on the secrets file

writeFileSync's `mode` applies only when it creates the file, and the write
is skipped entirely when the stored values are already valid — so a secrets
file left with permissive permissions kept them. chmod it on every run.

* fix(cli): write the secrets file atomically

Regenerating any one key rewrites all of them, and a plain write truncates
first — a crash mid-rewrite would strand a still-valid ENCRYPTION_KEY and
orphan the data it protects. Write to a temp file and rename into place.
2026-08-08 13:22:30 -07:00
Waleed ea5df41a57 fix(webhooks): resolve the AgentMail tenant before verifying the signature (#6431)
* fix(webhooks): resolve the AgentMail tenant before verifying the signature

The receiver identified the delivering workspace by trying every stored
webhook secret in turn, so each request cost one HMAC over the full body
per tenant. Resolve the workspace from the envelope's inbox id first and
check a single secret, and make that id uniquely indexed so the one-check
property holds by construction.

Also gates the route through the shared admission gate, like the other
public receivers, and gives it a body cap sized for an email envelope.

* fix(db): declare the inbox provider index partial in the schema too

The migration built a partial unique index but the Drizzle definition and
snapshot omitted the predicate, so a regenerate would have diverged. Also
records at the registration site why only message-bearing event types can
be subscribed.

* fix(db): drop the duplicate 0285 journal entry

The regenerate that added the partial-index predicate appended a second
entry for the same migration, so drizzle would have replayed it — dropping
and rebuilding the index, leaving a window with no unique constraint.
2026-08-08 12:59:45 -07:00
Waleed 4f5ad2011f fix(tables): keep row context menu labels on one line (#6418)
* fix(tables): keep row context menu labels on one line

* improvement(emcn): ellipsize menu row labels instead of clipping them
2026-08-08 11:55:17 -07:00
Waleed 08af7db910 improvement(ui): unbold the app, align the folder chevron, tidy the feedback modal (#6400)
#6241 deleted the --font-weight-* scale from globals.css and its fontWeight
mapping from tailwind.config.ts. font-medium jumped 440/480 -> 500,
font-semibold 500/550 -> 600, and body dropped 420 -> 400, so every existing
call site snapped a full step above a body that got lighter. #6291 fixed
packages/emcn only; the product call sites were left behind.

Strips the weight class from body, label, row, and heading text across
app/workspace, ee, workflow-renderer, the non-workspace route groups, and
components/ui/button.tsx, whose buttonVariants injected font-medium into every
consumer. Keeps it only where it steps up: markdown/prose bold and micro
avatar initials.

Also aligns the workflow-tree folder chevron to the sidebar section header
(14px, 150ms), and on the feedback modal drops the prompt line above the
Feedback field and re-homes Copy ID as a footer secondary action.
2026-08-07 20:12:06 -07:00