Skip to content

Commit 9f9aca8

Browse files
committed
fix(v2): correct four stale contracts and clear the merge debris behind them
Five of the reported defects were real and four of them were documentation that had stopped describing its own code. `cleanCellValue` said only "coerce a raw input value"; it also answers `null` for anything the column type refuses, and since the multiselect write path started refusing partial matches that is the difference between a paste storing one option and blanking the cell. It deliberately does not consult `salvage`, which would read the same paste as the option that did resolve — that reading is for writes with no caller to answer, and a typed cell has one. The pairing is now asserted, so a future helper that "improves" the paste by salvaging it fails. `EXECUTE_OPTION_CONSTRAINTS` carried two stacked TSDoc blocks, the second explaining that the enumeration had moved onto the fields; the body schema still told a reader the six combinations were enumerated in the constant. The deployment route's second block orphaned the endpoint documentation above it, and `list-query.ts` kept the TSDoc for a cursor message that now lives, with its own rewritten doc, in `cursor-binding.ts`. Two agents left near-identical essays arguing the same 400-vs-403-vs-409 question about the table ceilings and concluding that neither status changes; the decision is recorded once, in `billing.ts`, and `service.ts` points at it. The credentials use case echoed `sortBy`/`sortOrder` back with a TSDoc explaining that the presenter needs them, which it no longer does — it reads `query.*`. The local upload roots move from the data-plane provider to `core/storage-key.ts`, beside the sidecar suffix, so the cleanup sweep can name what it reclaims without importing the transport that writes it. `documents.test.ts` justified sweeping only knowledge and files for the 413 by saying the same sweep over the other five documents still reported gaps. It does not: widened to all seven, every body-carrying operation publishes it. Three reports did not survive checking, and the evidence is recorded where the next reader will look. An empty rerank result is not the reranker matching nothing — `rerank` asks for `top_n` over a non-empty document list, so an empty array means the response carried nothing usable, which is what `unavailable` already promises. The zero-byte knowledge document is refused on the upload-session path too, by `validateFile`, under both boundary contracts; that parity is now pinned, and it fails if the guard is removed. The MCP re-registration reports exactly the connection fields its SET clause writes, and the create mutation already drops both caches — what lags is the status badge, not the tools, because discovery is gated on `connected` for OAuth rows only.
1 parent 75357db commit 9f9aca8

17 files changed

Lines changed: 168 additions & 80 deletions

File tree

apps/sim/app/api/v2/workflows/[id]/deployment/route.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ export const revalidate = 0
2121
* fallback: it retains the timestamp of a deployment that has since been
2222
* undeployed, so reading it would report a deploy time alongside
2323
* `isDeployed: false`.
24-
*/
25-
/**
26-
* Deliberately head-safe despite issuing a write.
2724
*
28-
* Reading a workflow can trigger a migrate-on-read `workflow_blocks` update when
25+
* Deliberately head-safe despite issuing a write. Reading a workflow can trigger
26+
* a migrate-on-read `workflow_blocks` update when
2927
* `applyBlockMigrations` upgrades a stored block. That write is convergent: it is
3028
* conditional on a migration actually applying, idempotent, and would be issued by
3129
* the next ordinary read regardless, so a `HEAD` only brings it forward.

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { columnTypeOf } from '@/lib/table/column-types'
56
import {
67
cleanCellValue,
78
dateValueToLocalParts,
@@ -160,6 +161,32 @@ describe('cleanCellValue', () => {
160161
expect(cleanCellValue('Nope', column)).toBeNull()
161162
expect(cleanCellValue('Bug, Nope', column)).toBeNull()
162163
})
164+
165+
/**
166+
* The refusal above is `coerce`'s, not the last word the registry has on the
167+
* value: `salvage` reads the same paste as the one option that resolved. That
168+
* reading is reserved for writes with no caller to answer — a CSV row, a block
169+
* output — and a typed cell has one, so this helper must not reach for it. The
170+
* pairing is asserted rather than described so a future helper that "improves"
171+
* the paste by salvaging it fails here.
172+
*/
173+
it('refuses a partial multiselect paste the registry could still salvage', () => {
174+
const column = {
175+
name: 'tags',
176+
type: 'select',
177+
multiple: true,
178+
options: [
179+
{ id: 'opt_a', name: 'Bug' },
180+
{ id: 'opt_b', name: 'Docs' },
181+
],
182+
} as const
183+
184+
expect(columnTypeOf(column).salvage?.('Bug, Nope', column)).toEqual({
185+
ok: true,
186+
value: ['opt_a'],
187+
})
188+
expect(cleanCellValue('Bug, Nope', column)).toBeNull()
189+
})
163190
})
164191

