Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1082a18
fix(api): name the failing field once in v2 validation errors
waleedlatif1 Nov 11, 2026
b48982b
fix(api): call the v2 error id requestId
waleedlatif1 Nov 11, 2026
0984f96
fix(api): describe what the workflow outline actually omits
waleedlatif1 Nov 11, 2026
5cea1cc
fix(api): prove the workspace schedules list against Postgres
waleedlatif1 Nov 11, 2026
2330195
chore(skills): include the optional requestId in the v2 error shape
waleedlatif1 Nov 11, 2026
8f84af5
fix(cli): pick the handoff for --expires-in and name a refused key
waleedlatif1 Nov 11, 2026
5fe1ecf
fix(api): publish the workspace logoUrl pattern without a lookahead
waleedlatif1 Nov 11, 2026
8e34af1
chore(cli-auth): drop the poll-body half of a test that could not fail
waleedlatif1 Nov 11, 2026
4acfc1e
fix(workflows): refuse withheld blocks before the operations lock
waleedlatif1 Nov 11, 2026
7ebf094
chore(api): document the CONFLICT 409 from Apply workflow operations
waleedlatif1 Nov 11, 2026
1663e42
improvement(workflows): prove the operations conflict guard against P…
waleedlatif1 Nov 11, 2026
3dd16fc
chore(api): tighten comments and the field-label check from review
waleedlatif1 Nov 11, 2026
1d78cea
fix(cli): name a refused handoff key exactly
waleedlatif1 Nov 11, 2026
260c81f
fix(workflows): run the conflict-guard races on their own connections
waleedlatif1 Nov 11, 2026
f244516
chore(cli-auth): declare the poll key name the server always sends
waleedlatif1 Nov 11, 2026
7a44971
fix(workflows): count lock waiters queued behind another waiter
waleedlatif1 Nov 11, 2026
5b8e64f
fix(cli): probe the endpoint before an --expires-in handoff
waleedlatif1 Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/v2-api-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The v2 surface makes one promise: **every response is the same two shapes, and a
```
success (single) { "data": {...} }
success (collection) { "data": [...], "nextCursor": "..." | null }
failure (always) { "error": { "code": "...", "message": "...", "details"?: ... } }
failure (always) { "error": { "code": "...", "message": "...", "details"?: ..., "requestId"?: "..." } }
```

Nothing else at the top level. No `success: true`, no bare `{ "error": "string" }`, no HTML.
Expand Down Expand Up @@ -214,7 +214,7 @@ The consequence to keep true: **never put a resource id, filter, or scope into a

Run this against any new or changed v2 endpoint.

- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
- [ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?,requestId?}}`, where `v2Error` alone sets `requestId` (the `x-request-id` value).
- [ ] Route uses a shared builder; no hand-built `NextResponse.json`.
- [ ] Query and body schemas are `.strict()`.
- [ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/api-reference/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,12 @@ Rejected v2 requests use HTTP error status codes and a stable code/message envel
"error": {
"code": "NOT_FOUND",
"message": "Workflow not found",
"traceId": "3f9a1c7e"
"requestId": "3f9a1c7e"
}
}
```

Every response carries an `x-request-id` header, and an error repeats it as `error.traceId`. Include it when you contact support about a failed request.
Every response carries an `x-request-id` header, and an error repeats the same id as `error.requestId`. Quote it when you contact support about a failed request.

| Status | Meaning | Action |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ sim login [options]
| `--method <method>` | No | Credential to obtain: oauth requires OAuth support; api-key creates an API key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. |
| `--no-browser` | No | Print the approval URL without opening it (either login method). |
| `--read-only` | No | Ask only for permission to read, never to change anything. |
| `--expires-in <days>` | No | API-key login only: days until the key expires (1-365); omit for a key that never expires. |
| `--expires-in <days>` | No | Log in with an API key that expires after this many days (1-365); without it an API key never expires. |
| `--callback-port <port>` | No | Pin the local port the browser returns to. |
| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. |

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ sim login [options]
| `--method <method>` | No | Credential to obtain: oauth requires OAuth support; api-key creates an API key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. |
| `--no-browser` | No | Print the approval URL without opening it (either login method). |
| `--read-only` | No | Ask only for permission to read, never to change anything. |
| `--expires-in <days>` | No | API-key login only: days until the key expires (1-365); omit for a key that never expires. |
| `--expires-in <days>` | No | Log in with an API key that expires after this many days (1-365); without it an API key never expires. |
| `--callback-port <port>` | No | Pin the local port the browser returns to. |
| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. |

