The internal execute route used the bare server-tool router, which rejects
VFS tools with 'Unknown server tool: read/glob/grep' — so nearly every
background agent's first discovery call failed (102 in-band calls in one run,
dozens rejected). It now uses the relay's executeTool dispatcher: registered
handlers (VFS, function execute) with permission checks and param
normalization, falling back to the app tool router — the same surface
foreground execution gets.
wait/tail/steer/interrupt titles humanize the slugified agent ids back to
their display names ('Waiting for the first of Digest Workflow Build + 4
more'), and the agent card's parallel-tool suffix uses the same '+ n more'
format.
Lane-scoped span events route to the span handler, which only recorded trace
side effects — no subagent start block was ever persisted (verified: a
seven-agent run stored 104 blocks with zero starts). Grouping then fell back
to keying lane content by agent NAME, so a respawned agent of the same type
merged invisibly into the first one's card until it resolved. The handler now
persists the start block (spanId-keyed and deduped, carrying the display
name) and stamps endedAt on close, giving every invocation its own card.
The mothership group is the turn's own narration, not a delegation card —
collapsing it hid main-lane text and tools until manual expand, which read as
mis-ordered streaming while async subagents interleaved. It keeps the
original live-expand behavior and no status suffix.
Intent parsing is fully removed (turn model, relay handler, persistence
fields, group extraction). The collapsed card's status is the latest tool
call in its RUNNING phrasing — never the completed rewrite, which stays in
the expanded log. Parallel tools show the most recently started still-running
title with a +N for concurrent siblings; between rounds the last title stays
frozen; a closed lane shows the bare name. Nested agent cards compute their
own status recursively from their own items.
The relay's subagent text handler now runs the split-safe intent extraction
as chunks stream: the latest complete tag is stamped onto the lane's persisted
subagent block (subagentIntent) and stripped from the stored prose, so live,
persisted, and replayed views all agree. Per-lane carry handles tags split
across chunks; a never-closing tag flushes back as plain text.
Persisted data proved tool-first subagents (grok search agents) emit zero
prose, so intent tags never stream no matter what the prompt says. The
collapsed card now always narrates: the agent's own <intent> tag when present,
else the latest tool's display title while the lane is live.
The turn-model intent filter only fires for span-scoped subagent lanes, but
this surface also delivers subagent text through the legacy block path — so
<intent> tags flowed through unparsed and rendered as prose rows. Groups now
extract intents from their accumulated text at append time: the last complete
tag becomes the card's status line and every complete tag is stripped from
the rendered prose. Covers span-scoped, legacy, and persisted-reload paths
for both synchronous and background delegations.
Files/tables created through the internal execute route now register on the
chat's resources exactly like the resume driver's executions — the route runs
the same handleResourceSideEffects pass (persistence only; an out-of-band
route has no live event sink, so mid-turn chip pushes are a follow-up).
POST /api/copilot/tools/execute (INTERNAL_API_SECRET, Go→Sim) runs one
sim-server tool through the same server tool router the resume driver uses
and returns the result synchronously — no checkpoint. This is what lets
background (async) subagents write files/tables/knowledge, and lets the main
lane keep streaming (instead of checkpoint-pausing and killing every
background run) while async agents are live.
Subagents now narrate their work through <intent>3-5 words</intent> tags (a
fleet-wide prompt protocol on the mothership side). The turn model streams
each subagent's text through a split-safe tag parser: complete tags update the
agent's currentIntent and disappear from the prose, tags split across deltas
are carried until their close arrives, and a tag that never closes flushes
back as plain text.
The agent card renders as one line — display name (or agent label) plus the
latest intent, replaced inline as the agent shifts gears — and never
auto-expands; expanding to the full tool log is a deliberate click. Only an
outstanding permission prompt or a browser hand-back forces a group open.
Intents persist on the subagent block (and through the legacy persisted-
message paths) so reloads keep the last status, and a renamed reinvocation
now takes the latest name instead of pinning the first.
The wait_agents title ignored the mode argument, so an any-mode wait over
three agents read 'Waiting for 3 agents' while the model narrated waiting for
the first — contradicting the transcript.
The dispatch-time subagent_start fires before the trigger args (and therefore
the name parameter) have streamed; the phase-3 start re-announces the lane with
the name. The block builder was dropping that duplicate wholesale, losing the
name on streaming providers — now it backfills subagentName onto the existing
block instead. (The home turn-model path already reconciled this case.)
A subagent_start whose payload data carries a name (the orchestrator's new
name trigger parameter) now labels the agent group with that mission name —
the agent-type icon stays. The name flows through the live stream path, the
turn model (AgentNode.displayName) and its serialize/rebuild round-trip, and
persisted transcripts (PersistedContentBlock.name), so reloads keep the label.
wait_agents / tail_agent / steer_agent / interrupt_agent get natural-language
running titles (naming the agent id being waited on, tailed, steered, or
stopped) and a Steering→Steered completed-verb rewrite.
* fix(workflow): draw a highlighted edge over the ordinary ones
An edge's z came from the nesting depth of the container it belongs to, and a
highlighted edge kept that depth like any other. A line one level deeper
therefore sat above it and painted straight through the highlight, cutting it
in half wherever the two crossed.
Give a highlighted edge — selected, or connected to the selected card — the top
tier of the edge band instead. Depth only ever ordered edges against each
other, and once the user has picked one out, being drawn whole matters more
than which container it came from.
The tier stays inside the band, below the cards, deliberately: highlighted
edges were elevated over the cards once before and drew across the chrome of
their own endpoints. A line belongs behind cards, knobs and the action-bar
swell whether or not it is highlighted, so ordinary edges give up the top of
the band rather than the band being widened into the cards.
* fix(workflow): elevate the connection preview edge with the rest
It renders highlighted — its data carries `isConnectedToSelection` — but it was
the one call site left taking a depth tier, so the line being drawn could be
crossed by an ordinary edge in a deeper container. Highlighted now means
elevated with no exception.
Also drop the export on the highlighted tier: nothing outside the module reads
it, and the band's tiers are an implementation detail of `getEdgeZIndex`.
* fix(workflow): give the edge highlight one definition
The z-index elevation I added checked canvas selection only, while the edge
darkens for panel focus too — a block open in the editor lights its edges, and
those stayed depth-tiered, so an ordinary edge could still cut through the
highlight. The bug I set out to fix, on the path I had not covered.
The condition already existed in two places and the second one carries a comment
saying it must mirror the first exactly, because a knob checking fewer
conditions than the line leaves a dark line running into a light knob. Adding
the z would have made a third copy, and the finding here is what the third copy
gets you.
One predicate now, in `edge-highlight`, used by the line, the knobs, and the z.
The canvas subscribes to the panel store rather than reading `getState()`, since
the z has to be recomputed when the open block changes.
* fix(workflow): stop a nested block jumping when it leaves its container
`getNodeAbsolutePosition` added the container's header and padding to a child's
position. Those are already in the position: React Flow places a child at its
parent's origin plus its own coordinates, and `clampPositionToContainer` is what
holds it clear of the chrome, flooring it at `LEFT_PADDING` and
`HEADER_HEIGHT + TOP_PADDING`. Counting them twice put every nested node 16px
right and 66px below where it actually renders.
Visible as a block dropping down-right the moment it is dragged out of a Loop,
and as a block landing off-target when dragged into one from the canvas. Also
skewed container hit-testing during a drag and the bounds `fitView` focuses on.
Two callers already knew: both subtracted the same three constants straight back
off to recover a relative position. They now take the difference of two
absolutes, which is what a relative position is. A third place, React Flow's
child `extent`, had its own copy of the numbers — a fourth distinct header
height, 42, against the 40 the card renders — and now reads the same constants
as the clamp, so a drag stops where a drop would put it.
`positionAbsolute ?? getNodeAbsolutePosition(...)` in the fit-view path can also
stop disagreeing with itself: React Flow's own answer carries no offset, so the
two branches returned points 66px apart for the same node.
* refactor(workflow): type the node-utilities block map
`useNodeUtilities` took `Record<string, any>`, so the test fixtures had to be
cast to reach it and nothing in the hook was checked against a real block.
Typing it as `Record<string, BlockState>` surfaced an unsafe read straight away:
the cycle walk re-read `blocks[currentId].data.parentId` after the `while`
condition had tested the same optional chain, on a map where both links are
optional. It now reads the value once and breaks on absence, which is what the
condition was trying to express.
The fixtures follow the hook's own parameter type, so they stay honest without a
cast on either side.
* fix(v2): close four validation holes in the logs and billing surfaces
Each of these answered a caller-supplied value with a 500 or a silently
wrong result instead of a 400.
- `GET /api/v2/logs` accepted any string as `startDate`/`endDate`. The
route constructs a `Date` from it, so `?startDate=abc` reached the
driver's timestamp mapper as an `Invalid Date` and 500'd. Both bounds
now carry `.datetime()`, matching the sibling run list so one timestamp
works on both collections. This narrows the accepted set: a date without
a time and an offset-bearing timestamp are now rejected, and the field
descriptions say "UTC ISO 8601" rather than overpromising "ISO 8601".
- `v2BillingStatusQuerySchema` was the only non-strict query schema in its
family, so a mis-cased `workspaceID` was stripped and the caller got
account-scope billing in place of the workspace scope it asked for — a
wrong answer about money, served as a 200.
- An unresolvable `cursor` on `/api/v2/billing/logs` applied no cursor
condition and restarted the sequence at page 1 while still reporting
`hasMore`, so a pager holding a cursor across a deploy loops over the
first page and counts the same credits on every lap. It is now a 400.
The message does not reuse `INVALID_CURSOR_MESSAGE`, which names
`sortBy`/`sortOrder` params this collection does not accept.
- The logs `status` field disagrees with the run resources for the same
run: the run projection overlays `paused` from `paused_executions`,
so an ordinary human-in-the-loop pause reads `paused` there and
`pending` here. Reconciling would mean joining `paused_executions` in
this read and silently moving live runs between two buckets of a
shipped field, so the divergence is documented on the contract instead.
* feat(v2): expose the MCP tool plane and page the MCP server list
Registering an MCP server through v2 dead-ended: nothing on the public
surface ever ran tool discovery, so connectionStatus, toolCount, lastError,
and lastToolsRefresh stayed at their registration defaults and there was no
way to read a server's tools without opening the UI.
Adds GET /api/v2/mcp-servers/{id}/tools over a thin use case composed from
the existing mcp_servers.tools.discover operation, resolveServerContext, and
mcpService.discoverServerTools. It is personal-API-key-only — discovery
resolves the acting user's own OAuth credentials, which a workspace key
cannot supply — and the contract says so rather than letting callers meet an
unexplained 403. Discovery failures are classified instead of collapsing
into a 500: an unreachable or cooling-down server is a retryable 503, a
stale OAuth grant is a 401.
Also pages GET /api/v2/mcp-servers. It was the one unbounded list on the v2
surface, classified full-set on a bounded-by-construction rationale that
only holds for folder lists; nothing caps how many servers a workspace
registers.
* feat(v2/tables): strict row bodies, a filtered row count, and round-trippable required columns
Three tables gaps from the v2 capability evaluation.
Strictness. Every v2 tables request body is now `.strict()`. The row family
was the whole hole: `POST /query` sent v1's `filter` key answered 200 with a
fully unfiltered page, because Zod strips unknown keys unless told not to. The
same laxity covered the row create/update/delete/upsert/find bodies, the
run and cancel-runs bodies, the enrichment body, and — outside the row family
but the same class — the column delete, view create/update, and export bodies.
A contract sweep now walks every body-bearing tables contract and fails if one
of them stops rejecting an unrecognized key.
Filtered row count. `POST /api/v2/tables/{tableId}/query/count` answers the
question v1's `includeTotal`/`totalCount` answered and the `{data, nextCursor}`
envelope has nowhere to put: how many rows a predicate matches. It binds the
existing `queryTableRows` use case with `includeTotal: true, limit: 1` — no new
domain logic and the same `tables.rows.query` read policy. The use case types
`totalCount` as nullable because paged callers can decline it; this route always
asks for it, so a null is treated as a broken invariant rather than presented as
a fabricated zero.
Required columns. `required` is accepted on create-table, add-column, and
update-column, matching v1. v2 emitted the flag on every read while stripping it
from every write, so a column could not round-trip. Enforcement was already
complete: turning it on over rows with null, missing, or empty cells is rejected
by the domain.
* test(skills): pin the workspace-API-key split as structural, not accidental
A workspace API key can create a skill it can then never update or delete,
which no sibling resource does — so the asymmetry reads like an oversight
worth widening. It is not. Skill edits are authorized by the per-skill
editor row belonging to the acting user, which is why update/upsert/delete
declare a 'read' floor rather than 'write': workspace role is not the
authority. A workspace key carries no user subject, so allowing one replaces
a 403 with an unclassified PrincipalSubjectUserRequiredError that the v2
surface renders as a caller-reachable 500.
Records the reason on the registry and pins it, so the next reader finds the
argument instead of flipping the flag.
* feat(v2): read deployment state, and undo a file delete
Two v2 reads that existed only as a side effect of a mutation.
`GET /api/v2/workflows/{id}/deployment` publishes the state the deploy,
undeploy, and rollback responses carry, plus `needsRedeployment` — which
those responses structurally cannot carry, because they answer at the
moment the draft and the live version are equal. A caller that lost the
mutation response, or that polls from another process, had no way to ask.
Reuses `readWorkflowDeploymentStatus` behind `workflows.read`, the same
use case the internal status and deploy GETs already adapt.
`DELETE /api/v2/files/{fileId}` was a soft delete with no way to see what
it archived and no way to reverse it. `GET /api/v2/files?scope=archived`
pages the archived set and `deletedAt` on the file resource dates each
one; `POST /api/v2/files/{fileId}/restore` reverses the delete through
the existing `files.restore` operation. Restore is not a pure undo — it
returns the file to the root and renames it on a collision — so the use
case now reads the file back and both the response and the OpenAPI
description say what actually came back rather than what was deleted.
`scope=all` is rejected on the list for the reason the internal contract
already gives: it drops the `deleted_at` predicate and cannot use the
partial index. `scope=archived` combined with `folderPath` 404s when the
containing folder was archived too, which the contract documents.
* fix(v2): keep the unresolvable-cursor rejection a 400 on every surface
The cursor rejection lived in shared billing core but was an OrchestrationError
only, which the session-only GET /api/users/me/usage-logs cannot project: that
route is raw withRouteHandler and readTypedError matches instanceof HttpError,
so any signed-in caller typing ?cursor=x got a 500. UnknownUsageCursorError is
an HttpError carrying the OrchestrationError as its cause, so the v2 route still
renders BAD_REQUEST off the cause chain and the internal route answers 400.
Also closes the other half of the run-list parity: an inverted window on
GET /api/v2/logs is now a 400 instead of a silently empty page.
* fix(v2/tables): sweep union bodies per member and name the shapes on a rows 400
Review follow-ups on the strictness work.
The sweep was vacuous on the one union body it covers. Parsing
`{ notAContractField: true }` against `v2CreateTableRowsBodySchema` and looking
for `unrecognized_keys` anywhere in the issue tree is satisfied by either member
alone, so dropping `.strict()` from the single-row branch shipped green —
reproduced, 36/36 passing with the regression in place. The sweep now flattens a
union body into its members and asserts each one separately; removing `.strict()`
from either branch now fails a case that names it.
`POST /rows` answered an unknown key with `Invalid input`, the exact message the
v2 conventions name as failing the actionable-error rule, because a union
surfaces `invalid_union` first. The union now carries a message naming both
accepted shapes; the per-member failures still ride along in `details`.
Two TSDoc corrections. The `required` docstring claimed the domain rejects
turning the flag on over rows with empty cells — true of the update path, false
of add-column, which applies the flag as given (the same shape `unique` already
had here). And `.strict()` binds the top level only, so the view `config` object
and the shared sort-spec elements still strip unknown keys; both docstrings now
say so instead of implying full coverage.
* fix(v2): classify MCP discovery failures by type, not by substring
The tool-discovery error policy consumed categorizeError's status, whose
fallback is a substring match on the upstream message. Three consequences,
all caller-visible:
- A ZodError from the builder's own response `.parse` contains `invalid_type`,
so a Sim-side response-schema defect answered 400 "Invalid request
parameters" and suppressed the builder's 500 and its unhandled-error log.
- An upstream `Invalid params` or `not found` became the caller's 400/404 on a
request the contract had already validated.
- A stale OAuth grant to the third-party server answered 401, the status this
surface reserves for a missing or invalid Sim API key, so a client would
rotate a credential that was never the problem.
The policy now dispatches on the MCP error families and returns null for
anything else. Reauthorization is a 409 carrying
`details.code: MCP_SERVER_REAUTHORIZATION_REQUIRED`; an unreachable, slow, or
cooling-down server is a 503 with a constant message.
Also: widen the shared server path-param description now that it covers tool
listing, map the list query explicitly so no undeclared `cursor` reaches the
use-case input, and document the endpoint's write side effects.
* merge: bring in the MCP tool plane workstream
* feat(v2): make knowledge tags usable and let documents be updated
v2 accepted tag slots on upload and filtered search by tag display name,
but no response ever returned a tag value and nothing listed the
vocabulary, so a shipped feature dead-ended in the public API. A document
that failed processing could only be deleted and re-uploaded, and
retiring 500 documents cost 500 requests.
- GET /api/v2/knowledge/{id}/tags returns the vocabulary (display name,
slot, field type) as a full-set list.
- Document list and detail responses carry `tags`, keyed by display name
exactly as search keys its result metadata. Writes stay slot-keyed; the
tags endpoint is the mapping and the contract documents the split.
- PATCH /api/v2/knowledge/{id}/documents/{documentId} renames, enables,
disables, retags, or requeues processing. Derived indexing state is not
writable: asserting `processingStatus` on an unindexed document would
corrupt search. A retry may not ride along with field updates.
- PATCH /api/v2/knowledge/{id}/documents bulk-enables or bulk-disables.
Bulk delete is deliberately absent — that operation records no semantic
audit, and a public bulk delete would empty a knowledge base leaving no
DOCUMENT_DELETED entries.
- The document list accepts the same name-based `tagFilters` as search;
the name-to-slot resolver moves out of search into a shared helper, and
the filters are stamped into the offset cursor scope so a replayed
cursor cannot cross a filter change.
- Search accepts `rerankerEnabled`, `rerankerModel`, `rerankerInputCount`
and returns `rerankerScore`; `rerankerApiKey` and `skipUsageBilling`
stay unexposed. Every result now names its `knowledgeBaseId`.
knowledge.tags.list flips from workspaceApiKey 'deny' to 'allow' (and
gains the workspace_api_key principal kind) so it matches the sibling
reads knowledge.documents.list / read / search. The vocabulary is
required input for two operations a workspace key can already perform.
Every tag write stays human-delegated.
* fix(v2): name every 403 cause, unfork boolean params, close nested strictness holes
Four cross-cutting consistency gaps on the v2 public surface.
**403s now carry a machine-readable cause.** The conventions skill mandated
`error.details.code` on 403 and nothing emitted one, so a client had to
string-match prose to tell "raise this member's role" from "this workspace
refuses personal keys" from "buy an enterprise plan" — four different
remedies behind one status, and every message reword a silent break. The
vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES`, with a `Record` of
descriptions beside it that the generated OpenAPI 403 description is built
from, so a code cannot reach the wire unpublished. Refusals throw
`ForbiddenOperationError` in the domain and `v2CaughtOrchestrationError` —
the function every v2 error policy falls through to — attaches the code, so a
route cannot forget it. The audit-log resolver distinguished four causes and
collapsed them into one; it now names each.
Cross-tenant refusals deliberately get no code: they are concealed as 404 and
naming their cause would hand back the existence signal the concealment
withholds.
**Two boolean query params rejoin the majority.** `?includeDeparted` and
`?includeOutput` were `'true'`/`'false'` string enums inherited from the
internal shapes they reused, while four sibling params were real booleans.
Both move to `booleanQueryFlagSchema`, which still coerces both strings — a
strict widening, so an existing caller is unaffected, and the spec stops
telling callers to send a string.
**Two nested strictness holes close.** `.strict()` binds the top level only,
so `sort: [{ field, direction, nulls: 'last' }]` was answered 200 with the
null-ordering request dropped, and an unknown key inside a saved view's
`config` was accepted and discarded — the headline `filter` bug one level
down. `sortSpecSchema`'s element and both view-config schemas are now strict.
Safe on the read side because `normalizeStoredViewConfig` projects the
schemaless stored blob onto the declared keys first, so a legacy row cannot
turn into a 500.
The two sort dialects stay as they are. `/logs` and `/workflows/{id}/runs`
have one sortable column, so there is no `sortBy` to pair with; renaming
`order` breaks every caller and an alias is a second spelling of one thing
with undefined precedence. Both contracts and the skill now state the rule.
* style: format the files the workspace-scoped lint gate does not reach
`turbo run lint:check` runs `biome check .` per workspace, so `scripts/` at the
repo root is outside the graph and four changed files were unformatted — one of
them a merge artifact from reconciling the route baseline across branches.
* fix(v2): collapse the four knowledge document projections onto one null-tolerant summary
Extracts toV2DocumentSummary in app/api/v2/knowledge/utils.ts and composes the
list, upload-acknowledgement and detail presenters from it. toV2TaggedDocument
serialized uploadedAt with a bare .toISOString(), so a document with no upload
timestamp threw where every sibling returned null and the contract declares the
field nullable.
Also consolidates the two Zod strictness walkers onto one shared introspection
helper that unwraps wrappers and expands unions, closing the hole where a
union-shaped schema answered null and was skipped by the pagination sweep.
* fix(v2): stop HEAD driving MCP discovery, and unbreak the updatedAt keyset page
B1: Next aliases HEAD onto GET, which RFC 9110 permits only because GET is safe.
The MCP tool-discovery GET is not: it opens a live connection to the registered
endpoint and writes the outcome onto the server row. The v2 JSON builder gains a
headSafe option, default true, and the discovery route declares itself unsafe —
a HEAD is authenticated and rate-limited, then answered bodiless.
B2: a discovery status write stamped updatedAt, which this branch added as a
keyset sort, so any concurrent discovery duplicated and skipped servers across a
caller's pages. Discovery liveness already has lastConnected, lastToolsRefresh,
lastError and statusConfig.
B4: a public refresh now skips the positive cache but keeps the failure cooldown,
so it cannot be used to drive a connection attempt per request at a failing
endpoint. An explicit user action on their own server keeps the full bypass.
B6: the consecutive-failure counter is incremented SQL-side rather than read,
incremented and written back, and the success branch carries the same workspace,
liveness and staleness guard the failure branch already had.
* fix(v2): bound the bulk update echo, close the search leak, and make the docs true
B3: a selectAll bulk document update echoed every changed identifier, which the
request does not bound — a 100k-document knowledge base produced a multi-megabyte
array, materialized and then element-wise validated. The use case now reports
whether the selection was unbounded and the presenter omits the echo.
A1: the knowledge search presenter spread the whole use-case result, which also
carries userId, workspaceId, a cost breakdown and a live secret-trace registry.
Only Zod's default key-stripping kept them off the wire. Projected explicitly.
P1-a: GET /knowledge/{id}/tags advertised all 17 slots while the document PATCH
accepted only the seven text ones. The writer already coerces every slot type,
so the PATCH now takes all 17 in their declared types, with a 400 where a
malformed value used to silently clear the tag.
P1-b: both new PATCHes deny workspace API keys and now say so.
P1-c: the two table query reads declare maxBodyBytes and now document the 413.
P1-d: getWorkflowDeploymentV2 loses its legacy suffix.
C3: deletes two orchestration error mappers with no callers that mapped
'forbidden' with no details.
D2: a stored null in table_views.config survived the pick and failed the
response schema.
Also folds the six 'bounded set' paraphrases onto one FULL_SET_LIST constant,
shares the run-window date bound between the logs and runs lists so their
documented parity is enforced rather than asserted, adds the missing barrel
export for FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, and strictens two response
schemas whose peers were already strict.
Migrates 40 v2 route tests onto the shared @sim/testing harness: 26 asserted a
rateLimitSubjectIds shape v2 auth never returns, 26 asserted the wrong
refillRate, 33 could not exercise their 401 path at all, and 6 hard-wired the
rollout gate to null.
* fix(mcp): bound the connect handshake, and stop the 403 description over-claiming
B5: the connect clamp was getMaxExecutionTimeout(), the workflow ceiling of
seven days, so the real bound became the server row's own timeout — which the
registration contract permits up to 300s — times the connect retries. A slow
server could hold a Node request for roughly twenty minutes. Connecting is not a
workflow run, so the handshake now shares the one-minute ceiling tools/list
already applies to itself.
C2: the generated 403 description asserted that error.details.code names the
cause on every 403. Nine domain refusals still throw a bare forbidden
OrchestrationError and reach the wire codeless, so the wording now says 'where
the cause is one a caller can act on'. Reparenting those throws is left as a
deliberate change: one of them is a cross-tenant refusal that belongs in the
codeless class and would change its status.
* chore: reconcile the route ratchet with staging
* style: sort imports and format the three files biome flagged
* fix(openapi): import the forbidden-code constants from their module, not the application barrel
The barrel also re-exports the authorized use-case layer, which loads
@sim/db at import time. That pulled a database connection into the
OpenAPI spec check, so check:audits failed wherever DATABASE_URL is
absent, including CI.
* fix(workflow): stop subflows resizing themselves after every load
A container sized itself from its children, and when a child had not yet
reported a height it used `estimateBlockDimensions` in its place — a guess of
`ceil(subBlockCount / 2)` rows, which read a 39-field Gmail card as 276px tall
against the 112px it draws. The container painted that number, the real height
arrived a frame later, and it visibly resized between the two. Nothing is
persisted, so it happened on every refresh.
A card's height depends on what it actually renders — which rows survive its
conditions, whether it draws a summary sentence, and for a reactive field even
a credential it has to fetch — so the card is the only thing that can know it.
Size only from heights the children have themselves reported, and hold the
container at its current size until they have. `getBlockDimensions` keeps the
estimate for the callers that only need a rough box (clamping a drag, placing a
paste) and is now that same lookup plus the fallback.
Also stop `calculateContainerDimensions` counting the container's chrome twice.
Child coordinates are relative to the container's own origin and are already
held clear of the header by `clampPositionToContainer`, so a child's far edge is
the distance to cover and only the trailing padding is owed on top. Adding the
header and leading padding again left every container 66px taller and 16px
wider than its contents.
* fix(workflow): gate container sizing on this session's reported layout
Two holes in the measurement gate, both from reading the wrong field.
`height` is a persisted column and `data.width` / `data.height` persist a
container's last size, so a block that has not reported yet can still carry last
session's numbers — reachable through paste, import, and checkpoint restore. The
gate treated those as reported and sized from them.
Nested containers had it worse: an inner container with an unreported descendant
handed back its 500x300 default as though it were measured, so the outer
container sized to that and resized again once the descendant filled in — the
same two-step this change exists to remove.
`layout` is in-memory only and written by exactly the two places that know: a
card through `updateBlockLayoutMetrics`, a container through
`updateNodeDimensions`. Reading it means "reported during this session" and
nothing else, and an inner container that is still waiting reports null, so the
outer one waits with it.
`getBlockDimensions` keeps the persisted height and the estimate as fallbacks —
its callers only need a rough box, where a stale height still beats a guess.
* Revert "fix(workflow): gate container sizing on this session's reported layout"
This reverts commit 3cce724832.
* fix(workflow): size containers from a state-aware child estimate
The gate in the reverted commit held a container at its current size until its
children reported. That is worse than it sounds: the size it holds is the
persisted default of 300, the child needs 335, and so the child hung outside
the container until something forced a resize.
Estimate accurately instead of waiting. `getBlockMetrics` derives a card's
height from the block's own state — the sub-blocks its values leave visible, the
summary sentence, the error row — through the same
`calculateWorkflowBlockDimensions` the card calls, and lands on the height the
card goes on to render: 112px for the Gmail card the type-only estimate put at
276px. The pass before the cards report and the pass after now produce the same
container, so there is nothing to gate and nothing to correct.
This also fixes the guess everywhere else it was painted rather than only in the
container path — `estimateBlockDimensions` fed React Flow's node height for
unmeasured blocks, so selection bounds were 276px around a 112px card.
* improvement(workflow): even out the gutter inside a container
Left, top and bottom were 16 and the bottom read tighter than either, because
the 50px header sits above the top gap and gives that edge visual weight the
other two do not have. Taking them to 24 leaves the three gutter-only edges
matching and the bottom no longer pinched.
Right stays 80. The container's output handle sits on that edge, so a child
needs clearance there it does not need anywhere else — chrome rather than
gutter, now said so in the type.
Only reachable as a single constant each because the paddings mean what they
say: each is the gap between a child's edge and the container's, counted once.
While the sizing math added the header and leading padding a second time, the
effective bottom gap was spread across three constants and tuning it meant
reasoning about all of them.
* improvement(workflow): give a container one source for its own gutter
The four paddings and the header height existed twice: as
`CONTAINER_DIMENSIONS`, which sizes a container and clamps its children, and
again as Tailwind literals in `subflow-node-view`, which draws the header and
the content box. Nothing kept them in step and they had already drifted — the
view rendering a 40px header against a constant claiming 50, so children were
clamped 10px below where the header actually ends.
The view now renders from the constants, and the constant follows the DOM at 40.
Match the bottom gutter to the right at 80. The two edges that carry chrome are
now the two that are wider: the container's output handle sits on the right, and
the resize grip in the bottom-right corner spans 40px in from both, so a child
at the 24px gutter width could sit underneath it. Left and top are only gutter
and stay at 24.
* test(workflow-renderer): assert the subflow header's height, not its class
The header renders from `CONTAINER_DIMENSIONS.HEADER_HEIGHT` now, so the class
it used to carry is gone. Assert the rendered height against the same constant
the layout math measures against — the two drifting apart is what this whole
change is about, and a utility-class assertion cannot catch that.
Also set `IS_REACT_ACT_ENVIRONMENT`, which these tests have always needed. React
only treats `act` as supported when it can see the flag, so every render logged
"The current testing environment is not configured to support act(...)" — around
forty lines of it per run, burying the actual failure output.
* fix(workflow-renderer): declare the act-environment global
`vitest.setup.ts` is inside the package's tsconfig, so assigning an undeclared
property on `globalThis` failed type-check (TS7017) even though the tests ran.
* fix(workflow): size a container from one snapshot of the store
`calculateLoopDimensions` took child positions from the live store but child
dimensions from the hook's render snapshot, so it was reading two ages of the
same data. `resizeLoopNodes` walks deepest-first: an inner container resized
earlier in the pass was already updated in the live snapshot and still stale in
the closed-over one, so its parent sized against the old inner box and only
caught up on a later render — a nested container visibly resizing twice, which
is the symptom this branch set out to remove.
Take both from the snapshot the function already reads.
* fix(workflow): size an unmeasured note as a note
Routing every non-container block through `getBlockMetrics` sent notes through
the workflow-card estimate, which counts sub-block rows and an error row a note
does not have. A note that had not reported a height yet got a card's box, so a
container holding one sized itself around the wrong shape.
Give a note its own branch, as the estimate it replaced did: measured height
when there is one, and the height an empty note paints when there is not.
* feat(windchill): add document integration
* fix(windchill): align tool contracts and docs
* fix(windchill): use official integration icon
* fix(windchill): correct response and paging semantics
* fix(windchill): align execution and API contracts
* refactor(windchill): inline route authentication
* fix(windchill): correct OData query encoding, content download, and cleared-field handling
Validated the integration end to end against PTC Windchill REST Services 2.7
documentation and fixed every divergence found.
Protocol correctness:
- Encode OData query spaces as %20 rather than the form-encoded `+` that
URLSearchParams emits. Every multi-token $filter and $orderby reached
Windchill as a literal `+` and could not match.
- Download content through the documented typed navigation
`<content>/PTC.ApplicationData/Content/URL`, which returns a signed vault
URL, instead of a `$value` segment that WRS does not implement. The
resolved URL is pinned to the configured HTTPS origin.
- Terminate every Stage 2 CacheDescriptor_array entry with `;` to match the
documented grammar.
- Raise the $top bound to Windchill's documented 2000 maximum, keeping 200 as
the default page size.
Cleared-field handling:
- The executor merges raw block inputs before the block's param transform, so
omitting a key could not clear it. A cleared numeric or boolean field
reached the URL builder as '' and threw, and cleared optional strings failed
contract validation. Coercions now emit an explicit undefined, and the
internal-route body strips blanks centrally.
Robustness and contracts:
- Bound the document-structure walk to the depth actually requested.
- Loosen response schemas that re-applied request-side bounds to
provider-returned values, which turned committed mutations into opaque
parse failures.
- Return contract-shaped bodies for oversized, malformed, and unhandled
request failures.
- Normalize downloaded content types and drop charset parameters.
Presentation and docs:
- Square the icon to a centred tile on white.
- Replace WT.Document and PATCH-compatible jargon with plain language.
- Fix canvas sentence noun stutters on the bulk operations.
- Correct the revision skill's unverified working-copy claim to read the OID
back rather than assume it, and add retirement and stale-checkout skills.
- Add a manual intro section to the integration docs page.
* fix(windchill): align tool copy with the docs page and rebase the route baseline
Tool descriptions feed both the integration catalog and the generated docs page,
so the plain-language pass had to reach them too: drop WT.Document and
PATCH-compatible from the operation copy, and correct the $top bound the
descriptions still advertised as 200.
Correct the docs intro's attachment wording, gloss OData on first use, and
attribute the bulk-atomicity claim to PTC's documented behavior.
Raise the API route-count baseline, which staging advanced while this branch
was behind.
* feat(windchill): add update common properties
Name, Number, and Organization are rejected by the PATCH-based update
operation, and the rejection message told users to reach for Windchill's
UpdateCommonProperties action that the integration did not expose. Add it.
PTC documents UpdateCommonProperties as a bound DocMgmt action taking an
Updates wrapper, available when hasCommonProperties is set on the Documents
entity, and refused while the document is checked out. The subblock and param
descriptions carry that constraint, and the rejection message now names the
operation that does the job.
* test(windchill): assert block and tool params stay aligned for every operation
Validating the new operation surfaced that nothing enforced the block-to-tool
alignment the review process had been checking by hand. Assert it for all 27
operations instead: every required tool param has a required, non-advanced
input under that operation's condition, and no operation shows an input its
tool cannot accept.
Both fail on a deliberately broken condition or a dropped required flag.
---------
Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
* improvement(workflow): smooth the running hatch's slanted edges
The marks read as stepped rather than slanted. A repeating gradient is sampled
once per pixel with no coverage term, so a hard colour stop on an edge 15° off
vertical can only land wholly on one side or the other — there is no partial
value to soften the transition, and the staircase is the whole edge on a mark
this thin.
Ramp each edge over 0.75px, roughly a device pixel, instead of switching colour
at a single offset. That hands the rasterizer the intermediate values
antialiasing would have produced: measured deviation of the edge from its own
straight line falls from 0.28 device px — pure quantization — to 0.05.
The ramps are centred on the offsets the hard stops used, so the 50%-coverage
line does not move: same 75° lean, same 24/2 rhythm, same 26px scroll period.
* improvement(workflow): sit the running hatch in the slot's own box
The hatch was inset 4px into a 24px row, so it stood 16px tall inside a swell
whose slots are 24px — it read as a shorter bar floating inside the row rather
than as the slots themselves filling, and its right end stopped short of where
a hovered slot's fill ends.
Span the row instead. The row already sits inside the container's 2px/3.2px
inset, so occupying it outright puts the hatch in exactly the box a slot's
hover fill occupies: same height, same padding in from the swell on every side.
The end taper has to move with it, since its two numbers were read off the
slot's diagonal at the old overlay's top and bottom (y=4 and y=20). Continuing
that same edge — slope 20/24 — across the full row gives 20px in at the top and
flush at the bottom, so the hatch still ends on the slot's own diagonal.
* fix(workflow): feather both hatch edges, not just one
The trailing ramp straddled the period boundary. Anchored at 0, the mark's
leaving edge ramped 24.735 → 25.485, but a repeating gradient truncates at its
own wrap, so it was cut at 25.11: half the feather, and its 50%-coverage line
pulled 0.19px inward. That edge stayed sharper than the other and the gap
rendered 1.75px instead of 1.93px.
Run the period centre-of-mark to centre-of-mark instead, so both ramps sit
strictly inside it. The stop list still tiles backwards from its first stop, so
the marks land where anchoring at 0 put them — measured pitch is unchanged at
26px and both edges now carry the full 0.75px.
Handing back the `nextCursor` from any timestamp-sorted v2 list and passing
it straight in returned 500. The keyset compares millisecond-truncated
timestamps on both sides, and the bound cursor value went out as a bare
placeholder — which Postgres types as `unknown`. `date_trunc` is overloaded
across `timestamp`, `timestamptz`, and `interval`, so `date_trunc(unknown,
unknown)` matched no single candidate and the statement failed outright.
The value was already validated; it just carried no type. Cast it to the
column's own SQL type inside `timestampKey`, so all twelve call sites across
six modules inherit the fix. Derived from the column rather than hardcoded,
which keeps a `timestamptz` column's offset honoured too.
The millisecond truncation is unchanged — it is what stops the page's own
last row being re-admitted.
* fix(copilot): surface document render failures
* Address PR review feedback (#6629)
- validate render errors against workspace-file provenance before returning details\n- cover blocked provenance with a regression test
* Address PR review feedback (#6629)
- mark every non-throwing render failure as a failed dynamic read\n- cover all soft render failure paths with producer-level tests\n\nNote: pre-existing type-check failures in HEIC and provider files are not addressed by this PR.
A block's tile disagreed with the card it named. The canvas brands only
third-party integrations and gives everything first-party its role accent,
but the command palette, connection lists, tag menus and output pickers all
painted straight from the catalog `bgColor` — so Webhook Trigger showed green
in the palette and blue on the canvas it was about to be dropped onto, and the
five roleless first-party triggers showed catalog blues where the canvas shows
neutral.
Read the canvas rule from one place (`hasBlockAccent`) and render it through
one component (`BlockTile`), then point every surface that lists a block at
them: canvas, editor header, preview, toolbar, palette, connection picker,
terminal, logs trace rows, connection lists, tag menus, output pickers and the
tables workflow sidebar.
Folds in the duplication the split had grown: three copies of `TagIcon`, five
hand-rolled tile divs, the toolbar's second encoding of the accent rule, a
third icon-contrast helper on its own brightness threshold, and the dead
`showColoredIcon` prop every caller passed. Tiles now share the chip radius,
and the tile forces its own icon colour so popover and command rows painting
`[&_svg]:text-*` can no longer wash out a pale brand tile.
Large detail headers (preview panel, trace-view detail) keep their own
treatment and are left for a follow-up.