165192
describe('formatValueForInput', () => {

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,18 @@ export function generateColumnName(columns: ReadonlyArray<{ name: string }>): st
1919
}
2020

2121
/**
22-
* Coerce a raw input value to the appropriate type for a column.
23-
* Throws on invalid JSON.
22+
* Coerce a value a person typed or pasted into a cell to that column's type.
23+
* Throws on invalid JSON, and answers `null` for everything else the column
24+
* type refuses.
25+
*
26+
* `null` is what the server would store for the same value, which is the point:
27+
* the optimistic cache and the row that comes back agree. It deliberately does
28+
* not consult `ColumnTypeDefinition.salvage`, which reads a refused value
29+
* lossily — a multiselect paste naming one option that no longer exists blanks
30+
* the cell here rather than storing the members that did resolve. Salvage is
31+
* reserved for writes with no caller to answer, and this one has one: a person
32+
* watching the cell, who is better served seeing the paste refused than seeing
33+
* part of it silently kept.
2434
*/
2535
export function cleanCellValue(
2636
value: unknown,

apps/sim/hooks/queries/mcp.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,14 @@ export function useCreateMcpServer() {
317317
authType,
318318
}
319319
},
320+
/**
321+
* Both caches are dropped, so neither waits out its stale time — but the
322+
* refetched row still reads `disconnected`, because the discovery that
323+
* moves it runs on the tools query this same invalidation kicks off, after
324+
* the list has already come back. The status catches up on the next list
325+
* refetch; the tools do not wait for it, since
326+
* {@link isServerEligibleForDiscovery} gates only OAuth rows on `connected`.
327+
*/
320328
onSettled: (_data, _error, variables) => {
321329
queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(variables.workspaceId) })
322330
queryClient.invalidateQueries({

apps/sim/lib/api/contracts/v2/openapi/shared.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,14 @@ export const WORKSPACE_API_KEY_DENIED =
257257
* {@link WORKSPACE_API_KEY_DENIED} for an operation behind the resource-concealment
258258
* error policy, which rewrites the authorization failure to a not-found response so
259259
* the caller learns nothing about the resource.
260+
*
261+
* Published on no operation today: every one audited so far refuses a workspace
262+
* key through its principal-kind list, which raises an error the concealment
263+
* policy does not rewrite, so all of them say 403. Kept because a concealed
264+
* operation that denies the key through the policy itself would need this exact
265+
* wording, and because `scripts/openapi/documents.test.ts` asserts the file-share
266+
* description does not carry it — inlining the string there would let the guard
267+
* and the wording it guards drift apart.
260268
*/
261269
export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND =
262270
'A workspace API key is rejected as `404` rather than `403`, because unauthorized resources are concealed; use a personal API key.'

apps/sim/lib/api/contracts/v2/workflows.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -816,18 +816,15 @@ export const v2ExecutionErrorSchema = z
816816
export type V2ExecutionError = z.output<typeof v2ExecutionErrorSchema>
817817

818818
/**
819-
* The mutually-exclusive execute option matrix, mirrored from the route's
820-
* post-parse checks in `app/api/v2/workflows/[id]/execute/route.ts`. Kept as one
819+
* That the execute options constrain each other, said once. Kept as one
821820
* exported string so the request-body description and the OpenAPI operation
822821
* description cannot drift from each other.
823-
*/
824-
/**
825-
* The six rejected option combinations used to be enumerated here and pasted
826-
* onto both the operation and the request-body description, restating what each
827-
* field already says. A caller reads the constraint where it applies — on the
828-
* field it constrains — so the enumeration lives on `async`, `stream`,
829-
* `executionTimeoutSeconds`, `includeThinking`, and `includeToolCalls`, and the
830-
* operation says only that the options are mutually constrained.
822+
*
823+
* It deliberately does not enumerate the combinations the route rejects. That
824+
* list used to be pasted onto both the operation and the request-body
825+
* description, restating what each field already says; a caller reads a
826+
* constraint where it applies, so it lives on `async`, `stream`,
827+
* `executionTimeoutSeconds`, `includeThinking`, and `includeToolCalls`.
831828
*/
832829
export const EXECUTE_OPTION_CONSTRAINTS =
833830
'Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.'
@@ -838,8 +835,9 @@ export const EXECUTE_OPTION_CONSTRAINTS =
838835
* (triggerType, draft state, deployment pinning) are NEVER wire fields; they
839836
* are typed options on the execution service.
840837
*
841-
* The six rejected option combinations are enumerated in
842-
* {@link EXECUTE_OPTION_CONSTRAINTS} and enforced by the route after parsing.
838+
* The rejected option combinations are enforced by the route after parsing and
839+
* described on the fields they constrain; {@link EXECUTE_OPTION_CONSTRAINTS}
840+
* only tells a caller that the options constrain each other.
843841
*/
844842
export const v2ExecuteWorkflowBodySchema = z
845843
.object({

apps/sim/lib/api/list-query.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,6 @@ export type CursorKey = string | number
5757
export const INVALID_CURSOR_MESSAGE =
5858
'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.'
5959

60-
/**
61-
* Caller-facing message for a cursor that cannot be read back at all.
62-
*
63-
* Separate from {@link INVALID_CURSOR_MESSAGE} because that one names
64-
* `sortBy`/`sortOrder`, and the lists that mint a wrapped domain token accept
65-
* neither param — `GET /logs` carries its direction in `order`, and
66-
* `GET /billing/logs` takes no sort param whatsoever. Sending those callers to
67-
* inspect a knob their operation does not have is the same wrong-signpost
68-
* problem `UNKNOWN_CURSOR_MESSAGE` was written to avoid on the ledger. The
69-
* actionable half — restart without a cursor — is identical.
70-
*/
7160
/**
7261
* One column of a keyset ordering, with the codec that moves its value through
7362
* the opaque cursor.

apps/sim/lib/credentials/application/list-workspace-credentials.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,6 @@ export interface ListWorkspaceCredentialsInput {
2525
export interface ListWorkspaceCredentialsResult {
2626
credentials: VisibleWorkspaceCredential[]
2727
nextCursorKeys: CursorKey[] | null
28-
/**
29-
* Echoed back because the route's presenter receives only this result, and the
30-
* cursor it hands out has to be stamped with the sort that produced it.
31-
*/
32-
sortBy: ListWorkspaceCredentialsInput['sortBy']
33-
sortOrder: ListSortOrder
3428
}
3529

3630
export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({
@@ -57,7 +51,7 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({
5751
limit: input.limit,
5852
cursorKeys: input.cursorKeys,
5953
})
60-
return { credentials: page.data, nextCursorKeys: page.nextCursorKeys, ...sort }
54+
return { credentials: page.data, nextCursorKeys: page.nextCursorKeys }
6155
}
6256

6357
const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, principal.userId)
@@ -82,6 +76,6 @@ export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({
8276
limit: input.limit,
8377
cursorKeys: input.cursorKeys,
8478
})
85-
return { credentials: page.data, nextCursorKeys: page.nextCursorKeys, ...sort }
79+
return { credentials: page.data, nextCursorKeys: page.nextCursorKeys }
8680
},
8781
})