Expand Down Expand Up @@ -5126,7 +5126,7 @@ sim schedules list [options]
| --- | --- | --- |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
| `--cursor <value>` | No | Continue from nextCursor returned by a previous result. |
| `--sort-by <value>` | No | Field used to sort the result. Defaults to "createdAt". Accepted values: `createdAt`. |
| `--sort-by <value>` | No | Field used to sort the result. `createdAt` is when the schedule was created for the deployment version that runs it: each deploy creates new schedule rows, so a redeployed workflow's schedules move to the newest end. Defaults to "createdAt". Accepted values: `createdAt`. |
| `--sort-order <value>` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. |

</CommandTable>
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli/schedules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ sim schedules list [options]
| --- | --- | --- |
| `--limit <n>` | No | Maximum items to return (0 for everything). Defaults to `100`. |
| `--cursor <value>` | No | Continue from nextCursor returned by a previous result. |
| `--sort-by <value>` | No | Field used to sort the result. Defaults to "createdAt". Accepted values: `createdAt`. |
| `--sort-by <value>` | No | Field used to sort the result. `createdAt` is when the schedule was created for the deployment version that runs it: each deploy creates new schedule rows, so a redeployed workflow's schedules move to the newest end. Defaults to "createdAt". Accepted values: `createdAt`. |
| `--sort-order <value>` | No | Sort direction. Defaults to "desc". Accepted values: `asc`, `desc`. |

