Skip to content

Commit 3b49719

Browse files
committed
fix(deploy): return deployed-version lint as its own field, opt-in and time-bounded
1 parent 9d64913 commit 3b49719

10 files changed

Lines changed: 434 additions & 162 deletions

File tree

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@
18421842
"post": {
18431843
"operationId": "deployWorkflow",
18441844
"summary": "Deploy Workflow",
1845-
"description": "Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. A conflicting webhook path returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
1845+
"description": "Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. `lint` reports advisory findings for the published version; they never block the deploy. A conflicting webhook path returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.",
18461846
"x-sim-operation": "workflows.deploy",
18471847
"x-oauth-scope": "api:write",
18481848
"tags": ["Workflows"],
@@ -7117,7 +7117,7 @@
71177117
],
71187118
"additionalProperties": false,
71197119
"title": "Workflow lint report",
7120-
"description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time."
7120+
"description": "Advisory findings about a saved graph. Findings never block a write or a deploy; they tell a caller what will misbehave at run time."
71217121
},
71227122
"ReplaceWorkflowStateResult": {
71237123
"type": "object",
@@ -10245,7 +10245,7 @@
1024510245
"items": {
1024610246
"type": "string"
1024710247
},
10248-
"description": "Non-fatal synchronization warnings. Empty when there is nothing to report."
10248+
"description": "Non-fatal warnings about deployment side effects, such as notifications that are still queued or failed. Empty when there is nothing to report. Lint findings are never reported here."
1024910249
},
1025010250
"activeDeployment": {
1025110251
"anyOf": [
@@ -10322,7 +10322,7 @@
1032210322
"items": {
1032310323
"type": "string"
1032410324
},
10325-
"description": "Non-fatal synchronization warnings. Empty when there is nothing to report."
10325+
"description": "Non-fatal warnings about deployment side effects, such as notifications that are still queued or failed. Empty when there is nothing to report. Lint findings are never reported here."
1032610326
},
1032710327
"activeDeployment": {
1032810328
"anyOf": [
@@ -10540,7 +10540,7 @@
1054010540
"items": {
1054110541
"type": "string"
1054210542
},
10543-
"description": "Non-fatal synchronization warnings. Empty when there is nothing to report."
10543+
"description": "Non-fatal warnings about deployment side effects, such as notifications that are still queued or failed. Empty when there is nothing to report. Lint findings are never reported here."
1054410544
},
1054510545
"activeDeployment": {
1054610546
"anyOf": [
@@ -10734,7 +10734,7 @@
1073410734
"items": {
1073510735
"type": "string"
1073610736
},
10737-
"description": "Non-fatal synchronization warnings. Empty when there is nothing to report."
10737+
"description": "Non-fatal warnings about deployment side effects, such as notifications that are still queued or failed. Empty when there is nothing to report. Lint findings are never reported here."
1073810738
},
1073910739
"activeDeployment": {
1074010740
"anyOf": [
@@ -10763,6 +10763,17 @@
1076310763
"type": "integer",
1076410764
"exclusiveMinimum": 0,
1076510765
"maximum": 9007199254740991
10766+
},
10767+
"lint": {
10768+
"anyOf": [
10769+
{
10770+
"$ref": "#/components/schemas/WorkflowLintReport"
10771+
},
10772+
{
10773+
"type": "null"
10774+
}
10775+
],
10776+
"description": "Advisory lint findings for the version this deploy publishes, checked as the deploying user. Findings never block a deploy. Null when the lint could not complete within its time budget; the deploy itself is unaffected."
1076610777
}
1076710778
},
1076810779
"required": [
@@ -10771,7 +10782,8 @@
1077110782
"deployedAt",
1077210783
"warnings",
1077310784
"activeDeployment",
10774-
"latestDeploymentAttempt"
10785+
"latestDeploymentAttempt",
10786+
"lint"
1077510787
],
1077610788
"additionalProperties": false,
1077710789
"title": "Deploy result",
@@ -10813,7 +10825,19 @@
1081310825
"activatedAt": null,
1081410826
"error": null
1081510827
},
10816-
"version": 3
10828+
"version": 3,
10829+
"lint": {
10830+
"sources": [],
10831+
"sinks": [],
10832+
"orphanBlocks": [],
10833+
"emptyOutgoingPorts": [],
10834+
"invalidBranchPorts": [],
10835+
"invalidConnectionTargets": [],
10836+
"fieldIssues": [],
10837+
"unresolvedReferences": [],
10838+
"tableFieldIssues": [],
10839+
"notes": []
10840+
}
1081710841
}
1081810842
}
1081910843
]
@@ -10898,7 +10922,7 @@
1089810922
"items": {
1089910923
"type": "string"
1090010924
},
10901-
"description": "Non-fatal synchronization warnings. Empty when there is nothing to report."
10925+
"description": "Non-fatal warnings about deployment side effects, such as notifications that are still queued or failed. Empty when there is nothing to report. Lint findings are never reported here."
1090210926
},
1090310927
"activeDeployment": {
1090410928
"anyOf": [

‎apps/sim/app/api/v2/lib/workflow-lint.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ function blockRef(ref: WorkflowLintBlockRef) {
1212
/**
1313
* Projects a lint report onto the wire.
1414
*
15-
* Shared by the two graph writes so the report is byte-identical whichever one
16-
* produced it. The domain leaves an absent block name `undefined`; the contract
15+
* Shared by the two graph writes and deploy so the report is byte-identical
16+
* whichever one produced it. The domain leaves an absent block name `undefined`; the contract
1717
* declares it `nullable`, because `undefined` is not a JSON value and a key that
1818
* simply vanishes is indistinguishable from one the server forgot to send. The
1919
* mapping is therefore load-bearing, not ceremony.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
V2_OPERATION_RATE_LIMIT_ALLOWED,
3+
V2_PREAUTH_RATE_LIMIT_ALLOWED,
4+
v2ApiKeyAuthModuleMock,
5+
v2RateLimiterModuleMock,
6+
v2RouteMocks,
7+
} from '@sim/testing'
8+
import { createPersonalApiKeyPrincipal } from '@sim/testing/factories/principal.factory'
9+
import { createRouteContext } from '@sim/testing/helpers/http'
10+
import { auditMock } from '@sim/testing/mocks/audit.mock'
11+
import { createMockRequest } from '@sim/testing/mocks/request.mock'
12+
import {
13+
workflowContextMock,
14+
workflowContextMockFns,
15+
} from '@sim/testing/mocks/workflow-context.mock'
16+
import {
17+
workflowsOrchestrationMock,
18+
workflowsOrchestrationMockFns,
19+
} from '@sim/testing/mocks/workflows-orchestration.mock'
20+
import {
21+
workflowsPersistenceUtilsMock,
22+
workflowsPersistenceUtilsMockFns,
23+
} from '@sim/testing/mocks/workflows-persistence-utils.mock'
24+
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
25+
import { beforeEach, describe, expect, it, vi } from 'vitest'
26+
27+
const mocks = vi.hoisted(() => ({ buildWorkflowLintReport: vi.fn() }))
28+
29+
vi.mock('@sim/audit', () => auditMock)
30+
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
31+
vi.mock('@/lib/workflows/application/context', () => workflowContextMock)
32+
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
33+
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
34+
vi.mock('@/lib/workflows/editing/lint-report', () => ({
35+
buildWorkflowLintReport: mocks.buildWorkflowLintReport,
36+
}))
37+
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
38+
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
39+
40+
import { POST } from '@/app/api/v2/workflows/[workflowId]/deploy/route'
41+
42+
const sideEffectWarning =
43+
'Deployment activation completed, and post-activation notifications are queued.'
44+
45+
const unquotedRowJson = {
46+
sources: [],
47+
sinks: [],
48+
orphanBlocks: [],
49+
emptyOutgoingPorts: [],
50+
invalidBranchPorts: [],
51+
invalidConnectionTargets: [],
52+
fieldIssues: [],
53+
unresolvedReferences: [
54+
{
55+
blockId: 'insert',
56+
blockName: 'Insert Order',
57+
field: 'data',
58+
value: ['<start.order_id>'],
59+
kind: 'block-output' as const,
60+
reason: 'unquoted-json-string: quote it',
61+
},
62+
],
63+
tableFieldIssues: [],
64+
notes: [],
65+
}
66+
67+
describe('POST /api/v2/workflows/[workflowId]/deploy', () => {
68+
beforeEach(() => {
69+
v2RouteMocks.authenticate.mockResolvedValue({
70+
principal: createPersonalApiKeyPrincipal({ userId: 'user-1', keyId: 'personal-key-1' }),
71+
rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'],
72+
rateLimitSubscription: null,
73+
keyType: 'personal',
74+
})
75+
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
76+
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
77+
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('admin')
78+
workflowContextMockFns.mockResolveActiveWorkflowApplicationContext.mockResolvedValue({
79+
workspaceId: 'workspace-1',
80+
workspaceOrganizationId: null,
81+
allowPersonalApiKeys: true,
82+
billedAccountUserId: 'billing-owner-1',
83+
workflowId: 'workflow-1',
84+
workflow: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true },
85+
})
86+
workflowsOrchestrationMockFns.mockPerformFullDeploy.mockResolvedValue({
87+
success: true,
88+
version: 4,
89+
deploymentVersionId: 'version-4',
90+
activeDeployment: null,
91+
latestDeploymentAttempt: null,
92+
warnings: [sideEffectWarning],
93+
})
94+
workflowsPersistenceUtilsMockFns.mockLoadWorkflowDeploymentVersionState.mockResolvedValue({
95+
blocks: {},
96+
edges: [],
97+
})
98+
mocks.buildWorkflowLintReport.mockResolvedValue(unquotedRowJson)
99+
})
100+
101+
/**
102+
* Chat, the CLI, and API callers learn a deployed block cannot run from this
103+
* response. Mixed into `warnings`, the findings were indistinguishable from
104+
* failed side effects, so no surface could present them deliberately.
105+
*/
106+
it('publishes the deployed version lint apart from side-effect warnings', async () => {
107+
const response = await POST(
108+
createMockRequest('POST', {}, {}, 'http://localhost/api/v2/workflows/workflow-1/deploy'),
109+
createRouteContext({ workflowId: 'workflow-1' })
110+
)
111+
const body = await response.json()
112+
113+
expect(response.status).toBe(200)
114+
expect(body.data.warnings).toEqual([sideEffectWarning])
115+
expect(body.data.lint.unresolvedReferences).toEqual([
116+
{
117+
blockId: 'insert',
118+
blockName: 'Insert Order',
119+
blockType: null,
120+
field: 'data',
121+
value: ['<start.order_id>'],
122+
kind: 'block-output',
123+
reason: 'unquoted-json-string: quote it',
124+
},
125+
])
126+
})
127+
})

‎apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { captureServerEvent } from '@/lib/posthog/server'
99
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
1010
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments'
1111
import { workflowOperations } from '@/lib/workflows/application/operations'
12+
import { presentWorkflowLint } from '@/app/api/v2/lib/workflow-lint'
1213

1314
export const dynamic = 'force-dynamic'
1415
export const runtime = 'nodejs'
@@ -28,6 +29,7 @@ export const POST = defineV2JsonRoute({
2829
name: body.name,
2930
description: body.description ?? undefined,
3031
requestId: generateRequestId(),
32+
lintDeployedVersion: true,
3133
}),
3234
useCase: deployWorkflow,
3335
present: (result) => ({
@@ -37,6 +39,7 @@ export const POST = defineV2JsonRoute({
3739
deployedAt: result.deployedAt?.toISOString() ?? null,
3840
version: result.version,
3941
warnings: result.warnings ?? [],
42+
lint: result.lint ? presentWorkflowLint(result.lint) : null,
4043
activeDeployment: result.activeDeployment ?? null,
4144
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
4245
},

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ const declaredRoutes = [
890890
applicationOperation: workflowOperations.deploy,
891891
operationId: 'deployWorkflow',
892892
summary: 'Deploy Workflow',
893-
description: `Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. A conflicting webhook path returns \`409\`. ${WORKSPACE_API_KEY_DENIED}`,
893+
description: `Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. \`lint\` reports advisory findings for the published version; they never block the deploy. A conflicting webhook path returns \`409\`. ${WORKSPACE_API_KEY_DENIED}`,
894894
errors: [...RESOURCE_ERRORS, 'Conflict', 'PayloadTooLarge', 'Locked'],
895895
success: jsonSuccess('The accepted deployment attempt.'),
896896
}),
@@ -924,6 +924,7 @@ const declaredRoutes = [
924924
error: null,
925925
},
926926
version: 3,
927+
lint: EMPTY_LINT_EXAMPLE,
927928
},
928929
},
929930
]

0 commit comments

Comments
 (0)