apps/sim/lib/knowledge/application/search.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,22 @@ describe('knowledge search application use case', () => {
352352
expect(result.results[0]).not.toHaveProperty('rerankerScore')
353353
})
354354

355+
/**
356+
* A resolved call with an empty ordering leaves the caller in the same place a
357+
* thrown one does — vector order, no `rerankerScore` — so it reports the same
358+
* status. It is not "the reranker matched nothing": `rerank` sends a non-empty
359+
* document list and asks for `top_n` of it, so an empty array means the
360+
* response carried nothing usable rather than a legitimate empty ranking.
361+
*/
362+
it('reports unavailable when the call resolves without a usable ordering', async () => {
363+
mocks.rerank.mockResolvedValueOnce({ results: [], isBYOK: false })
364+
365+
const result = await rerankedSearch(true)
366+
367+
expect(result.rerankerStatus).toBe('unavailable')
368+
expect(result.results[0]).not.toHaveProperty('rerankerScore')
369+
})
370+
355371
it('reports skipped for a tag-only search, which has no query to rank against', async () => {
356372
mocks.getTagDefinitions.mockResolvedValue([
357373
{ tagSlot: 'tag1', displayName: 'team', fieldType: 'text' },

apps/sim/lib/knowledge/application/search.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,18 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({
328328
* never completes, so only the success path has to move it. A request with
329329
* nothing to rank — no query text, or no candidate rows — is `skipped` rather
330330
* than `unavailable`: the reranker was never the obstacle. Anything else that
331-
* was asked for and did not run is `unavailable`, including a request that
332-
* reaches here with no model, which no HTTP contract can now produce.
331+
* was asked for and did not produce a usable ordering is `unavailable`,
332+
* including a request that reaches here with no model, which no HTTP contract
333+
* can now produce.
334+
*
335+
* A call that returns without raising but hands back an empty ordering counts
336+
* as `unavailable` too, and it is not the reranker "matching nothing":
337+
* `rerank` asks for `top_n` over a non-empty document list, so a provider that
338+
* ranked them returns one entry per document. Empty means the response carried
339+
* nothing usable — no results, or only indices outside the batch, which
340+
* `rerank` drops. The caller is left in vector order with no `rerankerScore`,
341+
* which is exactly what `unavailable` promises, and retrying is exactly the
342+
* right advice.
333343
*/
334344
let rerankerStatus: RerankerStatus = !input.rerankerEnabled
335345
? 'not_requested'

0 commit comments

Comments
 (0)