</CommandTable>
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-billing.json
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -4744,8 +4744,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -6049,8 +6049,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-logs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1096,8 +1096,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -15082,8 +15082,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down Expand Up @@ -15266,7 +15266,7 @@
"anyOf": [
{
"type": "string",
"pattern": "^(\\/(?!\\/)|https:\\/\\/)"
"pattern": "^(\\/$|\\/[^/]|https:\\/\\/)"
},
{
"type": "null"
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -6008,8 +6008,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down
14 changes: 7 additions & 7 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@
"get": {
"operationId": "getWorkflowOutline",
"summary": "Get workflow outline",
"description": "Read the saved draft as a compact outline: connections embedded in their source blocks, loop and parallel children nested under their container, and empty values and editor-only fields omitted. Requires workspace read access, and reveals nothing Get workflow state does not: its derived fields, such as `triggerWebhookUrl`, are computed from values that state already returns. The outline is read-only: it adds those derived fields and omits positions and edge handles, so never send it to Replace workflow state; edit with Apply workflow operations or a full state from Get workflow state. This read records no audit event; `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.",
"description": "Read the saved draft as a compact outline: connections embedded in their source blocks, loop and parallel children nested under their container, and null values omitted. Fields hidden from agents are omitted too, some of them real configuration, as are blocks whose parent container no longer exists; compare with Get workflow state for the full configuration. Requires workspace read access, and reveals nothing Get workflow state does not: its derived fields, such as `triggerWebhookUrl`, are computed from values that state already returns. The outline is read-only: it adds those derived fields and omits positions and edge handles, so never send it to Replace workflow state; edit with Apply workflow operations or a full state from Get workflow state. This read records no audit event; `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.",
"x-sim-operation": "workflows.read",
"x-oauth-scope": "api:read",
"tags": ["Workflows"],
Expand Down Expand Up @@ -702,7 +702,7 @@
"post": {
"operationId": "applyWorkflowOperations",
"summary": "Apply workflow operations",
"description": "Apply draft edits and block enablement changes in one write. Requires workspace write access. The live deployment is unchanged; lint is advisory.\n\nInspect `skipped` and `inputValidationErrors` for edits that did not apply. Deferred edges apply when their targets exist; do not resubmit them. With `atomic=true`, declined operations or dropped inputs return `409` (`OPERATIONS_NOT_APPLIED`) without saving. Use returned `mintedBlockIds` in later requests. `dryRun=true` evaluates without saving, auditing, or notifying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
"description": "Apply draft edits and block enablement changes in one write. Requires workspace write access. The live deployment is unchanged; lint is advisory.\n\nInspect `skipped` and `inputValidationErrors` for edits that did not apply. Deferred edges apply when their targets exist; do not resubmit them. With `atomic=true`, declined operations or dropped inputs return `409` (`OPERATIONS_NOT_APPLIED`) without saving. A `409` with code `CONFLICT` means the workflow changed after the batch read it and nothing was saved; re-read the workflow and retry. Use returned `mintedBlockIds` in later requests. `dryRun=true` evaluates without saving, auditing, or notifying. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
"x-sim-operation": "workflows.operations.apply",
"x-oauth-scope": "api:write",
"tags": ["Workflows"],
Expand Down Expand Up @@ -2965,10 +2965,10 @@
"name": "sortBy",
"in": "query",
"required": false,
"description": "Field used to sort the result.",
"description": "Field used to sort the result. `createdAt` is when the schedule was created for the deployment version that runs it: each deploy creates new schedule rows, so a redeployed workflow's schedules move to the newest end.",
"schema": {
"default": "createdAt",
"description": "Field used to sort the result.",
"description": "Field used to sort the result. `createdAt` is when the schedule was created for the deployment version that runs it: each deploy creates new schedule rows, so a redeployed workflow's schedules move to the newest end.",
"type": "string",
"enum": ["createdAt"]
}
Expand Down Expand Up @@ -8066,8 +8066,8 @@
}
]
},
"traceId": {
"description": "Identifier of this request, the same value as the `x-request-id` response header. Include it when contacting support.",
"requestId": {
"description": "Identifier of this request, the same id as the `x-request-id` response header. Quote it when contacting support.",
"type": "string"
}
},
Expand Down Expand Up @@ -8954,7 +8954,7 @@
"description": "Whether the block runs."
},
"inputs": {
"description": "Configured values keyed by field id, with empty values omitted and editor-only metadata removed. Loop and parallel containers report their iteration settings here. A webhook trigger also carries a derived `triggerWebhookUrl`, and a credential-routed trigger a derived `triggerRouting`; both are read-only.",
"description": "Configured values keyed by field id, with null values omitted. Fields hidden from agents are omitted as well, and some of them are real configuration, so read Get workflow state for the full set. Loop and parallel containers report their iteration settings here. A webhook trigger also carries a derived `triggerWebhookUrl`, and a credential-routed trigger a derived `triggerRouting`; both are read-only.",
"type": "object",
"propertyNames": {
"type": "string"
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/scripts/fixtures/content-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -879,8 +879,8 @@
{
"lang": "json",
"meta": null,
"sha256": "770907f8d75e5512815cdbcbfc73b1595d82596ce05ba3b5aa0005ff15498522",
"bytes": 108,
"sha256": "90d01678c8b5767e06bfe5a5e0b3e79d034de9f6c73bd6eb242df064e94c561b",
"bytes": 110,
"line": 173
},
{
Expand Down
21 changes: 10 additions & 11 deletions apps/sim/app/api/cli/auth/poll/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ function approved(overrides: Record<string, unknown> = {}) {
}
}

/** The name the terminal shows when it asks the caller to revoke a key it refused. */
const CLI_KEY_NAME = expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/)

describe('POST /api/cli/auth/poll', () => {
beforeEach(() => {
mockEnforceIpRateLimit.mockResolvedValue(null)
Expand Down Expand Up @@ -91,17 +94,14 @@ describe('POST /api/cli/auth/poll', () => {
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
status: 'complete',
key: { id: 'key-1', apiKey: 'sk-test' },
key: { id: 'key-1', apiKey: 'sk-test', name: CLI_KEY_NAME },
scope: 'copilot',
workspaceId: null,
workspaceBound: false,
})
// Second precision, not day: a date-only name made the second login of the
// day fail after the user had already approved in the browser.
expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith(
'user-1',
expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/)
)
expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', CLI_KEY_NAME)
expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST)
expect(mockReleaseMint).not.toHaveBeenCalled()
})
Expand All @@ -115,15 +115,14 @@ describe('POST /api/cli/auth/poll', () => {
expect(mockCreatePersonalApiKey).not.toHaveBeenCalled()
})

it('mints with the access and lifetime the approval recorded, whatever the poll body says', async () => {
it('mints with the access and lifetime the approval recorded', async () => {
mockPollApproval.mockResolvedValue(
approved({ scope: 'platform', access: 'read', expiresInDays: 30 })
)
const before = Date.now()
const response = await POST(
pollRequest({ request: REQUEST, verifier: VERIFIER, access: 'write', expiresInDays: null }),
{ params: Promise.resolve({}) }
)
const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER }), {
params: Promise.resolve({}),
})
expect(response.status).toBe(200)
const [params] = mockCreatePersonalApiKey.mock.calls[0]
expect(params.access).toBe('read')
Expand Down Expand Up @@ -192,7 +191,7 @@ describe('POST /api/cli/auth/poll', () => {
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
status: 'complete',
key: { id: 'key-1', apiKey: 'sk-test' },
key: { id: 'key-1', apiKey: 'sk-test', name: CLI_KEY_NAME },
scope: 'copilot',
workspaceId: null,
workspaceBound: false,
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/cli/auth/poll/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function approvedExpiresAt(grant: ApprovalGrant): string | null {
async function mintForGrant(grant: ApprovalGrant): Promise<
| {
ok: true
key: { id: string; apiKey: string }
key: { id: string; apiKey: string; name: string }
settings?: { access: ApiKeyAccess; expiresAt: string | null }
}
| { ok: false; status: number; message: string }
Expand All @@ -77,7 +77,7 @@ async function mintForGrant(grant: ApprovalGrant): Promise<
if (grant.scope === 'copilot') {
try {
const key = await generateCopilotApiKey(grant.userId, name)
return { ok: true, key }
return { ok: true, key: { id: key.id, apiKey: key.apiKey, name } }
} catch (error) {
const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined
return { ok: false, status: status ?? 500, message: 'Failed to generate copilot API key' }
Expand Down Expand Up @@ -117,7 +117,7 @@ async function mintForGrant(grant: ApprovalGrant): Promise<

return {
ok: true,
key: { id: result.key.id, apiKey: result.key.key },
key: { id: result.key.id, apiKey: result.key.key, name },
settings: {
access: result.key.access,
expiresAt: result.key.expiresAt?.toISOString() ?? null,
Expand Down
20 changes: 20 additions & 0 deletions apps/sim/app/api/v2/lib/response.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { InsufficientScopeError } from '@/lib/core/application'
import { HttpError } from '@/lib/core/utils/http-error'
import {
v2CaughtOrchestrationError,
v2Error,
v2HttpError,
v2RateLimitError,
v2ValidationError,
} from '@/app/api/v2/lib/response'

describe('v2 403 insufficient_scope challenge', () => {
Expand Down Expand Up @@ -146,3 +148,21 @@ describe('v2 401 authentication challenge', () => {
expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="mcp"')
})
})

describe('v2 validation error message', () => {
async function messageFor(schema: z.ZodType, value: unknown): Promise<string> {
const parsed = schema.safeParse(value)
if (parsed.success) throw new Error('expected a validation failure')
return (await v2ValidationError(parsed.error, 'query').json()).error.message
}

it('names the field once when the issue message already leads with its label', async () => {
const schema = z.object({
enabled: z.string().refine((value) => value === 'true', 'enabled: expected one of "true"'),
})

await expect(messageFor(schema, { enabled: 'maybe' })).resolves.toBe(
'query.enabled: expected one of "true"'
)
})
})
Loading
Loading