Commit Graph
29 Commits
Author SHA1 Message Date
Theodore Li b724cd152d improvement(cli): clarify npm package readmes (#7121) 2026-08-26 13:11:46 -07:00
Theodore Li 437ffa3190 fix(cli): exit after interactive secret input (#7118) 2026-08-26 15:43:53 -04:00
Theodore Li d86fdc152f feat(api): publish agent tool input schema (#7109)
* feat(api): publish agent tool input schema

* fix(api): bound agent tool inputs
2026-08-26 14:37:50 -04:00
Waleed 3645857ddb fix(v2): stop six responses reporting less than the layer beneath them knew (#7097)
* fix(v2): stop six responses reporting less than the layer beneath them knew

Findings from cubic's review of the v0.8.12 release PR, all on the v2 surface.

- `GET /workflows/{id}/runs/{runId}` documented `includeFileBase64` as
  requiring `includeOutput`, and the read honours that — files are projected
  inside the `includeOutput` branch alone. Nothing enforced it, so the flag
  parsed, was accepted, and was then dropped: a 200 carrying no files and no
  reason why. Now a 400 naming the missing flag, matching how `GET
  /billing/logs` refuses a window bound its period will not read.
- `POST /files/{id}/unzip` rendered a malformed or over-cap archive as 500.
  `ArchiveError` had no arm in the v2 policy, though the internal extract route
  beside it has mapped the same failures to 400/413 all along.
- Both workflow-MCP lists cut their tool inventory at a ceiling and published
  `nextCursor: null` regardless. The use cases were already returning
  `truncated`; only the v2 presenters dropped it, while the copilot handler
  published it. A reconciling caller read a partial set as the complete one.
- A table dispatch scoped by filter reported neither the filter nor its
  exclusions, and `rowIds: undefined` documented itself as "every eligible
  row" — so a filtered run and an unfiltered one were indistinguishable. The
  compiled filter stays unpublished, as before; `selectAll` and
  `excludeRowIds` name the distinction.
- `GET /files/uploads/{id}` promised the registered file after finalization and
  passed null unconditionally, so a caller polling a transfer it lost track of
  could watch a session reach `completed` and never learn what it created.
- `HEAD` on a run file resolved the file downstream of authorization, so it
  answered 200 for an id the `GET` beside it would 404.
- v2 bulk table delete audited `FOLDER_DELETED.resourceId` from the
  caller-keyed projection, writing a display path where the single-folder
  delete writes the canonical id.

Two further findings needed no change: the audit-log cursor scope is bound by
`member_user_id_unique` and a membership check, so the cross-organization
replay it described cannot arise; and both documentation findings had already
been fixed on staging by #7092.

* fix(v2): report a dispatch's two narrowings separately

Review follow-up. `selectAll` was set from the stored filter alone, but the
run rejects only `rowIds` *with* `excludeRowIds` — so an exclusion set with no
filter is a scope a caller can create, and the walk applies it. Those
dispatches published exclusions with no discriminator beside them, which is
the shape the flag existed to rule out.

One flag could not cover both: an exclusion-only scope is neither filtered nor
unnarrowed. So the two narrowings are reported as what they are — `filtered`
for the unpublished stored predicate, `excludeRowIds` for the deselections —
and every combination is now distinguishable from a run over every eligible
row. `excludeRowIds` mirrors the walk's own condition and is withheld beside
`rowIds`, where the dispatcher would ignore it.

* fix(v2): let a failed file read fail, and point truncation at its own check

Two review follow-ups.

`getWorkspaceFile` logs and returns null on a read failure unless told
otherwise, so a transient database error would have reported a finalized
upload as fileless — to the one caller polling to learn what it created, who
would then stop, having been told there was nothing. Read with `throwOnError`
so the failure surfaces and the poll can retry, matching how the sibling
record read loads the same row. A file genuinely deleted still answers null,
which is a different question with a different answer.

The server list pointed callers at `/tools` for an authoritative inventory
without saying that endpoint applies the same ceiling. It now names the
`truncated` flag this PR added there, so "authoritative" has a condition
attached instead of being asserted.
2026-08-25 21:02:59 -07:00
Waleed 2d85c0d1e1 fix(cli): correct three descriptions the CLI publishes, and restore --no-recursive (#7093)
Follow-ups from review of the v0.8.12 release PR, all on surfaces the CLI
audit touched.

- `credentialId` was one shared schema across PATCH and DELETE, so the
  disconnect reference offered "update or disconnect" for an operation that
  cannot update. Split into two, matching the two components OpenAPI already
  publishes for them.
- A boolean query param documents the spellings an HTTP caller may send and
  closes by calling them the whole accepted set. The CLI renders those fields
  as bare flags that take no value, leaving the sentence pointing at a list
  neither `--help` nor the reference ever prints. Stripped for bare flags only;
  the REST prose and OpenAPI specs are unchanged.
- `files list --recursive` became a bare switch in the audit, which removed the
  only way to send false. The API turns it on by itself as soon as `--search`
  is set, so a folder search always descended. The twin is back, declared per
  flag so the one-way toggles do not grow a meaningless negation.
2026-08-25 19:28:06 -07:00
Waleed 4508ec75d2 fix(cli): resolve blockers and majors from a full command-surface audit (#7083)
* fix(cli): resolve blockers and majors from a full command-surface audit

Audit of all 222 CLI leaves against a live deployment, plus fixes for every
defect it confirmed.

Blockers:
- An unrecognized --profile resolved to built-in defaults, so a typo silently
  targeted production and transmitted the API key there.
- sim logs follow sent an undeclared query key and failed on every invocation.
- sim workflows run exited 0 on a failed run, so CI reported success.
- knowledge connectors documents update matched rows already in the target
  state, making exclude and restore permanent no-ops.
- PDF text layers below the OCR threshold are transcribed by a model and stored
  verbatim with no record that it happened.

Majors include: rollback --version was swallowed by the program-level flag and
silently did nothing; nullable string flags could not send null despite their
help promising it; six protocol commands discarded excess arguments, dropping
files on upload; sim chat crashed with EPIPE when piped to head; tables import
dropped malformed CSV rows without reporting them; audit-logs required an
organization id no API surface exposed; secrets could not opt out of redaction
or read a value from a file; bulk deletes and moves exited 0 having done
nothing; and MCP registrations were destroyed by undeploy rather than restored.

Adds extraction_method to documents so OCR output is distinguishable from
parsed text, and reports the applied scope on billing logs so the two ledger
questions are no longer indistinguishable.

* fix(mcp): bound MCP restore by server and re-check uniqueness under the lock

Two gaps in the archive/restore lifecycle this branch introduced.

The candidate query bounded archived rows and deduplicated to one per server
afterwards, so several archived generations stacked on one server consumed the
whole budget and every other server the workflow had been published on fell out
of the result with no warning. Deduplication moves into SQL so the bound applies
to servers, preserving most-recently-updated-per-server.

The live-registration check ran before the server lock was acquired, so a
concurrent tool create could land in between and the restore would un-archive a
second live row for the same server and workflow, violating the partial unique
index and rolling back the whole deployment. That check now runs under the lock
alongside the tool-name, capacity, and metadata-budget checks it belongs with.

* fix(review): address round-three review findings across CLI and server

Restore now picks candidate servers by recency: DISTINCT ON forces its own key
to lead the sort, so bounding on that statement kept the lexicographically
lowest server ids and left a workflow's most recently used servers archived.
Deduplication and bounding are now separate stages.

CLI: a total miss on tables move reported only in notFound exited 0; unsetting
a key or removing a profile mutated the first duplicate INI block while reads
merged later ones, so the removal appeared to succeed and did nothing; an
import that rejected cells but no rows showed a clean progress line; and the
--run-id help implied idempotency it does not provide.

Server: a run with no recorded output projection let block-name selectors past
the new validation; the billing window comparison still fired on a bound that
parsed but failed the shared schema; CSV rejection accounting reached only the
streaming path, so buffered and synchronous imports still dropped records
silently, through to the Copilot tool that reports them; case-insensitive tag
name uniqueness now serializes on the knowledge-base row the delete paths
already lock; and a failed sync claim reports the lifecycle reason rather than
always claiming a sync is in progress.

Reverts an over-scrub from the previous commit: workspace-file-imports is
consumed only by Copilot, so naming save_upload and glob there is the correct
remediation rather than a leak, and the sweep that guards against leaks now
exempts it explicitly.

Corrects two contract descriptions that promised a bulk tag save would rename
or relocate an occupied slot, which it deliberately no longer does.

* fix(cli): remove flags a caller cannot use, and correct three that misled

Removes surface that should not have shipped:

- `files uploads get` is hidden. Its `--upload-token` was required, and the
  token is minted and consumed inside a single `files upload`, which completes
  or aborts its session before returning. Nothing in the CLI could produce the
  value, so the command answered every invocation by asking for something
  unobtainable. The same flag is dropped from the two table-import commands,
  where a CLI-created import is already queryable without it.
- The `--no-<flag>` companion that sent JSON null is gone. `--no-X` means "send
  boolean false" on thirty-seven other flags, and one spelling should not carry
  two meanings. `--description ''` already clears the displayed value, and the
  help now warns that the literal word null is stored as text rather than
  suggesting a substitute, because on the OAuth client fields null revokes a
  stored grant and an empty string does not.
- The document extraction method column and its contract field are reverted.
  Nothing read them, they were null for every existing document, and the name
  collided with the parser metadata field that already exists.

Corrects flags that misled: the workflow move destination is `--to`, matching
its two siblings rather than meaning the opposite of `--folder` one command
over; `files list --recursive` is a bare flag like the four folder deletes
rather than a twelve-alias string; the dispatch row cap takes a count instead
of its wire object; `--yes` no longer claims to be required on commands that
accept `--dry-run`; cancelling every run on a table is confirm-gated; and the
retry-processing negation, which the route rejects, is suppressed.

Extends the guard that missed all of this: it swept only `--x-` prefixes over
generated commands, so a header spelled without one was invisible to it. It now
derives every header name from the operation table and sweeps the assembled
program.

* fix(mcp): budget MCP restore against the workflow's live server fanout

Restore bounded its candidates at the per-workflow server limit counting
archived rows only, never subtracting the memberships the workflow already
holds live. The fanout validation a few lines later in the same deploy
transaction counts live servers against that same limit, so a workflow with
both live and archived registrations could restore past it and roll the whole
deployment back.

The candidate query now bounds on the remaining headroom, and the budget is
re-checked under the server locks and spent once per accepted candidate, so a
create that lands between the count and the unarchive cannot push it over.
Candidates that do not fit are dropped by recency, matching how the set is
already selected, and stay archived with a warning naming the workflow, the
server, and the reason — restore still never throws inside the deploy
transaction.

One residual is left open deliberately: a create on a server outside the
candidate set is serialized by neither the locks nor the recount. Closing it
would need a workflow-level lock, which would change the ordering every other
writer here depends on, and the create path runs its own limit check.

* fix(mcp): stop restore spending its budget on servers it cannot restore

A server can hold both a live registration and archived ones for the same
workflow: the partial unique index constrains only the live row. Such a server
was counted twice — once shrinking the restore budget, once consuming one of
its slots — before the liveness check under the lock skipped it. While the
bound was the full server limit that waste was invisible; once the bound became
the remaining headroom, every slot spent that way cost a registration that
could have been restored.

The candidate query now excludes servers the workflow is already live on, so
the budget and the candidate set agree. The exclusion sits on the inner stage,
before deduplication and the bound, and is a pre-lock optimisation only: the
check under the server lock stays authoritative, because the query can go stale
between reading and unarchiving.

Candidates rejected for a tool-name collision, the per-server cap, or the
metadata budget are still not replaced. That case is only knowable under the
lock, so replacing it would mean fetching past the bound and locking servers
outside the candidate set, widening an ordering every writer here relies on.
2026-08-25 18:43:48 -07:00
Waleed 0c38037bf1 fix(knowledge): harden ingestion pipeline (#7077)
* fix(knowledge): harden ingestion pipeline

* fix(knowledge): correct bounded ingestion edge cases

* fix(knowledge): tighten redaction and chunk validation

* fix(knowledge): close bounded ingestion gaps

* fix(files): preserve parser complexity limits
2026-08-25 17:55:56 -07:00
Waleed 32a15fde2f improvement(access-control): wire tool and model permissions into copilot editing (#7080)
* improvement(access-control): wire tool and model permissions into copilot editing

Permission groups already supported a per-tool denylist (deniedTools) and model
restrictions, but only the canvas honored them. The copilot edit path gated on
block type alone, so Sim could build workflows using tools and models the user
was not allowed to run — the executor refused them at run time instead.

Enforce both at authoring time, and stop advertising what the viewer cannot use.

* fix(access-control): use the path alias for the permission-groups type import

* fix(access-control): close two denied-tool leaks in copilot discovery

The VFS stamped every integration schema from the shared static map before the
per-viewer loop re-authored the permitted subset, so a denied operation's schema
stayed published. Skip the shared copy for integration paths; the viewer loop is
the only projection that knows the denylist.

Block metadata resolved denied operations from the catalog's `operation.toolId`,
which the projection fills only from `tools.config.tool` — a block whose
operation ids are its tool ids left it undefined and read as fully permitted.
Resolve through the shared operation gate instead.

* fix(access-control): key the copilot schema cache on permission policy

The deferred integration-tool schemas now depend on the viewer's permission
group, but the cache key encoded only identity and block visibility, so an
admin's change to deniedTools took effect only when the entry expired.

Resolve the config before the key and add a gate signature alongside the
existing visibility signature, mirroring how block visibility already keys the
same cache. The read moves out of the cached section rather than being added:
what the entry caches is a user-tool schema per exposed integration tool, which
dominates it.
2026-08-25 15:41:09 -07:00
WaleedandTheodore Li 656840a1b1 feat(api): make the platform operable headless over v2 (#6912)
* feat(v2): download run output files by API key

Adds GET /api/v2/workflows/{id}/runs/{runId}/files/{fileId}, closing the
async-run loop for headless callers. A run's output carries UserFile URLs
pointing at /api/files/serve/..., which rejects x-api-key outright, so an
async run that produces a file previously had no byte path out for an API
key at all.

The file is addressed by the id the run reported and resolved against the
run's own recorded execution data, from which the storage key is read. The
request never supplies a storage key, so the endpoint cannot be aimed at
bytes the run did not produce. Resolution deliberately reads the
materialized-but-undisplayed recording, because the display projection
strips exactly the `key`/`context` fields a byte read needs.

Also hardens normalizeStartFile to derive a file's storage key only from a
validated internal serve URL, discarding any caller-supplied `key`/`context`.
A workspace API key has no human subject, so the executor resolves its actor
to the workspace billing owner (preprocessing.ts -> resolveSystemBillingAttribution);
verifyFileAccess then authorizes a workspace-context key as that owner, whose
reach is not bounded by the key's workspace. Accepting an attacker-authorable
key made that substitution exploitable as a confused deputy. Normalization is
all-or-nothing, so a forged file now drops the whole files input.

* feat(workflows): one graph-write door, principal-derived audit source, and v2 authoring endpoints

Extract replaceWorkflowNormalizedState as the single persistence primitive for a
workflow graph replace and route both the internal editor save and the Copilot
edit tool through it, so neither can skip state preparation, the row lock, the
lastSynced stamp, or custom-tool extraction by choosing a different entry point.

Derive the audit source from the acting principal instead of hardcoding
'copilot', then widen workflows.variables.apply_operations and
workflows.bulk.move to every principal kind.

Add GET/PUT /api/v2/workflows/{id}/state, POST /operations, /duplicate,
/restore, PATCH /variables, and POST /api/v2/workflows/move over surface-neutral
application use cases; move the edit engine to lib/workflows/editing.

* test(workflows): cover the graph-write primitive, audit source, and the v2 authoring surface

Pin the two-doors fix (preparation runs, the row is locked, custom-tool
extraction is post-commit and best-effort) and the false-audit fix (a session
principal writes source: 'session', a delegated one writes its service). Both
were verified to fail with the fix reverted.

Add the application matrix for replaceWorkflowState, applyWorkflowOperations,
readWorkflowGraph, and restoreWorkflow — role floor, principal-kind rejection
before canonical load, asserted-scope concealment, lock, validation, atomic
conflict, plan gate, and audit-then-notify ordering — plus route tests for
every new endpoint.

* test(workflows): pin the internal graph-write door and the v2 list scope

Characterize saveWorkflowNormalizedState's statuses, messages, and notification
after the persistence extraction, and cover the new scope filter on
GET /api/v2/workflows including a cursor replayed under a different scope.

* feat(api): add v2 block, tool, connector-type, and enrichment catalogs

Adds six read endpoints under /api/v2 that publish Sim's code-defined
catalogs: GET /blocks, GET /blocks/{blockId}, GET /tools,
GET /tools/{toolId}, GET /connector-types, and GET /enrichments.

These read like static reference data and are not. What a caller may
place is decided per workspace by its permission-group integration
allowlist, per organization by which unreleased blocks have been
revealed, per deployment by ALLOWED_INTEGRATIONS, and per workspace
again by the workflows it has deployed as blocks. So all six are plain
defineWorkspaceOperation reads at minimumRole 'read' with
workspaceApiKey 'allow' — the exact policy of credentials.providers.list
— and every response keeps Cache-Control: private, no-store, because an
unrevealed preview block's existence must not leak across organizations
through a shared cache.

Trigger blocks ride as ?capability=trigger rather than a second
endpoint, and workspace custom blocks ride inside /blocks discriminated
by `source`, so "what may I place?" stays a one-call question.

The block projection is extracted out of the Copilot get_blocks_metadata
tool and rewritten onto @/tools/metadata and @/tools/metadata-outputs.
That cuts the tool's own @/tools/registry edge as a side effect: its
module graph drops from 6,756 to 1,318, and the new routes land at
1,673-1,734, next to the shipped /v2/credentials/providers baseline of
1,668.

Supporting changes:

- scripts/sync-tool-metadata.ts derives hostedApiKey ('always' |
  'conditional' | 'none') from each tool's `hosting`. The config itself
  stays excluded because it holds closures, but "does Sim host the key"
  is a first-order authoring question, so the answer is emitted.
- getCopilotToolDescription takes hostedApiKey as an option instead of
  reading `hosting` off the tool, so both an executable ToolConfig and
  the generated metadata can answer it through one shared derivation.
- principalUserId / allowedIntegrationTypes move out of
  lib/credentials/application/provider-catalog.ts into
  lib/integrations/principal-scope.server.ts. Two copies of the
  workspace integration gate would diverge first on the workspace-key
  path, which has no user for permission groups to key on.
- scripts/check-tool-registry-boundary.ts walked page.tsx/layout.tsx
  under app/workspace only, so a route importing the executable registry
  passed green. It now walks a list of entry sources, seeded with the
  four catalog route subtrees and the shared projection barrel. Routes
  are covered per subtree rather than wholesale because 122 of ~1,130
  route files legitimately execute tools.

Registry sweeps parse every block, tool, connector type, and enrichment
through its published response schema and compare against the wire
round-trip. They caught a real drift while being written: an operation's
inputs were typed as a union of the tool-param and block-input shapes,
and the union resolved to whichever member matched first, silently
dropping a block input's `schema`.

* docs(api): stop publishing a 413 GET /workflows/{id}/state cannot emit

* feat(v2): read upload-session state

Adds GET /api/v2/files/uploads/{uploadId}. Only DELETE was exported, so a
caller that lost track of a transfer could abort it but could not ask
whether the session was still alive, already finalized, or failed — the
resume story was missing.

Runs on a new files.upload.read operation at minimumRole 'read' rather than
reusing uploadCancel, which is a 'write': asking about a session must not
require permission to destroy it. The GET is a control leg like every other,
so it carries the signed upload token and re-authorizes the caller's present
workspace permission through reauthorizeWorkspaceUploadPurpose instead of
resolving the session on its id alone.

* fix(api): reconcile v2 catalog and workflow-authoring integration

Merging the catalog and workflow-authoring branches surfaced four issues
that neither produced in isolation.

- Route and OpenAPI counters were bumped to the same value on both
  branches, so git merged them as one change while the merged tree holds
  the sum. Corrects the route ratchet to 1142 and the workflows document
  to 29 operations (152 total), then regenerates the OpenAPI documents
  and the CLI surface from the reconciled contracts.
- The seven new workflow operations were published in the spec but absent
  from the workflow API reference groups, which `check:openapi` rejects.
- `route-policies.ts` reached `WorkflowOperationsNotAppliedError` through
  `apply-workflow-operations`, dragging the edit engine — and its diff and
  comparison dependencies, which reach a client OAuth hook — into every
  route that uses the shared workflow error policies. The class moves to
  its own leaf module, mirroring `WorkflowImportError`, and each importer
  now takes it from there.
- The operations route test shadowed that class inside its module mock, so
  `instanceof` matched a fake and the assertion pinned a message the
  production class never emits. It now uses the real class and asserts the
  real message.

* feat(v2): extract ZIP archives over the public API

Adds POST /api/v2/files/{fileId}/extract and widens files.extract_archive
from principalKinds ['session'] / workspaceApiKey 'deny' to admit personal
and workspace API keys at the unchanged 'write' role.

The widening is an authorization change, so the justification lives in the
operation's TSDoc: extraction grants no capability an API key lacks, since
every file it writes could be created one at a time through files.create and
files.upload.create, both already 'allow' at the same role. It only collapses
many calls into one. The previous ['session'] restriction read as an artifact
of the UI having been the only caller. Delegated services stay out — no
copilot or executor caller exists and admitting one is a separate decision.

The response is counts plus the destination folderPath, never the extracted
files: a large archive would otherwise materialize thousands of objects into
one body. Callers page GET /api/v2/files?folderPath=... instead. The use case
returns the internal display path and the adapter projects it to a v2 path,
keeping the use case surface-neutral.

* feat(v2): extract file text over the public API

Adds GET /api/v2/files/{fileId}/text. Text extraction previously sat behind
checkInternalAuth on /api/files/parse, a route that also mixes in external-URL
fetching, execution-file upload, and multi-file aggregation, so it could not be
reused. The parse call is lifted into a thin application use case instead.

Runs on the existing files.read_content operation unchanged — it is already
workspaceApiKey 'allow' at the read role, and turning bytes it already
authorizes into text grants no further reach.

`degraded` is a required, non-optional boolean on the response. The legacy doc
and ppt parsers deliberately return best-effort or placeholder content rather
than throwing, so an omittable flag would let a client that never checks it
treat guessed text as extracted text. It is reported honestly rather than
converted into an error, because the parsers' behaviour is deliberate and
characterization-tested.

The read is bounded on its input at 25 MiB before extraction rather than on its
output after, given the parsers' documented DoS history; a caller may lower the
ceiling but never raise it.

* feat(v2): restore archived folders and list the archived set

DELETE /api/v2/files/folders archives recursively, so a recursive delete was
unrecoverable over the API: the archived files stayed visible through
GET /api/v2/files?scope=archived, but nothing could rebuild the folder
structure.

Adds POST /api/v2/files/folders/restore, path-addressed like the rest of the
v2 folder family, and a `scope` selector on the folder list so a caller can
find the archived path to hand it.

`scope` extends the files folder-list query rather than the shared
v2ListFoldersQuerySchema: only workspace files have an archived folder set, so
adding it to the shared schema would give tables, workflows, and knowledge a
parameter they ignore. GET /api/v2/files/folders is a FULL_SET_LIST, not paged,
so no cursor binding changes — list-pagination.test.ts passes unchanged.

Restore resolves the archived folder from its path by scanning the archived
set rather than walking the live tree, which by definition does not contain
the folder being restored. The folder-restored analytics hook now reports the
folder actually restored rather than the requested selector, which carries no
id on a path-addressed surface.

* feat(v2): bulk-download a file selection as a zip

Adds GET /api/v2/files/bulk-download, an adapter over the existing
downloadWorkspaceFileItems use case and its internal binary route.

Path collision: a static segment beside [fileId] permanently shadows a file
whose id equals it, and workspaceFileIdSchema does accept [A-Za-z0-9_-]+.
Rather than invent a new shape, this follows the existing bulk-delete sibling:
the hyphenated form cannot be produced by either minted id shape (UUID v4 or
wf_<shortId>), so the shadowed id is unreachable in practice. Documented on
the contract so the reasoning is not lost.

Folders are addressed by path, matching the rest of the v2 file surface. The
paths resolve against the folder set the selection already loads, so it costs
no extra query, and a path matching no folder is rejected rather than silently
dropped — a misspelled folder must not yield a zip of whatever else was
selected. The empty-selection and folder-count guards now account for
folderPaths, which a path-only selection would otherwise have tripped.

Selections are comma-separated only: v2 rejects a query parameter sent more
than once, so a repeated-parameter form would never reach the schema. Pinned by
a test so the contract cannot advertise a form the boundary rejects.

* feat(v2): expose run output files and optional inline bytes on the runs read

GET /api/v2/workflows/{id}/runs/{runId} now reports the files a run produced,
each with the downloadPath that fetches its bytes, and can inline them as
base64 on request.

Gated by includeOutput, matching `output`'s nullability: a caller that did not
ask for output does not receive a file list it did not request. The async
execute request's rejection of includeFileBase64 is deliberately left alone —
at submit time the run has not happened, so there is nothing to inline; reading
a finished run is the first moment the question means anything.

Inlining is capped per file at the executor's 16 MiB inline ceiling, which a
caller may lower but never raise. A file above it answers 413 naming that
file's downloadPath, so the caller is told exactly how to get the bytes rather
than being left stuck.

The descriptor deliberately omits the storage key — files are addressed by id
and the key is re-derived from the run's recording — and omits an expiry, which
the recording does not carry and which would be fabricated if published.

The route becomes headSafe: false, since inlining reads object storage. The
builder enforces that this requires the use case to expose authorize(), so HEAD
still answers from a real authorization rather than from authentication alone.

* feat(v2): permanently delete an archived file

DELETE /api/v2/files/{fileId} only archives — the OpenAPI says its stored bytes
are never removed — so there was no way to actually destroy a file over the API.
Adds the repository primitive, application use case, operation, and
DELETE /api/v2/files/{fileId}/permanent.

A distinct path rather than a flag on the ordinary delete: a query parameter
that turns a recoverable archive into an irreversible destruction is set by
accident, and the two acts carry different minimum roles, which one route
declaration cannot express. The file must already be archived; a live file
answers 409 naming the archive step, so no single request can turn a live file
into lost bytes.

minimumRole 'admin', which forces workspaceApiKey 'deny' since the workspace-key
ceiling is 'write' — the desired policy anyway: unattended credentials should
not destroy bytes.

Row first, then object. The two legs commit independently, so one can survive a
crash between them: deleting the row first leaves at most an orphaned object for
the storage sweep, while the reverse would leave a live row pointing at bytes
that no longer exist — a file that lists and opens but can never be read. A
failed object delete is therefore reported as objectDeleted: false rather than
thrown, because the request has genuinely succeeded once the row is gone. Both
directions are pinned by failure-injection tests, verified to fail when the
order is reversed.

Audited as a distinct FILE_PERMANENTLY_DELETED action, not a reuse of
FILE_DELETED, which records the recoverable archive step.

* feat(api): v2 log analytics, itemized cost, filters, and sortable query

Adds the aggregate and rich-read halves of the public logs surface, and
fixes three defects the existing reads carry.

Aggregate analytics. `GET /api/v2/logs/stats` returns time-bucketed run
counts, success rate, error count, mean latency, and the window bounds,
per workflow and for the workspace. The first-party route was a raw
handler with inline SQL and inline aggregation, so it is split into a
repository (`lib/logs/stats-queries.ts`), a pure aggregator
(`lib/logs/stats.ts`), and an application use case. That route keeps its
legacy authorization — it answers a caller without workspace access with
a zeroed 200, where v2 conceals the workspace as a 404 — and consumes
only the two surface-neutral halves.

`segmentCount` had no `.int()`, `.min()`, or `.max()`, so `0` divided by
zero and `1e9` allocated two billion-element arrays: both caller-reachable
500s. Bounded on both contracts. `workflows` is capped, with the workspace
totals still computed from every workflow and the cut reported as
`workflowsTruncated`.

Detail reads gain the itemized `cost.items` ledger (`null` and `[]` are
distinct answers and both reachable) and `workflowInput`, restoring a
v1→v2 regression.

The list gains `workflowName` and `status` filters, and `includeJobRuns`,
which unions Chat and Sim-agent job runs into the sequence behind a new
`kind` discriminator — without it a job run is indistinguishable from a
run whose workflow was deleted. A filter no job row can answer drops the
branch outright rather than meaning two things across the union.

`POST /api/v2/logs/query` carries the additional sort columns. `GET /logs`
is untouched: its single `order` param rests on there being exactly one
sortable column, and both escapes from that are ruled out, so the rich
read gets its own endpoint — the split the table surface already ships.
It uses the shared keyset scheme with the two nullable sort columns read
through a sentinel, since a keyset cannot compare against null.

`folderPaths` now covers a folder's whole subtree on the public path, as
it already did everywhere else; it previously omitted every nested run
with no error. The path strings did not change, so a folder-scope version
is stamped into the cursor and in-flight tokens restart rather than
silently skipping rows.

Also fixes `folderName`, which ILIKEd `workflow.name` — a copy of the
clause above it — and so searched workflow names instead of folders.

`buildLogSortCursorCondition`'s `IS NULL` disjunct is documented and
pinned: under `NULLS LAST` the null block is only reachable through it,
so removing it as a duplicate-row fix makes those runs unpageable.

Ratchets: route count 1142 -> 1144; logs OpenAPI operations 2 -> 4; total
operations 152 -> 154.

* feat(api): v2 tables run state, dispatch polling, batch update, bulk, archive

Closes the headless gaps on the v2 tables surface.

- Per-cell run state is now readable through an opt-in `includeRunState` on
  `GET /rows`, `POST /query`, and `GET /rows/{rowId}`. The default projection
  is byte-identical; a page whose sidecar outgrows its byte budget is a 413
  rather than a silent truncation.
- Run dispatches are addressable: `GET /tables/dispatches/{dispatchId}`
  publishes the column's full four-state domain so polling a finished run is
  not a 500, and `GET /tables/{tableId}/dispatches` lists what is in flight.
- `POST /rows/batch-update` takes one distinct patch per row. Its transaction
  moved out of the Copilot-only module into a surface-neutral use case both
  surfaces now call.
- `GET .../enrichment/{groupId}` publishes the provider cascade, cost, and
  timing behind one enrichment cell.
- `POST /tables/bulk-move` and `/bulk-delete` reach the existing bulk use
  cases, which now accept folders by canonical path and resolve them inside
  the application layer.
- `DELETE` is recoverable: `scope=archived` on the table list plus
  `POST /tables/{tableId}/restore`.

* feat(api): expose knowledge chunks, tag writes, archive/restore on v2

Closes the knowledge cluster's remaining public-surface gaps.

Chunks: list/read/create/update/delete/bulk under
`/api/v2/knowledge/{id}/documents/{documentId}/chunks`. `queryChunks` gains
an `id` tiebreaker on every sort so the list pages on a keyset rather than an
offset — `tokenCount` and `enabled` are both non-unique, so a page boundary
inside a run of equal values used to repeat or drop the tied rows. The
internal offset caller is unchanged; the two positioning schemes share one
read.

Tag definitions: create, update, delete, next-slot, usage, and the
document-scoped save and cleanup. Without them a caller could write a tag
value into a slot with no definition and then had no way to name it, so
tag-filtered retrieval was unbuildable end-to-end. `v2KnowledgeTagSchema`
gains `id`, without which PATCH and DELETE are unaddressable. The
document-scoped DELETE is pinned to `action: 'cleanup'`: the domain's `'all'`
deletes the whole knowledge base's tag vocabulary from a document path.

Archive/restore: `GET /api/v2/knowledge/archived` as a sibling route rather
than a `scope` param — the two reads bind different operations and a v2 route
declares one — plus `POST /api/v2/knowledge/{id}/restore`. `knowledge.restore`
is a new workspace operation carrying `delete`'s policy, since an operation's
inverse must not be harder to reach; the internal session route now delegates
its workspace branch to the shared use case and keeps only the legacy personal
one.

Also: `POST .../documents/from-workspace-files` surfaces `addWorkspaceFiles`,
so a file already in workspace storage no longer has to be re-uploaded
byte-for-byte to be indexed; the `chunkingConfig` write widens to the
first-party five-key schema with its refines and separator bounds, while the
response stays `.catchall` so a legacy JSONB row cannot 500; and
`CONNECTOR_MANAGED_RESOURCE_READ_ONLY` joins `FORBIDDEN_DETAIL_CODES` now that
the bare 403 on connector-managed chunk writes is wire-reachable.

Document upsert is deliberately not included.

* feat(api): add v2 credential rotation and a gate-exempt capabilities endpoint

PATCH /api/v2/credentials/{credentialId} rotates service-account secret
material or renames a credential in place, preserving the credential id so
existing workflow, deployment, paused-run, connector, and webhook references
keep working. Re-posting to POST /api/v2/credentials answers 409, and
delete-and-recreate mints a new id, so rotation previously had no door.

The route is adapter-only: updateWorkspaceCredentialUseCase already owned the
rotation, its audit projection, and credentials.update. It gains one additive
assertedWorkspaceId field for the v2 workspace assertion, and the per-principal
credential-type table that deleteCredentialUseCase already applied is lifted
into requireManageableCredentialType so both operations share it. Without it a
personal API key could rename an env_workspace row and toV2Credential's throw
would surface as a caller-reachable 500.

CredentialProviderOperationError now maps to 503 with Retry-After when the
provider is unreachable, instead of the 400 its OrchestrationError('validation')
base projected. A transient outage rendered as a permanent input error invites a
caller to revoke a working credential.

GET /api/v2/meta reports the calling key's rollout cohort, type, and expiry.
It is the one route declaring the new typed gate: 'exempt' option, because the
rollout gate and the unknown-path catch-all answer byte-identical 404s and a
gated /api/v2/meta could never resolve that ambiguity. Authentication still runs
first, so the only fact disclosed is one about the caller's own credential.

* feat(api): publish deployment lifecycle and workflow-MCP v2 surfaces

Adds the four deployment-lifecycle operations v2 was missing, and the
workflow-as-MCP publishing surface, both as adapters over application use
cases that already existed.

Deployment lifecycle:
- PATCH /api/v2/workflows/{id}/versions/{version} relabels a version.
  Deliberately not the internal route's body-shape dispatch between
  "rename" and "promote to live".
- POST .../versions/{version}/activate promotes a version. Same use case
  as rollback under a different transition, on its own path because the
  two mean opposite things to a caller.
- POST .../versions/{version}/revert overwrites the draft. Accepts the
  literal `active` alongside a version number.
- PATCH /api/v2/workflows/{id}/deployment toggles unauthenticated public
  execution.

`workflows.public_api.update` widens from session-only to session plus
personal API key: it is an admin-role change the same accountable human
may make from a script. Workspace keys stay denied. Its EE refusal now
carries PUBLIC_SHARING_NOT_ALLOWED instead of a bare forbidden.

Workflow MCP servers:
- /api/v2/workflow-mcp-servers list, create, update, delete, plus
  publish and unpublish of a workflow as a tool. Named apart from
  /api/v2/mcp-servers, which registers the external servers Sim calls.
- The six mcp_servers.workflow_deployments operations widen from
  ['delegated'] to admit sessions and personal API keys; roles and the
  workspace-key denial are unchanged.
- The server list gains keyset pagination, matching its external
  sibling, since nothing caps how many a workspace publishes.
- Server, tool, and workflow reads move out of the use case into
  lib/mcp/queries.

Route ratchet 1150 -> 1160; OpenAPI operations 161 -> 171.

* feat(api): extract chat deployments and publish the v2 surface

Chat deployment was a shipped module with no public API and two
authorization systems: `lib/workflows/application/chat-deployments.ts`
had deploy/undeploy extracted, but only Copilot used them — the REST
routes reimplemented workflow authorization inline, and `PATCH
/api/chat/manage/[id]` additionally owned password encryption, the
auth-type field-clearing matrix, identifier uniqueness, the
redeploy-gating protocol with two 409s, a raw db.update, and a manual
recordAudit.

New `lib/chat-deployments` domain:
- `chat_deployments.list/read/update/delete`, keyed on the deployment
  whose workspace is derived by joining its workflow. Creation stays
  `workflows.chat.deploy`, which is keyed on the workflow.
- The PATCH extraction, including the field-clearing matrix and the
  asynchronous-cutover invariant the route had hand-mirrored from
  `performChatDeploy`.
- One `buildChatDeploymentUrl`, replacing three constructions that had
  already drifted onto two different host helpers. There is no chat
  subdomain, so nothing publishes a host.
- Repository reads moved out of the use cases into
  `lib/chat-deployments/queries`.

Internal routes are now adapters over those use cases. `GET /api/chat`
is deliberately not migrated: it scopes by `chat.userId` while every
other chat operation authorizes by workspace admin, and reconciling the
two is a product decision. `PATCH` keeps its 400 for an identifier
collision through a typed `ChatIdentifierInUseError`; v2 reports the
409 the condition actually is.

v2 surface at `/api/v2/chat-deployments`: list, create, read, update,
delete. Workspace-scoped, keyset-paged, and a stored password is never
readable — reads carry `hasPassword` only, and the session-only reveal
endpoint deliberately has no v2 counterpart.

Also: an email- or SSO-gated chat with an empty allow-list is now
refused in the use case rather than only at the internal boundary, since
it is unenterable; and the doc comment on `processHostedKeyCost`
claiming a `usageLog` write is corrected — no such write exists.

Route ratchet 1160 -> 1165; OpenAPI operations 171 -> 176.

* fix(api): close three review findings, two of them caller-reachable

- Run output files are filtered to keys under the run's own execution
  prefix. The recording they came from is not a trustworthy key source:
  the start block copies every caller-supplied input field verbatim into
  its output and `collectUserFilesById` accepts anything carrying the
  `UserFile` shape, so a caller could name any storage key and have the
  download and base64 paths — neither of which authorizes per file — serve
  it back.
- `getBlock` reads own keys only. `BLOCK_REGISTRY` is an object literal,
  so `constructor`, `toString` and friends returned inherited functions
  that every consumer then treated as a block, turning a path segment into
  a 500. `getToolMetadata` already guarded this way.
- A folder-scoped log page no longer unions in every job run in the
  workspace. The guard read `filters.folderIds`, which the public surface
  never sets — it carries the folder filter in `folderScope` — so the page
  contradicted the contract's promise that job runs are dropped whenever a
  filter they cannot answer is set.

Also: the log cursor stamps `includeJobRuns` only when it is on, so its
`.default(false)` no longer puts a constant in every fingerprint and
rejects cursors minted before it existed; and the `folderName` subquery is
scoped to the workspace and to workflow folders instead of scanning the
whole `folder` table.

* fix(api): close two more review findings, one an authorization bypass

- `workflows.operations.apply` no longer admits a workspace API key. The
  use case authorizes against three per-user policies — the EE permission
  config, block visibility, and credential reachability — and all three
  take a human subject. An actorless key has none, and both substitutes
  fail open: attributing to the workspace billing owner evaluates the
  batch as the least-restricted account in the workspace, and passing no
  user makes `getUserPermissionConfig` return `null`, which every caller
  reads as unrestricted. Either way a workspace constrained by an
  allowlist was edited as though it were not. Personal keys keep the
  capability, so headless editing is unaffected for a credential that
  names a human.
- `GET /workflows/{id}/state` reads its variables through
  `parseWorkflowVariables`, and the stored variable response schema drops
  the two assertions the column cannot honour. The column has carried a
  JSON string and a legacy array as well as the current record, the
  realtime `variable.add` op types `type` as `z.any()`, and the parser
  writes `name` through verbatim — so the input bounds on the read turned
  a stored workflow into a 500 on the endpoint that opens it. The write
  schema keeps them, which is where they can still be honoured.
- `GET /workflows?scope=archived` projects folder paths tolerantly.
  Archiving a folder cascades onto the workflows inside it but leaves
  their `folderId` dangling — which is why restore has to null it — so the
  strict projector threw a bare `Error` and took the whole page down with
  no cursor able to step past the row.

* fix(files): bind Start-block file keys to the executing workspace

The Start block derived a file's storage key by parsing the caller's own
`url`, which `isInternalFileUrl` matches on any host and
`extractStorageKey` returns verbatim — so a request body could name any
tenant's bytes. The key is now accepted only when its own layout names
the workspace the execution runs in, and every file is dropped when the
execution carries no workspace.

Also bounds `includeFileBase64` with an aggregate response ceiling and a
worker pool instead of an unbounded `Promise.all`, scopes the bulk
download's authorization resource to the workspace when folder paths are
requested, makes the folder-restore selector mutually exclusive at the
type level, and names the bound in the `maxBytes` validation message.

* fix(api): close v2 log review findings

Cursor scope: `scope` on the workflow and table lists carries
`.default('active')`, so it entered every fingerprint as a constant and
refused every cursor minted before the param existed — with the
"cursor does not match the requested filters" 400, which is actively
misleading for a caller that changed nothing. Both now stamp the
default as absent, so only a caller who asked for `archived` gets a new
sequence.

Dashboard stats: `maxWorkflows` capped the response, not the
allocation. Segment series are now densified after the cut instead of
before, so returning 200 series no longer materializes one
`segmentCount`-length array per workflow in the window. The aggregate
still sums every workflow, now from the sparse per-workflow maps.

Cost keyset: `cost_total` is an unconstrained `numeric`, so its anchor
travelled through `Number()` and was compared back at full precision —
rows differing beyond float64 collapsed onto one anchor. Adds
`decimalKey`, which carries the digit string and binds it `::numeric`.

Run detail: `cost_total` is a backfilled projection, so a run predating
the backfill reported `cost: null` even with a real ledger, making
`items` unreachable for exactly the runs the ledger explains. Falls
back to the ledger total.

Also caps the log folder-path index reads at MAX_FOLDERS_PER_WORKSPACE
like every other reader, publishing the folder-tree 413 on the four log
operations; reverts a dead `status` widening in `v2CommaListSchema`;
drops an unread `executionData` select; corrects the segment-count and
searchLogs prose; and replaces the sort-cursor SQL-text assertions with
a two-page walk over a fixture with a null block.

* fix(api): close knowledge v2 review findings

- widen knowledge.list_archived to the delete/restore policy so a workspace
  API key can discover what it may restore
- escape LIKE wildcards on the now-public chunk search
- derive tag slot capacity from TAG_SLOT_CONFIG per field type
- type updateKnowledgeBase's chunkingConfig as ChunkingConfig and project
  every declared field explicitly
- attribute a restore to the calling surface instead of a literal 'api'
- gate 'knowledge chunks batch-update' behind --yes, since it can delete
- present the tag-cleanup action from the parsed request rather than
  faulting on the domain result after the delete committed
- unbind asserted-scope workspaceId from the nested knowledge cursors,
  matching the table-row lists
- add executed-SQL coverage for the chunk keyset

* fix(catalog): close the catalog and registry-boundary review findings

The module-graph ratchet treated an entry with no baseline row as
informational, so the six catalog routes and the projection barrel were
unratcheted while the summary still read "within their module-count
baseline". An unbaselined entry now fails --check, the summary counts only
what was actually compared, and the baseline is re-recorded.

The Copilot block-metadata tool — the reason the shared projection exists,
6,756 modules down to 1,321 — was in no guarded subtree. It is now an entry
source and a catalog boundary root.

Catalog behaviour:
- hostedApiKey is gated on the deployment, so a self-hosted install reports
  none instead of promising 127 tools' keys it will never supply
- block detail resolves an unversioned base type to its newest version and
  projects through the viewer's visibility, so it can no longer 404 a block
  the list contains or name it differently
- offset-cursor ordering compares code units rather than the process locale
- projections copy every array they publish instead of handing out the
  registries' own
- an options function returning a thenable throws rather than silently
  widening the providers-store substitution across the event loop
- a throwing block projection costs the Copilot tool one block, not all of them
- the trigger-kind log returns to debug: chat/manual/api are entry-point
  kinds, not authoring defects

Also sweeps the custom-block detail branch against its response schema,
guards each projection module rather than the dead barrel over them, and
drops a provably dead branch in processHostedKeyCost.

* fix(workflows): close v2 workflow-authoring review findings

- Read a blockless draft back as an empty graph. `PUT /state` of
  `{ blocks: {}, edges: [] }` — the contract's own published example —
  deletes every block row, and the loader answers `null` for a blockless
  workflow, so the following `GET /state` answered 404 while the list
  endpoint still showed the workflow. Existence is the workflow row's to
  decide; the null is now projected as an empty graph.
- Rewrite the `readWorkflowGraph` authorization test so it can fail. It
  called `authorize?.()` and asserted only a negative, so deleting
  `authorize` or replacing it with a no-op both passed — the invariant the
  head-safe `HEAD` path depends on.
- Route `setWorkflowBlockEnabled` through `replaceWorkflowNormalizedState`,
  the same door the other two graph writes use, instead of writing the
  normalized tables itself without state preparation or custom-tool
  extraction.
- Count applied operations directly. Enablement refusals landed in the same
  skipped-item array and were subtracted from the operation count, which
  `Math.max(applied, 0)` then masked when it went negative.
- Give a `disabled_ancestor` refusal its own member of the published skip
  enum instead of reporting it as `block_locked`.
- Refuse an `atomic` batch whose credential or hosted API key would be
  stripped, and carry the dropped inputs in the 409 details.
- Publish the whole lint report — `sources`, `sinks`, `orphanBlocks`,
  `emptyOutgoingPorts`, `invalidBranchPorts`, `invalidConnectionTargets`,
  `fieldIssues`, and the `kind` discriminator on unresolved references —
  rather than only free-text reference prose.
- Stop reporting unresolved lint references as `inputValidationErrors`.
  `collectUnresolvedReferences` is read-only, so those values stay
  persisted; they were double-reported, and falsely as dropped inputs.
- Replace two unfalsifiable negative-principal tests, which used a
  principal kind `Exclude`d from `PrincipalKind`, with a reachable one.
- Nits: drop a stranded TSDoc block; assert the membership predicate in the
  selector-validator admin test; make the HEAD test assert a representation;
  assert the sanitized graph is what `replaceWorkflowState` writes; add a
  route test rejecting `baseGraph` in a v2 body; carry
  `principalAuditSource` on restore/duplicate/moveBulk audit; unify the two
  `base64MaxBytes` ceilings on `MAX_INLINE_MATERIALIZATION_BYTES`.

* fix(api): close seven v2 review findings, one an authorization bypass

Raise mcp_servers.workflow_deployments.update_server to admin: its body
carries isPublic, and a public server executes with no Sim credential, so a
write member could remove authentication from every workflow it publishes.
create_server already grants the same visibility at admin.

Pin the widened operations in registry tests — the six workflow-MCP ones,
the four workflow widenings, and files.extract_archive.

Reject secret fields on a credential that has no rotatable secret instead of
dropping them behind a 200, classify a non-transient provider 4xx as caller
error rather than a retryable outage, and reconcile a provider outage to 503
with Retry-After on all three surfaces.

Give /v2/meta a declarative principal policy through a new defineOperation
factory, carry the key expiry on the auth context instead of reading the
api_key table from the application layer, and make the impossible principal
branch an invariant error rather than a codeless 403.

Enforce the rollout-gate exemption at definition time, against the one
contract path it is reserved for, and remove the gate parameter from the
exported admission helper so the builder is its only door.

* fix(tables): bound the run-state sidecar, and close six v2 review findings

Enforces the 2 MiB run-state ceiling INSIDE the sidecar drain rather than
over its materialized result, refuses the unbounded query form paired with
it, and normalizes the two stored blobs the v2 surface publishes from bare
`as` casts.

- The run-state budget now travels into `loadExecutionsByRow`, which drains
  row ids in bounded chunks and refuses before fetching the next one. The
  post-hoc `requireBoundedRunState` walk is gone: it measured a spike that
  had already happened, and re-serialized every entry to do it.
- Both row reads that accept `includeRunState` cap the page at
  `V2_MAX_RUN_STATE_ROW_LIMIT`, and `POST /tables/{id}/query` additionally
  refuses the flag paired with `limit: 0`.
- `runState.status` and the enrichment cascade blob are projected onto the
  published shape before presentation; both were caller-reachable 500s on a
  well-formed read.
- `sim tables bulk-delete` now gates behind `--yes`, and the CLI sweep that
  should have caught it covers destructive non-DELETE forms.
- `POST /tables/{id}/restore` is idempotent (200, no audit) like its
  knowledge sibling, and bulk folder selection deduplicates after resolution.
- The batch-update backstop keeps the looser Copilot ceiling and says so in
  TSDoc: it is a backstop no surface reaches, because the contracts stop a
  v2 caller at 1000 and the Copilot tool stops itself at 5000. Each caller
  sees the bound that actually applies to it; neither surface's cap moved.

* fix(chat-deployments): close v2 chat review findings

Fixes the chat-deployments slice of the v2 review, several of which are
regressions the application-operation extraction introduced.

- Stop a `500` on schemaless JSONB: the response now declares a stored
  shape without bounds and `toV2ChatDeployment` projects
  `customizations`, `outputConfigs`, and `allowedEmails` onto it. The
  request schemas keep `.strict()` and their bounds.
- Restore the specific validation message on `POST /api/chat` and
  `PATCH /api/chat/manage/[id]`, and the deleted test that pinned it.
- Restore `chat_deployments.read` to workspace `admin`; the detail read
  serves the visitor gate.
- Narrow the list projection so `chat_deployments.list` can stay a
  `read` operation reachable by a workspace API key: `allowedEmails`,
  `hasPassword`, and `customizations` are gone from the list entry and
  available only from the admin-gated detail read. Serialized field by
  field so a field added to the detail shape cannot reach the list by
  default.
- Classify create-path failures: `performChatDeploy` carries an
  `errorCode`, so an in-flight deployment is a `409` and an invariant
  failure a `500` instead of every refusal being a `400`.
- Delete the callerless `GET /api/chat`, which served the encrypted
  password column with no response contract.
- Propagate undeploy infrastructure failures instead of concealing them
  as `404`, and return `ChatDeploymentView` from both delete paths.
- Name `CHAT_AUTH_MODE_NOT_PERMITTED` on the create path.
- Guard `getBaseUrl` inside `buildChatDeploymentUrl`, which otherwise
  throws on a self-host with no `NEXT_PUBLIC_APP_URL`.
- Correct the published allow-list claim: a replacement `allowedEmails`
  is applied after the auth-type clear, so it does survive.
- Assert `workspaceId` on the v2 detail routes and reconcile the
  concealment TSDoc with what the error policy actually renders.
- Move `resolveActiveWorkspaceApplicationContext` to the workspaces
  domain so chat-deployments no longer imports workflow application code.

* test(credentials): pin the reconciled provider-outage status

The internal route alone answered 502 where the v2 surface, the shared
status helper and `PROVIDER_OUTAGE_CODES`' own TSDoc all say 503. The
test pinned the divergence; it now pins the reconciliation, including the
`Retry-After` a 503 carries. Corrects a stale comment that still named
502 as the value callers see.

* fix(executor): restore cloud-storage Start files, dropped by the key rule

The ownership check accepted a key only when it could be parsed out of an
internal `/api/files/serve/...` URL. But the server-side uploader for run
inputs returns a *presigned cloud* URL whenever object storage is
configured, whose path is the bucket key — so every chat-deployment
attachment, API `files[]` payload and generic-webhook file field resolved
no key, and because normalization is all-or-nothing the entire `files`
input was dropped with no error. It passed locally and under vitest only
because the uploader falls back to an internal URL when no object storage
is configured, which is exactly why no test caught it.

The test is ownership, not provenance: a key is accepted when its own
layout names the executing workspace, whether it arrives directly or is
parsed out of the URL. Neither field has to be trusted, since both are
caller-authored and both are held to the same check. A payload whose key
and URL disagree is refused rather than resolved in the caller's favour —
a genuine uploader writes the two consistently, so only a forged pairing
is turned away.

`context` is now derived from the accepted key rather than read from the
payload or the URL's `?context=`, so an owned key can no longer be
labelled with a bucket its bytes do not live in — the hardening the
previous comment claimed but did not perform.

* fix(api): close four defects the fix pass introduced

- `resolveLatest` built a `RegExp` from the caller's block id and read the
  registry with a bare lookup, so the catalog detail route — moved onto it
  by the version-alias fix — routed around the `ownBlock` guard added for
  exactly this. `GET /api/v2/blocks/%5B` was a `SyntaxError` 500 and
  `.../constructor` an inherited function. Matched by string comparison
  now, the way `tools/tool-ids.ts` resolves the same convention, and read
  through `ownBlock`.
- The run-state byte budget was applied inside `queryRows` rather than at
  the callers that publish it, so the first-party table grid — which reads
  run state at five times the row limit and publishes no ceiling — turned
  a large page into a hard failure, with an error naming a parameter it
  does not expose. The budget is now an explicit option the public reads
  pass and internal callers omit.
- Three graph-write CLI commands shipped ungated because the sweep meant
  to catch them matched only the names already enumerated, so it could
  never fail. It now forces every non-`GET` operation into a destructive
  or non-destructive list, and the three carry confirmations.
- Unbinding `workspaceId` from the knowledge-documents cursor was right on
  the merits and wrong in effect: the value is constant per sequence, so
  removing it changed the fingerprint and refused every cursor already in
  flight. Restored there; the chunks list is new in the same change and
  keeps the cleaner reading.

* fix(api): resolve a detail read to a version the viewer can see

`getLatestBlockForViewer` took the newest version and then hid it, which
inverted the contradiction it was written to close: `slack_v2` and
`table_v2` are preview-gated while their v1 deliberately stays in the
toolbar, so an unrevealed viewer got a `404` on a detail read for a type
`GET /api/v2/blocks` was listing in the same breath. It now walks versions
newest-first and answers with the first one visible to that viewer.

Also:
- The chat password guard ran after `performFullDeploy`, so a request that
  could never succeed burned a real workflow deployment version and then
  answered 400. Its two sibling gate guards already refuse ahead of the
  deploy; this one now does too.
- The Copilot sub-block serializer published the registry's own `options`
  and `dependsOn` arrays by reference. Pre-existing, but the catalog
  projection this parallels copies every array it publishes precisely
  because they are process-global and shared by every request.

* fix(api): restore the locked read-modify-write and the password validator

Two findings verified as real regressions against staging, out of ten
checked — the rest were pre-existing, latent, or false.

`setWorkflowBlockEnabled` read the graph outside the row lock and wrote it
back inside a later transaction. The editor's own save takes that same
lock, so an autosave committing in the window was silently discarded: this
operation writes a whole graph, not a delta. The persistence primitive now
accepts a reader that runs after the lock is taken, and the toggle
re-reads and re-decides there. Its lock predicate is also scoped to the
workspace and to a live row again, so a workflow archived mid-flight is
refused rather than written.

The v2 chat-deployment contracts inlined their own password rule twice
instead of using `chatDeploymentPasswordSchema`, losing the refusal of a
whitespace-only password — which the internal contract rejects precisely
because it strands the deployment behind a password the visitor form will
not submit. Both sites use the canonical validator now.

* fix(api): one folder projection, one dynamic-provider list, honest 413s

- `toV2Folder` existed twice, and the second copy had been written without
  the name/path invariant — so a row the list read refuses loudly would
  have been served with a mismatched pair by the restore read. One
  definition, guard included.
- The catalog projection restated `DYNAMIC_MODEL_PROVIDERS` and had
  drifted by one member. Derived from the canonical list instead.
- The tables reads documented a `413` for run state that they cannot emit
  — the budget became opt-in, and the row limit is the bound now — so the
  claim is removed rather than declared. The workflow run read has the
  opposite problem: it genuinely emits one, on a single file *or* the
  run's inlined total, and declared neither. Now declared, and the
  sentence covers both.
- Reclassifies the operations staging added into the destructive sweep, so
  the triage stays exhaustive.

* fix(v2): classify storage and uniqueness failures, drop permanent file delete

- remove the permanent file-delete endpoint; the platform offers no such
  action in the UI, and its manager wrote outside a transaction with no
  storage accounting
- extract a generated document's text from its compiled artifact rather than
  its generation source, matching the download path; a `.pdf` source was a
  500 and a `.docx` source returned generator JavaScript as clean content
- report a run file whose object retention has already swept as 404 rather
  than 500, on both the inline base64 read and the download stream
- report a knowledge tag that loses at a unique index as 409, naming whether
  the slot or the display name is taken
- gate `workflows versions revert` behind a CLI confirm; it overwrites the
  draft graph and was classified non-destructive

* feat(v2): report lint from both graph writes and add dry-run previews

- `PUT /workflows/{id}/state` now returns the same `lint` report as
  `POST /operations`; an agent authoring a graph from scratch needs the
  findings at least as much as one editing incrementally
- extract the report into one shared builder so the two writes cannot drift,
  and one shared presenter so the wire shape is identical
- skip the credential/tool reference pass when the caller has no human
  subject, rather than resolving it against the workspace billing owner:
  that would misreport what the workflow can reach and disclose another
  person's grants. `lint.notes` says when it was skipped
- add `?dryRun=true` to both graph writes: validates and lints, persists
  nothing, records no audit, notifies nobody. A query param, not a body
  field, since the body of a PUT is the resource itself
- CLI: a dry run no longer demands `--yes`; requiring confirmation to preview
  a change teaches callers to pass `--yes` reflexively
- CLI: name the graph commands for their verbs — `workflows state get`,
  `workflows state replace`, `workflows operations apply` — instead of the
  derived `state list` / `state update` / `operations create`
- document when to use `rollback` vs `versions/{version}/activate` on both

* chore(docs): sync generated docs manifest for the new CLI pages

* feat(v2): add the missing workflow-MCP reads and align bulk naming

- add `GET /workflow-mcp-servers/{serverId}` and
  `GET /workflow-mcp-servers/{serverId}/tools`. The resource could be
  PATCHed and DELETEd but never read, and its tools could be published and
  unpublished but never listed — the server list reports tool names only, so
  nothing published the `workflowId` that addresses a tool for deletion.
  Both mirror `mcp-servers` beside them, and carry that family's
  workspace-API-key denial rather than the wider `mcp_servers.read` policy
- rename `POST /tables/bulk-move` to `POST /tables/move`, so tables matches
  the shipped `files` resource exactly (`move` + `bulk-delete`)
- name the CLI commands for their operations instead of the derived
  `... create`: `tables move`, `workflows move`, `tables bulk-delete`, and
  `tables rows update-each` for the per-row batch, which sits beside the
  existing filter-based `tables rows batch-update`

`POST /tables/{id}/rows/batch-update` keeps its name: a distinct payload per
resource is precisely AIP-234 BatchUpdate, and `bulk-` would have collided
one word away from the filter form.

* fix(v2): correct documented statuses and a caller-reachable 500

- duplicating a workflow into a locked destination folder answered 500:
  `FolderLockedError` is a plain Error carrying `status = 423`, which the v2
  error policy does not classify. Converted to OrchestrationError('locked')
  at the application boundary, matching the bulk-move path
- restore workflow promised a 413 for an oversized folder tree that its
  response list never published; the cap is real, so the status now is too
- move workflows and apply variables documented 409/423 they cannot emit:
  every per-item lock and conflict is reported in `failed`, not thrown
- bulk download and delete knowledge tag can both 409 and did not say so;
  cleanup tag definitions cannot and did say so
- apply workflow operations denies workspace API keys but never documented it
- the dry-run responses are not byte-identical to a committed write:
  `needsRedeployment` describes the pre-write state and persistence warnings
  cannot appear. Reworded rather than overclaimed
- read file text and get file upload are head-safe, so the "HEAD skips the
  effect" sentence did not apply to them

* fix(v2): guard tag field-type changes and publish the 415 every body route can return

- `PATCH /knowledge/{id}/tags/{tagId}` accepted a `fieldType` incompatible
  with the slot the tag already occupies. Slots are enumerated per field
  type, so a text tag could be relabelled `number` and every later read
  would interpret its values as the wrong type. Create checked this; update
  now runs the same two checks
- derive `415` from the contract the way `413` already is: the JSON builder
  answers UNSUPPORTED_MEDIA_TYPE for any body under a content type it cannot
  read, so all 100 body routes could return a status none of them published
- give version activation its own result component instead of publishing it
  as `RollbackResult`; the shipped rollback keeps that name
- correct descriptions that promised behaviour the code does not have: a
  `processingStatus` field never returned, a `gmail_send` resolution example
  that short-circuits, bucket widths that overflow the window, a bulk tag
  save that relocates rather than overwrites, and per-server tool names
  actually gathered under a page-wide budget
- drop 409 from three knowledge and upload reads that cannot emit it

* docs(v2): correct the upload transfer contract and 16 other published claims

The upload transfer step was documented as Sim's own data plane on every
deployment: "success is 204" and "a failure is the v2 error envelope". That
holds only when Sim stores objects itself. With object storage configured the
URL is the provider's presigned URL, so S3 and GCS answer 200 and Azure 201,
and a failure is the provider's XML — a client written to the old text reads a
successful cloud upload as a failure. Also states that part ETags do not need
retaining: completion takes no body because Sim lists the parts from the
provider itself.

Other corrections, all to shipped descriptions rather than behaviour:
- DELETE table and bulk-delete files archive rather than erase, and neither
  said so; bulk delete also cannot emit the 409 it declared
- complete knowledge upload published a 402 only the create leg can raise
- billing status conceals a foreign workspace id as 404, not the 403 its
  TSDoc and description both claimed
- audit entries null a folder's resourceId and strip folder ids from metadata
  at every level; neither redaction was documented
- details=full adds the workflow summary to workflow runs only, never to job
  runs; GET /logs folderPaths covers a subtree like its two siblings; getLog
  now carries the retention sentence
- list secrets returns description too, and the logs and resources documents
  described only part of what they serve

* improvement(api): consolidate the v2 surface and close seven defects

Endpoint consolidation:
- Fold POST /logs/query into GET /logs; add sortBy/sortOrder, cap the
  comma lists, and move the list onto the shared keyset codec
- Fold GET /knowledge/archived into GET /knowledge?scope=archived,
  matching files, tables, and workflows
- Re-home chat deployments as a singleton under the workflow they
  belong to; keep the workspace-scoped discovery list
- Move the tag-definition writes off the document path onto
  /knowledge/{id}/tags, where they already acted
- Nest the table export and dispatch reads under their parent table

Defects:
- Publish isPublicApi on the deployment read; it was write-only, so a
  workflow could be opened to unauthenticated execution unauditably
- Stop publishing raw storage keys and an unusable URL in log files
- Classify a chat-identifier unique violation as 409 rather than 500
- Fall back to the root path instead of throwing when a knowledge
  base's folder is archived
- Cap bulk-download at the ceiling it actually enforces
- Normalize variables through one helper on both graph write paths
- Fix a folder-name log filter that matched workflow names

Naming and gaps:
- Rename /files/{id}/extract to /unarchive, /rows/find to /rows/search,
  /rows/batch-update to /rows/bulk-update, /columns/run to /dispatches
- Type the last six generic [id] path segments
- Add table folder restore and id-addressed dispatch cancel

* chore(audits): record the v2 catalog routes in the boundary baseline

* fix(api): accept a null chat password and correct three published claims

- performChatDeploy validated `password: null` as a password, so the
  replace-shaped chat PUT answered 400 for every mode that owns no
  password — public (the default), email, and sso. The declared payload
  type has always allowed null, and the stored value is cleared by
  authType regardless, so null needs no validation of its own. The route
  test could not catch it: it mocks the orchestration module and pinned
  the exact null the real guard refused.
- Redirect the two docs slugs this branch retired that were genuinely
  published: findTableRows and runTableColumns.
- The table folder restore described an idempotent no-op for an already
  active folder; it answers 404. Say so, and say where the path comes
  from, since the tables folder list cannot yet report archived folders.
- Name the customizations exception to the chat PUT's replace semantics.
- A cursor-binding case used status=error, which is a level and not a
  status, so it failed contract validation and never reached the cursor
  check. Use an accepted value and pin the reason, not just the status.

* chore(cli): classify the new v2 chat operation as non-destructive

* feat(cli): expose canonical resource URLs

* fix(api): close three caller-reachable failures found by the final probe

- GET /files/{fileId}/text called parseBuffer unguarded, and parseBuffer
  signals every failure as a bare Error that no v2 policy classifies. A
  zero-byte upload or a mislabelled archive was an unhandled 500. Empty
  bytes now answer empty text — a zero-length file has no text — and
  unparseable bytes answer 409, matching the rendered-artifact resolver.
- GET /workflows/{workflowId}/state asserted write-side bounds over
  stored data. workflow_blocks.name and .type are bare text() and the
  realtime rename op accepts z.string(), so a block renamed past 255
  characters made the workflow unreadable, and unrepairable, over v2.
  The read shape now takes the same input/stored split the variable
  schema already had. Stored subflow conditions are coerced in the
  loader beside the existing numeric guards.
- GET /knowledge stamped scope into the cursor fingerprint
  unconditionally. scope defaults to active and is new on that list, so
  every cursor the deployed build handed out would have been refused
  with a message saying the caller changed a filter they never sent.
  Its siblings already carry the guard and the comment.

Also: POST /workflow-mcp-servers answers 201 like every other v2 create;
GET /logs/stats reuses the log list's entry ceilings; the execute and
resume routes install the media-type-aware 415 they publish; and a
rationale citing an endpoint that never reached the wire is corrected.

* fix(tables): carry the dispatch terminal timestamps through the stale sweep

Staging's abandoned-dispatch recovery builds its own `DispatchRow`, and
this branch had added `completedAt`/`cancelledAt` to that shape for the
id-addressed dispatch read and cancel. The merge was textually clean and
left the new mapping short two fields.

* fix(workflows): deny workspace API keys on the graph replace

`PUT /workflows/{workflowId}/state` stores blocks and their tool wiring
wholesale, but the policies deciding which of those a member may add —
the EE permission config and block visibility — take a human subject.
A workspace API key has none, and both substitutes fail open: the
billing owner is a different, typically less-constrained person, and
passing no user makes the permission lookup return null, which every
caller reads as unrestricted.

That made the replace a second graph-write door storing what its sibling
`POST /workflows/{workflowId}/operations` refuses, which denies workspace
keys for exactly this reason. Both doors now agree. Personal keys keep
the capability, so headless authoring is unaffected for a credential
that names a human.

* chore(api): drop the enrichment catalog endpoint

GET /api/v2/enrichments listed the code-defined table enrichments. The
per-row enrichment run detail stays; only the catalog read goes.

Removes the route, contract, response and query schemas, the semantic
operation, the use case, the projection module, its registry-boundary
entry, and the CLI command. The "not found" and "blank search" cases it
covered are repointed at the connector-type sibling so the shared
behaviour stays tested rather than deleted with it.

* improvement(api): make the workflow operations endpoint self-describing

Two things stood between this endpoint and a caller who has only the
published spec.

The accepted `params` keys existed only in the edit engine's source. The
spec said "the accepted keys depend on the target block type", so a
caller reading it could create a nameless empty block and nothing more —
while the Copilot tool catalog, over the same engine, has always spelled
out the envelope. That guidance now lives in the contract, shared by the
add, edit, and insert_into_subflow parameter schemas so the two surfaces
cannot describe one engine differently: `inputs` keyed by sub-block id,
`retry`/`triggerMode`/`advancedMode` beside it rather than inside it,
`connections` keyed by source handle, and `removeEdges` for dropping one
edge without restating the rest.

A `block_id` that is not already a UUID is replaced with a minted one,
and the mapping was computed and then dropped. A caller could not
reference the block it had just created except by re-reading the graph
and matching on name. The engine now returns it and the response
publishes it as `mintedBlockIds`, with the in-batch versus cross-request
rule stated in the operation description.

* fix(api): close the pre-merge scan's blocking findings

Docs and public wire, all of it permanent surface once released.

- Two published tag groups, Catalog and Meta, had no sidebar entry in any
  locale, so six operations shipped unbrowsable. Added to all six, and a
  test now fails when a published tag has no entry.
- deleteWorkflowChatDeployment pointed callers at DELETE on /deployment;
  the undeploy verb is on /deploy.
- PUT /state still promised workspace API keys a degraded lint pass after
  the operation began rejecting them outright. It also lived in a
  single-quoted string, so the shared clause would not have interpolated.
- POST /tables/move took targetFolderPath as nullable-but-required, which
  rendered the CLI flag as a required `<json|@file>`: `--to /Archive`
  failed to parse and omitting it failed outright, while the docs said
  "omit for root". Now optional and a plain string, matching
  POST /files/move; the route supplies the null the use case wants.
- Table dispatch status and row run state published `cancelled` beside
  imports, exports, and job state publishing `canceled`, and the note
  explaining the split was wrong about its own sibling. Both new schemas
  now publish `canceled`; the stored column is unchanged and mapped at
  the presenter. The shipped `cancelled` count field is left alone.
- applyWorkflowVariables can answer 423 and both workflow-MCP deletes can
  answer 409; none declared it. Hand-assembled error lists replaced with
  the shared sets.
- /files/{fileId}/unarchive became /unzip. `extract` reads as "extract
  text" and `unarchive` reads as the inverse of restore on a resource
  where archived means soft-deleted; unzip collides with neither and is
  what the implementation calls itself.

* improvement(cli,docs): finish the naming and clear the enrichment leftovers

- Four single-record GETs derived to `list` while returning one thing:
  meta, a workflow's chat deployment, log stats, and file text. Renamed to
  `meta status`, `workflows chat status`, `logs stats`, `files read`,
  matching the `workflows deployment status` correction already in this
  branch. None of the old spellings shipped.
- Dropped five `renamedFrom` aliases pointing at spellings that never
  existed, each of which built a hidden command and a permanent
  deprecation warning for argv nobody could have typed. `tables rows find`
  keeps its alias — that one really shipped.
- Removed the enrichment catalog from three prose sites left behind when
  the endpoint went: the resources spec description, its Catalog tag, and
  the contract and pagination-test comments.
- Documented the six new command groups in the CLI index table. That page
  is a guide page, so the docs staleness check cannot flag it.
- Published the `customizations` exception to the chat replace semantics.
  It was in the route TSDoc and invisible to every caller reading the
  spec, which is where the claim "Replace, not merge" is made.

* improvement(cli): use batch- for the tables bulk delete, matching its siblings

The CLI renames a bulk form only when it would collide with its singular
sibling, and uses AWS's `batch-` prefix when it does — `files
batch-delete`, `tables rows batch-delete`, `knowledge chunks
batch-update`. `tables delete` exists, so `tables bulk-delete` was that
same rename reaching for the other word, and the only `bulk-` command on
the surface. No `bulk-` CLI command has ever shipped, so this costs
nothing now and would be permanent later.

`files bulk-download` keeps its name: there is no `files download` to
collide with, and it is one archive rather than N operations. Its config
block now says why it exists at all, since the command is never built —
the builder skips non-JSON response modes, but the contract sweeps still
read the entry and require the folder-path field to be marked.

* fix(workflows): allow operations on blockless drafts

* feat(workflows): add manual and run-from-block execution

* fix(cli): update workflow run description test

* fix(tests): align fixtures with current contracts

* fix(api): derive chat activity from workflow deployment

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-08-25 04:19:34 -04:00
Vikhyath Mondreti 0a5b3801ea feat(secrets): let workspace secrets opt out of redaction (#7045)
* feat(secrets): let workspace secrets opt out of redaction

* fix(secrets): certify no sandbox exemptions once the registry is incomplete

* feat(secrets): carry visible secret values on the v2 list and document visibility

* fix(secrets): read visible values by own property so prototype-named secrets cannot poison the list
2026-08-24 13:46:57 -07:00
Theodore Li 8104c33bba Fix knowledge connector sync follow-up (#6927)
* Fix knowledge connector sync follow-up

* Fix connector sync pause race

* fix(knowledge): surface connector sync dispatch failures

* fix(knowledge): make connector sync recovery durable

* fix(knowledge): deduplicate connector sync dispatches

* fix(knowledge): preserve pending connector syncs

* fix(knowledge): lock connector sync snapshot
2026-08-22 20:50:48 -04:00
Waleed 8937bb3550 fix(kb): let the server say a connector sync is queued (#6968)
* fix(kb): let the server say a connector sync is queued

The connector chip inferred "a sync is coming" from `createdAt` inside a
2-minute window, because nothing on the row distinguished a queued sync from
an idle connector until a worker took the lock. The guess was wrong under
queue backlog and under client clock skew, and it forced a pile of client
state to stand in for it.

Adds `pending`, written as the sync is handed to the queue and cleared when a
worker takes the lock or the hand-off is found to have been lost. It is a
phase of the same lock `syncing` holds, so it opens the lease and takes an
ownership token the same way — the lease is what the scheduler ages a stranded
queue entry against (`updatedAt` cannot serve: a pending connector is still
editable, so any unrelated write would renew the recovery it should trigger),
and the token is what proves a late release belongs to this dispatch.

Deletes the 2-minute window, the in-flight id sets, the 5-minute cooldown
timers and the forced re-render they needed. The cooldown lived in a ref
inside a modal, so it evaporated whenever the modal closed; the disable now
comes from durable server state and is shared across tabs.

Also fixes, all found while tracing the lifecycle:
- An on-demand sync on a paused or disabled connector silently resumed it for
  good. Nothing could put the pause back: success writes `active`, a lost
  queue entry writes `error`, and the due-sweep keeps syncing that. Refused.
- A failed hand-off no longer advances the connector's auto-disable breaker. A
  queue outage would otherwise increment every connector in the fleet until
  they all disabled themselves for a fault that was never theirs.
- Manual sync on an established connector gave no feedback at all: the poll
  only ran while the predicate matched, which it never did.
- Four over-broad invalidations that refetched every cached chunk page and
  chunk search in a base when one connector document was excluded.
- The dead-process reporter re-sent a PATCH per stale document on every poll.

* fix(kb): refuse to start a queued run on a paused connector

The queue outlives the decision to sync. Pausing a connector after its run was
queued cleared the queue entry's token but left the task itself alive, and the
lock CAS accepted any row that was not already `syncing` — so the worker took
the paused row and wrote its own terminal `active` over the pause.

Moves the rule to the two points that can enforce it: an explicit
`LOCKABLE_CONNECTOR_STATUSES` allowlist on the lock acquisition, and the same
allowlist on `markSyncPending`, which closes the mirror race where a dispatch
already in flight rewrites a just-paused row back to `pending`. Queueing and
starting now agree on one rule, and a skipped hand-off is reported as its own
outcome rather than a concurrency conflict.

Also patches the connector detail cache alongside the list on an optimistic
status write, so an already-expanded card starts its own sync poll instead of
showing stale history behind the list's spinner.

* fix(kb): make a queued sync prove it is the run that was queued

`markSyncPending` minted an ownership token but only `releaseFailedDispatch`
checked it, so the worker could consume a queue entry that was not its own. A
task delayed past its lease is reclaimed and replaced; the status check alone
let that stale task take the replacement's entry and run superseded options —
a plain sync where the user had just asked for a full resync — while the
replacement was turned away as `sync_in_progress`.

Carries the token in the task payload and matches it at lock acquisition, the
same discipline `holdsSyncLockToken` already applies to the `syncing` phase,
extended to the phase before it. A superseded run is now reported as such
rather than as a concurrency conflict.

The payload field is optional for the rollout window only: tasks already in
the queue carry no token, and stranding them would be worse than letting them
fall back to the status check for one deploy.

* fix(kb): report a paused connector as paused, not superseded

Pausing a queued connector releases its token, so testing ownership before
status reported every pause-while-queued — the common case — as a superseded
dispatch. The mismatch is the symptom there; the status is the reason.

* fix(kb): stop a status update landing on a run that already started

The update's guards ran against a row read moments earlier and the write
carried no compare-and-set, so a worker taking the lock in between meant the
write landed on a `syncing` row — overwriting the run's status and, because
leaving `pending` also clears the lock columns, wiping the token its heartbeat
and terminal write match on. That stranded a sync that had already begun.

The write is now conditional on the status the request was authorized against,
and a lost race is reported as a conflict rather than "not found".

Also restores the in-flight guard on the pause control. The optimistic status
flip relabels it Pause -> Resume immediately, so a second click could send
`active` before the first pause settled and resume a connector the user meant
to pause. Read from the mutation's own pending state rather than the local id
set this PR removed — React Query already knows which row is in flight.
2026-08-21 20:53:16 -07:00
Siddharth Ganesan 58aa6379e0 feat(cli): add chat command (#6937)
* feat(cli): add chat command

* fix(cli): harden chat command execution
2026-08-21 12:59:08 -07:00
Theodore Li 5b28da1989 fix(tables): accept plain row query predicates (#6916)
* fix(tables): accept plain row query predicates

* fix(cli): show table predicate group syntax
2026-08-20 20:48:47 -04:00
Theodore Li a99f61bee9 feat(api): add v2 resource management endpoints (#6900)
* feat(api): add v2 resource management endpoints

* fix(cli): gate destructive v2 commands

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

* feat(cli): add shared workspace profiles
2026-08-20 18:55:57 -04:00
Waleed 4f9d5f33b0 improvement(search): search every folder, and document real API error bodies (#6861)
* improvement(search): search every folder, and document real API error bodies

Search on Files, Tables, and Knowledge was ANDed with the open folder, so a
query only ever matched that folder's direct children — and the query was not
cleared when you entered a folder, filtering the folder you just opened down to
the same matches. A non-empty query now searches the whole workspace, a
Location column names each result's folder, and opening a folder ends the
search.

Also gives GET /api/v2/files a `recursive` flag, and replaces the single shared
OpenAPI error example — which showed `BAD_REQUEST` under every status tab — with
one real body per status.

* fix(search): discard the search term on clear instead of masking it

`useSearchFilterValue` returned the debounced term whenever the input was
non-empty, so clearing only hid the settled needle. The mask lifted on the next
keystroke while the debounce still held the pre-clear term — opening a folder
and typing within the window searched the whole workspace for the query the
user had just abandoned.

A clear now resets the settled term rather than hiding it, adjusted during
render so the reset is visible to the render that follows the clear. The
initial state is seeded from the first value so a deep-linked `?search=` still
filters on the first render.
2026-08-19 13:55:53 -07:00
Waleed 374c1064ea chore(cli): release 2.1.0 (#6826)
The publish workflow derives the npm version from this field, and on `main`
it uses it verbatim: `2.0.0` was already on the registry, so the release step
skipped and every change since then stayed unpublished. Staging and dev never
showed it because they append `-preview.N` and `-dev.N`, which are always new.

So the merged CLI work — the redirect refusal, the default endpoint, folder
path encoding, the request bound, the User-Agent, `run --follow`,
`runs wait` and `logs follow` — is on `main` but not on npm, and `sim@latest`
is still the build whose every write is dropped by the apex redirect.

Minor rather than patch: three commands are new and the default endpoint
changed.
2026-08-18 15:33:08 -07:00
Waleed 1c69372cba feat(cli): follow a run, wait for one, and tail the log (#6813)
* feat(cli): follow a run, wait for one, and tail the log

Three commands the surface was missing, each polling or streaming something
the generated command layer cannot express.

`workflows run --follow` renders the SSE the execute route already emits, so
a multi-minute agent run stops printing nothing until it ends. It rides on
the generated `run` leaf rather than a sibling command — same operation, one
different response encoding — and delegates to the handler it replaced, so
every non-follow invocation still runs the generated path. Answer text,
thinking and tool calls go to stderr; only the final envelope reaches
stdout, so redirecting still yields the result. Reasoning and tool frames
need the `X-Sim-Stream-Protocol` header, which is sent only when asked for,
because negotiating also switches answer text to live chunks the server may
retract.

`workflows runs wait` closes the loop `--async` opens. Terminal is
completed, failed or cancelled; `redacting` is not, since a run whose output
is still being scrubbed is not yet a run you can read. A time pause keeps
polling because the server resumes it, and a human pause stops with the
resume command rather than burning the bound and calling it a timeout.
Distinct exit codes keep cancelled and paused from reading as failure. The
bound is `--wait-timeout` and not `--timeout`, because SIM_TIMEOUT_SECONDS
already bounds one request and two knobs of the same name hide each other.

`logs follow` tails runs as they arrive. Dedup keys on run id, not on the
timestamp: a schedule fan-out starts many runs in the same millisecond, so a
timestamp watermark either drops the siblings or reprints them. JSON output
is one object per line, because a follow never closes an array, and the
table header is printed once so columns stay aligned across polls.

* fix(cli): disclose a truncated burst, and clear a stale retry notice

Two review findings in `logs follow`, both verified against the code first.

The page budget bounds one poll so an enormous burst cannot stall the follow,
but on reaching it the live cursor was discarded: the remainder is older than
everything collected and the next poll restarts at the newest page, so those
runs were never printed and nothing said so. The budget stays — draining
without one trades a bounded poll for unbounded buffering in a process meant
to run for hours — but hitting it now warns on stderr, naming the count and
pointing at `sim logs list`. That notice is written even off a terminal,
because a piped log is where an unexplained hole is hardest to spot.

The retry notice was cleared after the empty-rows check, so a poll that
recovered but found nothing left "retrying in Ns…" on screen while the follow
was already healthy. Clearing now happens as soon as a poll succeeds.

The second test needed two failures to be worth anything: the teardown clears
the line either way, so what separates fixed from broken is whether a bare
erase lands before the second notice or only at the end. The first version
passed against the bug.

* test(cli): pin that a mixed page is the watermark, not a truncation

A page holding a run already printed proves the follow caught up, so the
truncation warning must not fire there — that is how every healthy poll
terminates, and warning would report a hole on the ordinary path. The
straggler sharing that page is still collected, because the filter takes
every unprinted row on it rather than only those above the known one.

* fix(cli): say when the requested backlog was larger than a page holds

The logs API clamps `limit` into 1–1000 rather than rejecting it, so
`logs follow -n 5000` came back with 1000 rows, anchored the floor to that
partial page, and said nothing. The seed already knew — it computes whether
a live cursor remained — but the caller discarded the answer.

Guarded on both halves. Fewer rows than asked for is only a shortfall when
more were waiting: a workspace holding ten runs answers `-n 50` with ten and
nothing is missing, so warning on the row count alone would fire on every
small workspace. The cursor is what separates the two.
2026-08-18 11:43:16 -07:00
Waleed 0e84e92d39 fix(cli): bound, trace, and explain the requests the CLI makes (#6798)
* fix(cli): bound, trace, and explain the requests the CLI makes

Four transport gaps, all of which failed silently.

A request had no timeout, so a connection that was accepted and then never
answered hung the terminal indefinitely. `SIM_TIMEOUT_SECONDS` now bounds
one, defaulting to 3600s — deliberately above every timeout the server
itself applies, since a synchronous workflow run is allowed 3000s on a paid
plan and a tighter default would abort real work and report it as a
transport failure. `0` removes the bound, for a self-hosted deployment that
runs executions without one of its own. The caller's abort signal is
composed with the timeout rather than replaced, so neither masks the other.

Node ignores HTTP(S)_PROXY unless NODE_USE_ENV_PROXY opts in, and only from
v22.21 and v24.5, so on a network that reaches the API only through a proxy
every command failed to connect while the variable that would have fixed it
was already set. The CLI cannot enable that from inside the process — Node
reads it at startup — so it says what to do rather than bundling an HTTP
stack for a setting the platform now owns.

An API key was sent to any http:// endpoint with no signal. Now a warning,
not a refusal: http is the documented way to reach a local dev server, and
a deployment terminating TLS at a gateway is real. Loopback stays silent.

`SIM_DEBUG=1` traces method, URL, status and duration. Bodies and headers
are deliberately absent — the request carries the API key, and `secrets set`
carries the secret itself.

All four write to stderr, so a piped stdout stays parseable.

* fix(cli): make the request bound safe on every runtime it supports

Two ways the new timeout could fail before the request was made.

`AbortSignal.any` arrived in Node 20.3 and this package supports Node 20, so
composing a caller's abort signal with the timeout threw a bare TypeError on
the earliest 20.x releases. It is now used when present and composed through
an AbortController when not.

`AbortSignal.timeout` rejects a fractional millisecond outright, and past
2^31-1 ms it does not fail at all — it clamps to 1ms, so the longest timeout
anyone asked for became the shortest. The value is now rounded and refused
above what Node can actually wait, pointing at 0 for an unbounded wait.

Also unstubs env vars between tests: `stubEnv` is not undone by
`unstubAllGlobals`, so a SIM_TIMEOUT_SECONDS set for one test configured
every test after it.

* fix(cli): correct the proxy version table, and classify a timeout mid-body

`runtimeCanProxy` treated any release between 22 and 24 as capable, so on
Node 23 — which reached end of life before the backport — a configured proxy
was ignored and the CLI stayed silent about it, which is the exact failure
the warning exists to report. The table is now the two lines that shipped the
support, and anything after them.

`AbortSignal.timeout` keeps firing after `fetch` resolves, so a bound that
elapsed while the body was still being read — a large `files get` — escaped
the client's own handling and printed a raw TimeoutError stack. The top-level
handler now names it, which covers the streaming path as well as the JSON
one. A user's own Ctrl-C raises AbortError and is deliberately left alone.

* fix(cli): report a timed-out download as a timeout

`files get --output-file` streams the body to disk, and `streamToFile`
converted anything the stream threw into a write failure. So a request bound
elapsing mid-download read as `Could not write <path>: ...`, sending the
reader to check permissions and free space for a timeout they can raise, and
hiding the one instruction that resolves it.

The predicate and that instruction now live beside the timeout that raises
them, so the client, the top-level handler and the download path all say the
same thing. The wrapping stays where it is: the staged-download cleanup runs
off that failure, and rethrowing past it would leak the temporary directory.

* fix(cli): keep a sub-millisecond timeout bounded

Zero is how this function says "no bound", so rounding a positive
SIM_TIMEOUT_SECONDS down to zero inverted the request: anything under
0.0005s asked for the shortest possible timeout and got none at all, leaving
a stalled request to hang. Introduced by the rounding that fixed the
fractional-millisecond rejection.

Floored at 1ms for every positive value; only a literal 0 still disables.
2026-08-17 18:07:36 -07:00
Waleed 0b4d34137b feat(secrets): add optional descriptions to workspace secrets (#6796)
* feat(secrets): add optional descriptions to workspace secrets

Workspace secrets already have a backing credential row with a description
column, but nothing surfaced it. Teammates had no way to record what a
secret is for.

- Add a Description field to the secret detail page, matching the
  integrations credential page, gated on workspace-secret admin
- Fold the value and description editors into one Save/Discard pair and
  one unsaved-changes guard; two guards cannot coexist, since each seeds
  its own same-URL history entry
- Match descriptions in the secrets settings search
- Expose description on GET/PUT /api/v2/secrets and in the CLI

Descriptions are workspace-only: env_personal credential rows are
per-workspace mirrors of one user-global secret, so one saved there would
exist in a single workspace, and a personal secret has no teammates to
inform. The API rejects a description on personal scope rather than
silently dropping it, and omitting it on PUT leaves any existing
description untouched so a value rotation cannot erase it.

* fix(secrets): address review findings on secret descriptions

- Patch the credential detail cache optimistically on update. `onMutate`
  cancelled the detail query but only patched the lists, so a detail-backed
  editor stayed dirty after a successful save until the refetch landed —
  long enough for Discard to restore the pre-save value over the committed
  one, and for Back to open the unsaved-changes guard.
- Memoize `useSecretValue`'s returned callbacks and object, per the hook
  convention, so the composed form's save/discard stop churning per render.
- Reject a description on a personal secret in the domain layer rather than
  only at the v2 boundary. The internal credential update path accepted one
  for any type, writing data every reader hides.
- Normalize an empty description to null so the API and UI agree.
- Correct the secrets documentation, which described a Display Name field
  the detail view does not have and omitted the scope rule.
- Drop the CLI's copy of the 500-character bound; it can't import the
  contract, so a copy only drifts from the message the API already returns.
- Collapse a redundant save guard and align the description write gate with
  the render gate.

Leaves the integrations credential page byte-identical to staging.

* fix(secrets): keep the API docs example and CLI column order stable

Backward-compatibility fixes for anyone who never sets a description.

- Move the blank-to-null normalization out of the contract and into the
  route. A Zod `.transform()` on any property drops the whole request
  schema's OpenAPI examples, which had silently removed the Set Secret
  request example from the published docs.
- Append the CLI `description` column instead of inserting it before
  `updated`. `--output text` is positional, so inserting would shift every
  field an existing script cuts.
- Reject a description on a personal secret with a message that says so,
  rather than dropping the field and falling through to the generic
  "no updatable fields" error.
2026-08-17 17:33:35 -07:00
Waleed 3abac09dc3 fix(cli): name SIM_CONFIG_DIR in help, and fix the similarity score width (#6795)
Two loose ends from the command-surface audit.

`--help` stated where the profile files live and named only `~/.sim`, so it
was wrong for anyone who had set `SIM_CONFIG_DIR` — which every CI job
pointing the CLI at a scratch directory has. The variable is documented in
the README and the guides; help was the one place that omitted it.

`knowledge search` printed the raw similarity double, `0.2818957269585687`:
a nineteen-character column whose last dozen digits cannot separate one
result from another. A `score` format fixes it to four decimals, the width
`cost` already uses, so the column stays put down the page. `json` and
`yaml` still carry the full double, which is what a script compares.
2026-08-17 17:07:58 -07:00
Waleed fe4480d3f7 fix(cli): keep a path prefix in the login URLs (#6793)
* fix(cli): keep a path prefix in the login URLs

`sim login` built both of its URLs with `new URL('/path', endpoint)`. A
leading-slash path is absolute, so it resolves against the endpoint's origin
and drops any path the endpoint carries: a deployment served at
`https://host/sim` sent the browser to `https://host/cli/auth` and polled
`https://host/api/cli/auth/poll`, neither of which exists there.

Every other command builds its URL by concatenation and was unaffected, so
the endpoint looked correct and login alone failed.

Both now go through the client's `buildUrl`, which is exported for it rather
than duplicated — one URL builder for the whole CLI is the point, since two
of them is how the halves drifted apart. Its TSDoc now names the trap.

* test(cli): restore fetch with spyOn so the stub cannot outlive its test

vi.stubGlobal is not undone by restoreAllMocks, so the completed-auth
response would have leaked into whatever ran next. The rest of this file
already spies on globalThis.fetch, which the existing teardown restores.
2026-08-17 16:55:37 -07:00
Waleed 5c37778bbd fix(cli): identify the CLI to the API and name the remedy on every key refusal (#6792)
Sends a `User-Agent` on both request paths. Without one a CLI request is
indistinguishable from any other API traffic, so a bug that reproduces on a
single CLI version cannot be found in the server's own logs; the runtime and
platform ride along because they are the first things asked about a
transport failure only some users hit.

The version moves into its own module so the HTTP client can read it. It
could not import `program.ts` — program builds the commands, which reach the
client — and duplicating the manifest read would let the two disagree.

Also recognises `PRINCIPAL_KIND_NOT_PERMITTED`. The same refusal is raised at
two layers under two codes, and only the workspace-key one was matched, so
the audit-log commands reported "Principal kind workspace_api_key cannot
perform operation audit_logs.list" — accurate, written for a server log, and
missing the one sentence that tells the reader a personal key resolves it.
2026-08-17 16:39:24 -07:00
Waleed 60097c89b4 fix(cli): default to the host that serves the API (#6791)
`sim.ai` answers /api/** with a 301 to `www.sim.ai`, and the client refuses
to follow redirects — a 301 rewrites a POST into a bodyless GET, so
following one turns a write into a silent no-op and hands the API key to
whatever host Location names. Defaulting to the apex therefore failed every
command for anyone who never set an endpoint. Before the refusal shipped it
was quieter and worse: reads succeeded while writes did nothing.

Also trims the provider catalogue from eleven inferred columns to seven.
`docsUrl`, `helpText`, `requiresClientGeneratedCredentialId` and the nested
`fields` are what you read once you have chosen a provider, not what you
scan to choose one, and they pushed the table well past a terminal. Both
ids stay: `credentials connect` names an OAuth provider by `serviceId`,
`credentials create` matches a service account on `providerId`.
2026-08-17 16:25:58 -07:00
Waleed ae2147645c fix(cli): resolve findings from a full command-surface audit (#6788)
* fix(cli): resolve findings from a full command-surface audit

Exercised all 147 commands against a live deployment. Fixes the defects
that surfaced, plus the docs and generator drift they exposed.

Transport
- Stop following redirects. A bare domain that 301s to www silently
  converted POST to GET and dropped the body, so reads worked while every
  write failed with a misleading validation error and login returned 405.
  Both the client and the device flow now explain the redirect and name
  the endpoint to configure, rather than carrying credentials off-origin.
- Report a non-JSON response as one instead of printing the HTML page.
- Name the personal-API-key remedy on a workspace-key refusal, reading the
  machine-readable code the API actually sends.
- Drop union-branch noise from validation errors that contradicted itself.
- Show paging progress on stderr for multi-page fetches.

Output
- Clamp record values for table only. text is the format built for pipes,
  and it was truncating signed URLs and tool source mid-value.
- Infer timestamp, duration, bytes and boolean formatting for API-owned
  keys so undeclared commands stop printing raw ISO and float ms. Skips
  user-defined table cells and leaves json/yaml on the raw payload.
- Render a declared-but-absent field as an em dash; billing credits were
  vanishing silently.

Paths, naming and validation
- Percent-encode folder paths per segment and decode them for display, so
  a folder reads and types as the name shown in the app.
- Reject a malformed endpoint where it is set and where it resolves,
  instead of crashing with a URL parse trace.
- Request the detail level logs list's own columns need; its workflow
  column could never populate.
- Rename three commands that described themselves wrongly and align two
  flags with their siblings. Old spellings still work: hidden, warned on
  stderr, and kept out of help and docs.
- Verify whoami against the API, separating a bad key from an unreachable
  endpoint, and report the workspace by name.
- Correct the --yes help text, which advertised skipping a prompt that
  does not exist.

Docs
- Teach the docs generator that a flag required by the runtime is required,
  and that hidden commands are not documented.

* fix(cli): clear the paging progress line when a page fails

Progress is written without a trailing newline so it can be overwritten in
place, and both paging loops cleaned it up only on success. A page that
threw part-way through left `fetched 1200…` on the line the error was then
printed onto, so the two ran together.

* fix(cli): name a working API root when an endpoint redirects

The suggested endpoint was the redirect target's origin, which drops a path
prefix. A self-hosted deployment reached at https://host/sim was told to set
https://www.host — not an API root, so following the advice replaced one
broken endpoint with another.

Derive it by stripping the request's own path from the target instead, so a
prefix survives, and say nothing about --set-endpoint when the target
resolves to the endpoint already configured: a trailing-slash or path
normalization redirect keeps the origin, and naming the value the caller
already has explains nothing. The login poll shared both faults and now
shares the helper.
2026-08-17 15:55:04 -07:00
Waleed d152fad788 chore(deps): bump js-yaml to 4.3.1 in sim-cli and drop the aged-out release-age waivers (#6784)
js-yaml < 4.3.1 has quadratic CPU consumption in !!omap resolution
(GHSA-5p4m-2wfm-xmqj / CVE-2026-59870). sim-cli builds with
--packages=bundle, so its dev-scoped js-yaml is bundled into the
published CLI. 4.3.1 is already what apps/sim pins, so this collapses
sim-cli onto the hoisted copy.

The minimumReleaseAge waivers for js-yaml and mermaid were temporary
and have both aged past the 7-day window; leaving them behind would
disable the supply-chain gate for those packages indefinitely.
2026-08-17 14:22:38 -07:00
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
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