diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index ecde7d1730f..b7020ae27f9 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -32,6 +32,10 @@ { "name": "Usage", "description": "Check rate limits and billing usage" + }, + { + "name": "Billing", + "description": "Inspect billing status and credit-denominated ledger events" } ], "security": [ @@ -1014,12 +1018,12 @@ "parameters": [] } }, - "/api/v2/billing/usage": { + "/api/v2/billing/status": { "get": { - "operationId": "getUsageSummary", - "summary": "Get Usage Summary", - "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `sim-chat`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. Sim Chat combines the internal Copilot and workspace-chat ledgers. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", - "tags": ["Usage"], + "operationId": "getBillingStatus", + "summary": "Get Billing Status", + "description": "Return the current plan, billing standing, period, and credit allowance. This endpoint never embeds ledger rows or per-source analytics; use `GET /api/v2/billing/logs` for billing history.", + "tags": ["Billing"], "security": [ { "apiKey": [] @@ -1033,12 +1037,12 @@ "schema": { "type": "string" }, - "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + "description": "Resolve the status against this workspace's actual payer. A workspace-scoped API key is pinned to its own workspace; passing a different id returns 403." } ], "responses": { "200": { - "description": "The current billing period's usage summary.", + "description": "The current billing status.", "content": { "application/json": { "schema": { @@ -1047,14 +1051,12 @@ "properties": { "data": { "type": "object", - "required": [ - "period", - "totalCredits", - "bySourceCredits", - "limitCredits", - "plan" - ], + "required": ["workspaceId", "period", "plan", "status", "credits"], "properties": { + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace whose payer was resolved, or null for account billing." + }, "period": { "type": "object", "required": ["start", "end"], @@ -1069,21 +1071,27 @@ } } }, - "totalCredits": { - "type": "number" - }, - "bySourceCredits": { - "type": "object", - "additionalProperties": { - "type": "number" - }, - "description": "Credits consumed per usage source over the billing period." - }, - "limitCredits": { - "type": "number" - }, "plan": { "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "limit_exceeded", "billing_blocked"] + }, + "credits": { + "type": "object", + "required": ["used", "limit", "remaining"], + "properties": { + "used": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "remaining": { + "type": "number" + } + } } } } @@ -1091,18 +1099,18 @@ }, "example": { "data": { + "workspaceId": null, "period": { "start": "2026-07-01T00:00:00.000Z", "end": "2026-08-01T00:00:00.000Z" }, - "totalCredits": 512, - "bySourceCredits": { - "workflow": 380, - "sim-chat": 120, - "knowledge-base": 12 - }, - "limitCredits": 20000, - "plan": "pro" + "plan": "pro", + "status": "active", + "credits": { + "used": 512, + "limit": 20000, + "remaining": 19488 + } } } } @@ -1123,12 +1131,12 @@ } } }, - "/api/v2/billing/usage/logs": { + "/api/v2/billing/logs": { "get": { - "operationId": "listUsageLogs", - "summary": "List Usage Logs", - "description": "Cursor-paged, credit-denominated ledger of the account's usage events. The per-source aggregate lives on `GET /api/v2/billing/usage`; this is the row-level detail. Page by passing `nextCursor` back as `cursor` and stop when it is null.", - "tags": ["Usage"], + "operationId": "listBillingLogs", + "summary": "List Billing Logs", + "description": "Cursor-paged, credit-denominated billing ledger. This endpoint returns history only and never embeds the current billing status. Page by passing `nextCursor` back as `cursor` and stop when it is null.", + "tags": ["Billing"], "security": [ { "apiKey": [] @@ -1226,7 +1234,15 @@ "type": "array", "items": { "type": "object", - "required": ["id", "createdAt", "source", "workflowName", "creditCost"], + "required": [ + "id", + "createdAt", + "source", + "workspaceId", + "workflow", + "executionId", + "creditCost" + ], "properties": { "id": { "type": "string" @@ -1249,9 +1265,30 @@ "voice-output" ] }, - "workflowName": { - "type": ["string", "null"], - "description": "Populated only when `source` is `workflow`." + "workspaceId": { + "type": ["string", "null"] + }, + "workflow": { + "oneOf": [ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + } + } + }, + { + "type": "null" + } + ] + }, + "executionId": { + "type": ["string", "null"] }, "creditCost": { "type": "number", @@ -1272,7 +1309,9 @@ "id": "log_1", "createdAt": "2026-07-29T18:04:11.000Z", "source": "sim-chat", - "workflowName": null, + "workspaceId": "ws_1", + "workflow": null, + "executionId": null, "creditCost": 12 } ], @@ -1445,7 +1484,7 @@ }, "status": { "type": "string", - "enum": ["queued", "processing", "completed", "failed"], + "enum": ["queued", "processing", "completed", "failed", "cancelled"], "description": "Current status of the job.", "example": "completed" }, diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 5b314f60a37..1235ef28c4d 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -777,17 +777,28 @@ "get": { "operationId": "listAuditLogs", "summary": "List Audit Logs", - "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "description": "List audit log entries for an explicitly selected organization with opaque cursor pagination. These organization-scoped enterprise endpoints require a personal API key; workspace-scoped keys return `403`. The caller must belong to the selected organization, hold an admin or owner role, and have an active Enterprise subscription. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", "tags": ["Audit Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?organizationId=org_abc123&limit=50\" \\\n -H \"X-API-Key: YOUR_PERSONAL_API_KEY\"" } ], "parameters": [ + { + "name": "organizationId", + "in": "query", + "required": true, + "description": "Organization to audit. The caller must be an admin or owner of this organization.", + "schema": { + "type": "string", + "minLength": 1, + "example": "org_abc123" + } + }, { "name": "action", "in": "query", @@ -958,14 +969,14 @@ "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", - "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "description": "Retrieve a single audit log entry by ID within an explicitly selected organization. This endpoint requires a personal API key; workspace-scoped keys return `403`. The caller must belong to the selected organization, hold an admin or owner role, and have an active Enterprise subscription. An entry outside that organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", "tags": ["Audit Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}?organizationId=org_abc123\" \\\n -H \"X-API-Key: YOUR_PERSONAL_API_KEY\"" } ], "parameters": [ @@ -979,6 +990,17 @@ "minLength": 1, "example": "audit_2c3d4e5f6g" } + }, + { + "name": "organizationId", + "in": "query", + "required": true, + "description": "Organization that owns the audit entry. The caller must be an admin or owner of this organization.", + "schema": { + "type": "string", + "minLength": 1, + "example": "org_abc123" + } } ], "responses": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 2a4f40bc765..13b6fac185b 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -249,10 +249,10 @@ "example": { "data": [ { - "id": "log_7x8y9z0a1b", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "deploymentVersionId": "dep_2c4e6a8b0d1f", + "status": "completed", "level": "info", "trigger": "api", "startedAt": "2026-01-15T10:30:00.000Z", @@ -287,35 +287,35 @@ } } }, - "/api/v2/logs/{id}": { + "/api/v2/logs/{executionId}": { "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve a single log entry by its ID, including workflow metadata, materialized execution data, a top-level `traceSpans` array, and the cost summary. Returns `{ data }`.", + "description": "Retrieve the diagnostic representation of an execution by its execution ID, including workflow metadata and state, trace spans, final output, and cost. Logs and executions share the same public identity; no separate log ID is exposed.", "tags": ["Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ { - "name": "id", + "name": "executionId", "in": "path", "required": true, - "description": "The unique identifier of the log entry.", + "description": "The unique execution identifier shared by the lifecycle and diagnostic resources.", "schema": { "type": "string", - "example": "log_7x8y9z0a1b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], "responses": { "200": { - "description": "The requested log entry with full execution data, trace spans, and cost summary.", + "description": "The requested diagnostic log representation.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -340,9 +340,10 @@ }, "example": { "data": { - "id": "log_7x8y9z0a1b", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "status": "completed", "level": "info", "trigger": "api", "startedAt": "2026-01-15T10:30:00.000Z", @@ -360,13 +361,14 @@ "updatedAt": "2025-06-18T16:45:00.000Z", "deleted": false }, - "executionData": { - "traceSpans": [], - "finalOutput": { - "result": "Hello, world!" - } + "workflowState": { + "blocks": {}, + "edges": [] }, "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + }, "cost": { "total": 0.0032 }, @@ -390,96 +392,6 @@ } } } - }, - "/api/v2/logs/executions/{executionId}": { - "get": { - "operationId": "getExecution", - "summary": "Get Execution", - "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", - "tags": ["Logs"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "name": "executionId", - "in": "path", - "required": true, - "description": "The unique execution identifier.", - "schema": { - "type": "string", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - } - } - ], - "responses": { - "200": { - "description": "The full execution state snapshot with workflow state and metadata.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/Execution" - } - } - }, - "example": { - "data": { - "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workflowState": { - "blocks": {}, - "edges": [], - "loops": {}, - "parallels": {} - }, - "executionMetadata": { - "trigger": "api", - "startedAt": "2026-01-15T10:30:00.000Z", - "endedAt": "2026-01-15T10:30:01.250Z", - "totalDurationMs": 1250, - "cost": { - "total": 0.0032 - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } } }, "components": { @@ -638,10 +550,10 @@ "type": "object", "description": "Summary of a single workflow execution log entry returned by the list endpoint.", "required": [ - "id", - "workflowId", "executionId", + "workflowId", "deploymentVersionId", + "status", "level", "trigger", "startedAt", @@ -651,26 +563,26 @@ "files" ], "properties": { - "id": { + "executionId": { "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" + "description": "The sole public identifier for both the execution and its log representation.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "workflowId": { "type": ["string", "null"], "description": "The workflow that was executed. null if the log is not associated with a workflow.", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, - "executionId": { - "type": "string", - "description": "Unique execution identifier for this run.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - }, "deploymentVersionId": { "type": ["string", "null"], "description": "The deployment version that produced this run. null for runs not tied to a deployment.", "example": "dep_2c4e6a8b0d1f" }, + "status": { + "type": "string", + "description": "Durable execution status recorded with this log.", + "example": "completed" + }, "level": { "type": "string", "description": "Log severity. info for successful executions, error for failures.", @@ -734,11 +646,12 @@ }, "LogDetail": { "type": "object", - "description": "Detailed log entry with full workflow metadata, materialized execution data, top-level trace spans, and cost summary.", + "description": "Diagnostic representation of an execution, addressed by the same execution ID as its lifecycle resource.", "required": [ - "id", - "workflowId", "executionId", + "workflowId", + "deploymentVersionId", + "status", "level", "trigger", "startedAt", @@ -746,26 +659,31 @@ "totalDurationMs", "files", "workflow", - "executionData", + "workflowState", "traceSpans", + "finalOutput", "cost", "createdAt" ], "properties": { - "id": { + "executionId": { "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" + "description": "The sole public identifier for both the execution and its log representation.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "workflowId": { "type": ["string", "null"], "description": "The workflow that was executed. null if the log is not associated with a workflow.", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, - "executionId": { + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment." + }, + "status": { "type": "string", - "description": "Unique execution identifier for this run.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + "description": "Durable execution status recorded with this log.", + "example": "completed" }, "level": { "type": "string", @@ -805,25 +723,10 @@ "workflow": { "$ref": "#/components/schemas/LogWorkflowDetail" }, - "executionData": { + "workflowState": { "type": "object", "additionalProperties": true, - "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", - "properties": { - "traceSpans": { - "type": "array", - "description": "Block-level execution traces with timing, inputs, and outputs.", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "finalOutput": { - "type": "object", - "additionalProperties": true, - "description": "The workflow's final output after all blocks completed." - } - } + "description": "Snapshot of the workflow configuration at execution time." }, "traceSpans": { "type": "array", @@ -833,6 +736,9 @@ "additionalProperties": true } }, + "finalOutput": { + "description": "Materialized final output, or null when the execution produced none." + }, "cost": { "$ref": "#/components/schemas/Cost" }, @@ -844,85 +750,6 @@ } } }, - "Execution": { - "type": "object", - "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", - "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], - "properties": { - "executionId": { - "type": "string", - "description": "The unique identifier for this execution.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - }, - "workflowId": { - "type": ["string", "null"], - "description": "The workflow that was executed. null if the log is not associated with a workflow.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "workflowState": { - "type": "object", - "additionalProperties": true, - "description": "Snapshot of the workflow configuration at the time of execution.", - "properties": { - "blocks": { - "type": "object", - "additionalProperties": true, - "description": "Map of block IDs to their configuration and state during execution." - }, - "edges": { - "type": "array", - "description": "Connections between blocks defining the execution flow.", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "loops": { - "type": "object", - "additionalProperties": true, - "description": "Loop configurations defining iterative execution patterns." - }, - "parallels": { - "type": "object", - "additionalProperties": true, - "description": "Parallel execution group configurations." - } - } - }, - "executionMetadata": { - "type": "object", - "description": "Metadata about the execution including trigger, timing, and cost.", - "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], - "properties": { - "trigger": { - "type": "string", - "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", - "example": "api" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution started.", - "example": "2026-01-15T10:30:00.000Z" - }, - "endedAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", - "example": "2026-01-15T10:30:01.250Z" - }, - "totalDurationMs": { - "type": ["integer", "null"], - "description": "Total execution duration in milliseconds. null if the run has not finished.", - "example": 1250 - }, - "cost": { - "$ref": "#/components/schemas/Cost" - } - } - } - } - }, "Error": { "type": "object", "description": "Canonical v2 error envelope.", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 00d943e77a7..32561a872e2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1354,6 +1354,149 @@ } } }, + "/api/v2/workflows/{id}/executions": { + "get": { + "operationId": "listWorkflowExecutionsV2", + "summary": "List workflow executions", + "description": "List the durable executions belonging to one workflow. Freshly queued runs are available through their execution status URL but do not enter this history until durable execution logging begins. This lifecycle collection is intentionally lightweight; fetch one execution for output and pause detail, or fetch `/api/v2/logs/{executionId}` for diagnostic trace data.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] + } + }, + { + "name": "trigger", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of workflow executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowExecutionListItem" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + }, + "example": { + "data": [ + { + "executionId": "exec_1", + "workflowId": "wf_123", + "status": "completed", + "trigger": "api", + "startedAt": "2026-07-31T00:00:00.000Z", + "endedAt": "2026-07-31T00:00:01.000Z", + "durationMs": 1000, + "cost": { + "total": 0.02 + } + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v2/workflows/{id}/executions/{executionId}": { "get": { "operationId": "getWorkflowExecutionV2", @@ -2717,6 +2860,54 @@ } } }, + "WorkflowExecutionListItem": { + "type": "object", + "required": [ + "executionId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "cost" + ], + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] + }, + "trigger": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "durationMs": { + "type": ["number", "null"] + }, + "cost": { + "type": ["object", "null"], + "required": ["total"], + "properties": { + "total": { + "type": "number" + } + } + } + } + }, "ExecutionResource": { "type": "object", "required": ["executionId", "workflowId", "status", "output", "error"], diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index e58d9a037d4..90daf86fd64 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -203,7 +203,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Delete completed/failed jobs older than retention period const retentionThreshold = new Date(Date.now() - JOB_RETENTION_HOURS * 60 * 60 * 1000) let asyncJobsDeleted = 0 @@ -212,7 +211,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .delete(asyncJobs) .where( and( - inArray(asyncJobs.status, [JOB_STATUS.COMPLETED, JOB_STATUS.FAILED]), + inArray(asyncJobs.status, [ + JOB_STATUS.COMPLETED, + JOB_STATUS.FAILED, + JOB_STATUS.CANCELLED, + ]), lt(asyncJobs.completedAt, retentionThreshold) ) ) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 6fdf4cdfdc6..1a5e57ae334 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -67,7 +67,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ getDocumentTagDefinitions: mockGetDocumentTagDefinitions, })) -vi.mock('./utils', () => ({ +vi.mock('@/lib/knowledge/search/queries', () => ({ executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 12a88854571..fcccd247ac4 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -22,16 +22,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' import { rerank } from '@/lib/knowledge/reranker' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' -import { estimateTokenCount } from '@/lib/tokenization/estimators' import { executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { estimateTokenCount } from '@/lib/tokenization/estimators' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { getRerankModelPricing } from '@/providers/models' import { calculateCost } from '@/providers/utils' diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 461289c09d2..641053d867f 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -56,7 +56,7 @@ import { handleVectorOnlySearch, RRF_K, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' /** Minimal SearchResult builder — only the fields fusion and ordering read. */ function makeResult(id: string, distance = 0.1): SearchResult { @@ -795,7 +795,7 @@ describe('Knowledge Search Utils', () => { describe('getDocumentMetadataByIds', () => { it('should handle empty input gracefully', async () => { - const { getDocumentMetadataByIds } = await import('./utils') + const { getDocumentMetadataByIds } = await import('@/lib/knowledge/search/queries') const result = await getDocumentMetadataByIds([]) diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 13c9079202f..e92dc49f419 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' -import { document, embedding, knowledgeBase } from '@sim/db/schema' +import { embedding, knowledgeBase } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' +import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' interface KnowledgeBaseData { @@ -243,27 +244,14 @@ async function resolveDocumentAccess( } } - const doc = await db - .select() - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (doc.length === 0) { + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) { return { hasAccess: false, notFound: true, reason: 'Document not found' } } return { hasAccess: true, - document: doc[0] as DocumentData, + document: doc, knowledgeBase: kbAccess.knowledgeBase!, } } @@ -313,25 +301,12 @@ async function resolveChunkAccess( } } - const doc = await db - .select() - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (doc.length === 0) { + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) { return { hasAccess: false, notFound: true, reason: 'Document not found' } } - const docData = doc[0] as DocumentData + const docData = doc // Chunks are only accessible once the document has finished processing. if (docData.processingStatus !== 'completed') { diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index 483ae2bddde..b006b84264a 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -18,7 +18,7 @@ const logger = createLogger('UsageLogsAPI') /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. - * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`. + * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/logs`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index e8122de36dd..d9aa8f48455 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -99,13 +99,17 @@ describe('enterprise audit access', () => { expect(result.success).toBe(false) }) - it('still requires organization membership', async () => { + it('names the requested organization when target membership is missing', async () => { setEnvFlags({ isAuditLogsEnabled: true }) queueTableRows(schemaMock.member, []) - const result = await validateEnterpriseAuditAccess('viewer') + const result = await validateEnterpriseAuditAccess('viewer', 'organization-route') - expect(result.success).toBe(false) + if (result.success) throw new Error('Expected organization membership to be rejected') + expect(result.response.status).toBe(403) + await expect(result.response.json()).resolves.toEqual({ + error: 'Not a member of the requested organization', + }) }) }) }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 60c3d61fc5c..7076d5ec7d0 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -65,7 +65,13 @@ export async function resolveEnterpriseAuditAccess( .limit(1) if (!membership) { - return { success: false, status: 403, message: 'Not a member of any organization' } + return { + success: false, + status: 403, + message: targetOrganizationId + ? 'Not a member of the requested organization' + : 'Not a member of any organization', + } } if (membership.role !== 'admin' && membership.role !== 'owner') { diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 8f75f565454..7c5cbc2fc23 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -44,7 +44,7 @@ const SYSTEM_BILLING_ATTRIBUTION = { payerSubscription: null, } -vi.mock('@/app/api/knowledge/search/utils', () => ({ +vi.mock('@/lib/knowledge/search/queries', () => ({ executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 490c8e88b7b..a54a4685b6b 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -9,15 +9,15 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' import { executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { handleError } from '@/app/api/v1/knowledge/utils' import { diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 108e9fc534e..12066f2f9cc 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -1,13 +1,11 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -38,36 +36,7 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) - - const log = rows[0] + const log = await getPublicWorkflowLog({ column: 'id', value: id }) if (!log) { return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index eefad39bb80..cd2c2e5cead 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -1,11 +1,9 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -15,6 +13,13 @@ import { const logger = createLogger('V1ExecutionAPI') +function countWorkflowStateBlocks(workflowState: unknown): number { + if (!workflowState || typeof workflowState !== 'object' || Array.isArray(workflowState)) return 0 + const blocks = (workflowState as Record).blocks + if (!blocks || typeof blocks !== 'object' || Array.isArray(blocks)) return 0 + return Object.keys(blocks).length +} + export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { try { @@ -34,37 +39,25 @@ export const GET = withRouteHandler( logger.debug(`Fetching execution data for: ${executionId}`) - const rows = await db - .select() - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) + const workflowLog = await getPublicWorkflowLog({ column: 'executionId', value: executionId }) - if (rows.length === 0) { + if (!workflowLog) { return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const workflowLog = rows[0] - const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) if (accessError) { return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) - .limit(1) - - if (!snapshot) { + if (!workflowLog.workflowState) { return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) } const response = { executionId, workflowId: workflowLog.workflowId, - workflowState: snapshot.stateData, + workflowState: workflowLog.workflowState, executionMetadata: { trigger: workflowLog.trigger, startedAt: workflowLog.startedAt.toISOString(), @@ -78,7 +71,7 @@ export const GET = withRouteHandler( logger.debug(`Successfully fetched execution data for: ${executionId}`) logger.debug( - `Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` + `Workflow state contains ${countWorkflowStateBlocks(workflowLog.workflowState)} blocks` ) // Get user's workflow execution limits and usage diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index e6da3cb0e2a..390e7707300 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' -import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -23,23 +20,6 @@ const logger = createLogger('V1LogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -interface CursorData { - startedAt: string - id: string -} - -function encodeCursor(data: CursorData): string { - return Buffer.from(JSON.stringify(data)).toString('base64') -} - -function decodeCursor(cursor: string): CursorData | null { - try { - return JSON.parse(Buffer.from(cursor, 'base64').toString()) - } catch { - return null - } -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -74,6 +54,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) + const decodedCursor = params.cursor + ? decodePublicLogCursor(params.cursor, params.order ?? 'desc') + : null + if (params.cursor && !decodedCursor) { + return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 }) + } + const cursor = decodedCursor ?? undefined + const filters = { workspaceId: params.workspaceId, workflowIds: params.workflowIds?.split(',').filter(Boolean), @@ -88,50 +76,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { minCost: params.minCost, maxCost: params.maxCost, model: params.model, - cursor: params.cursor ? decodeCursor(params.cursor) || undefined : undefined, + cursor, order: params.order, } - const conditions = buildLogFilters(filters) - const orderBy = getOrderBy(params.order) - - const baseQuery = db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, - workflowName: workflow.name, - workflowDescription: workflow.description, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - - const logs = await baseQuery - .where(conditions) - .orderBy(...orderBy) - .limit(params.limit + 1) - - const hasMore = logs.length > params.limit - const data = logs.slice(0, params.limit) - - let nextCursor: string | undefined - if (hasMore && data.length > 0) { - const lastLog = data[data.length - 1] - nextCursor = encodeCursor({ - startedAt: lastLog.startedAt.toISOString(), - id: lastLog.id, - }) - } + const { data, nextCursor } = await listPublicWorkflowLogs({ + filters, + limit: params.limit, + includeExecutionData: params.details === 'full', + }) const needsMaterialize = params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) @@ -192,7 +145,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = createApiResponse( { data: formattedLogs, - nextCursor, + nextCursor: nextCursor ?? undefined, }, limits, rateLimit // This is the API endpoint rate limit, not workflow execution limits diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts index 89186235598..f2cb6d059a9 100644 --- a/apps/sim/app/api/v1/workflows/utils.ts +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -1,5 +1,8 @@ -import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { NextResponse } from 'next/server' +import { + type DeploymentWorkflowTarget, + getDeploymentWorkflowTarget, +} from '@/lib/workflows/deployments/queries' import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' function workflowNotFoundResponse(): NextResponse { @@ -16,24 +19,16 @@ export async function resolveV1DeploymentWorkflow( rateLimit: RateLimitResult, userId: string, workflowId: string -): Promise< - | { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string } - | { ok: false; response: NextResponse } -> { - const workflow = await getActiveWorkflowRecord(workflowId) - if (!workflow?.workspaceId) { +): Promise<({ ok: true } & DeploymentWorkflowTarget) | { ok: false; response: NextResponse }> { + const target = await getDeploymentWorkflowTarget(workflowId) + if (!target) { return { ok: false, response: workflowNotFoundResponse() } } - const accessError = await validateWorkspaceAccess( - rateLimit, - userId, - workflow.workspaceId, - 'admin' - ) + const accessError = await validateWorkspaceAccess(rateLimit, userId, target.workspaceId, 'admin') if (accessError) { return { ok: false, response: workflowNotFoundResponse() } } - return { ok: true, workflow, workspaceId: workflow.workspaceId } + return { ok: true, ...target } } diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index 65a270342fa..6d6f819dd8b 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -22,12 +22,9 @@ export const revalidate = 0 /** * GET /api/v2/audit-logs/[id] * - * Returns a single audit log entry scoped to the authenticated user's - * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization - * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted - * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate - * is folded into the lookup so a non-org log reads as 404 (existence is not - * leaked). + * Returns a single audit log entry scoped to an explicitly selected + * organization. Audit logs are personal-key-only because a workspace-scoped + * key must never expand into organization-wide visibility. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -42,14 +39,21 @@ export const GET = withRouteHandler( const gate = await v2ApiGateError(userId) if (gate) return gate - const authResult = await resolveEnterpriseAuditAccess(userId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - const parsed = await parseRequest(v2GetAuditLogContract, request, context, { validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response + if (rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Audit logs require a personal API key') + } + + const authResult = await resolveEnterpriseAuditAccess( + userId, + parsed.data.query.organizationId + ) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + const { id } = parsed.data.params const { organizationId, orgMemberIds } = authResult.context diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts new file mode 100644 index 00000000000..9c15eb5e4b5 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveEnterpriseAuditAccess, + mockBuildFilterConditions, + mockBuildOrgScopeCondition, + mockGetOrgWorkspaceIds, + mockQueryAuditLogs, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveEnterpriseAuditAccess: vi.fn(), + mockBuildFilterConditions: vi.fn(), + mockBuildOrgScopeCondition: vi.fn(), + mockGetOrgWorkspaceIds: vi.fn(), + mockQueryAuditLogs: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/app/api/v1/audit-logs/auth', () => ({ + resolveEnterpriseAuditAccess: mockResolveEnterpriseAuditAccess, +})) + +vi.mock('@/app/api/v1/audit-logs/query', () => ({ + buildFilterConditions: mockBuildFilterConditions, + buildOrgScopeCondition: mockBuildOrgScopeCondition, + getOrgWorkspaceIds: mockGetOrgWorkspaceIds, + queryAuditLogs: mockQueryAuditLogs, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/audit-logs/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'admin-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-01T00:00:00Z'), +} + +function callGet(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/audit-logs${query}`)) +} + +describe('GET /api/v2/audit-logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveEnterpriseAuditAccess.mockResolvedValue({ + success: true, + context: { organizationId: 'org-1', orgMemberIds: ['admin-1'] }, + }) + mockGetOrgWorkspaceIds.mockResolvedValue([]) + mockBuildOrgScopeCondition.mockReturnValue({ type: 'scope' }) + mockBuildFilterConditions.mockReturnValue([]) + mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + }) + + it('requires an explicit organization before authorization', async () => { + const response = await callGet() + + expect(response.status).toBe(400) + expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + }) + + it('rejects workspace keys before organization-wide access is resolved', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callGet('?organizationId=org-1') + + expect(response.status).toBe(403) + expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + }) + + it('authorizes exactly the requested organization for personal keys', async () => { + const response = await callGet('?organizationId=org-1') + + expect(response.status).toBe(200) + expect(mockResolveEnterpriseAuditAccess).toHaveBeenCalledWith('admin-1', 'org-1') + expect(mockQueryAuditLogs).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index 32ef339a8f9..596adff6680 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -30,11 +30,9 @@ export const revalidate = 0 /** * GET /api/v2/audit-logs * - * Lists audit logs scoped to the authenticated user's organization. Org-scoped - * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — - * access is gated by enterprise org admin/owner membership. Auth ordering - * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the - * untrusted query is parsed. + * Lists audit logs scoped to an explicitly selected organization. Audit logs + * are personal-key-only because a workspace-scoped key must never expand into + * organization-wide visibility. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -48,11 +46,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const gate = await v2ApiGateError(userId) if (gate) return gate - const authResult = await resolveEnterpriseAuditAccess(userId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - - const { organizationId, orgMemberIds } = authResult.context - const parsed = await parseRequest( v2ListAuditLogsContract, request, @@ -65,6 +58,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query + if (rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Audit logs require a personal API key') + } + + const authResult = await resolveEnterpriseAuditAccess(userId, params.organizationId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + if (params.actorId && !orgMemberIds.includes(params.actorId)) { return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') } diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts new file mode 100644 index 00000000000..362fc54473a --- /dev/null +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetUserUsageLogs, + mockGetUsageCreditsByLogId, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/logs/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callLogs(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/logs${query}`)) +} + +describe('GET /api/v2/billing/logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'workflow', + description: 'claude-sonnet', + cost: 0.06, + workspaceId: 'ws-1', + workflowId: 'workflow-1', + workflowName: 'Support Agent', + executionId: 'execution-1', + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([{ key: 'log-1', dollars: 0.06 }]) + ) + }) + + it('returns ledger rows without embedding billing status', async () => { + const response = await callLogs() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ + data: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'workflow', + workspaceId: 'ws-1', + workflow: { id: 'workflow-1', name: 'Support Agent' }, + executionId: 'execution-1', + creditCost: 12, + }, + ], + nextCursor: null, + }) + expect(body).not.toHaveProperty('status') + }) + + it('normalizes both internal chat sources to sim-chat', async () => { + const response = await callLogs('?source=sim-chat') + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + }) + + it('forwards the cursor when more rows remain', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-42' }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue({}) + + const body = await (await callLogs()).json() + + expect(body.nextCursor).toBe('log-42') + }) + + it('rejects custom periods without a start date', async () => { + const response = await callLogs('?period=custom') + + expect(response.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('authorizes a personal key before reading a workspace ledger', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callLogs('?workspaceId=ws-2') + + expect(response.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts similarity index 82% rename from apps/sim/app/api/v2/billing/usage/logs/route.ts rename to apps/sim/app/api/v2/billing/logs/route.ts index fe54242b97c..f6a8095466c 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing' +import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' import { parseRequest } from '@/lib/api/server' import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' @@ -18,16 +18,12 @@ import { v2ValidationError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2BillingUsageLogsAPI') +const logger = createLogger('V2BillingLogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/billing/usage/logs — Cursor-paged, credit-denominated ledger of - * the account's usage events. The per-source aggregate lives on - * `GET /api/v2/billing/usage`; this is the row-level detail. - */ +/** Cursor-paged, credit-denominated billing ledger. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -36,12 +32,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) if (gate) return gate const parsed = await parseRequest( - v2ListUsageLogsContract, + v2ListBillingLogsContract, request, {}, { @@ -51,7 +46,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query - const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, workspaceId) + const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, workspaceId) if (!workspaceFilter.ok) return workspaceFilter.response const dateRange = resolveDateRange(period, startDate, endDate) @@ -71,7 +66,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: log.id, createdAt: log.createdAt, source: toBillingUsageLogSource(log.source), - workflowName: log.workflowName ?? null, + workspaceId: log.workspaceId ?? null, + workflow: log.workflowId ? { id: log.workflowId, name: log.workflowName ?? null } : null, + executionId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, })) @@ -81,7 +78,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { rateLimit } ) } catch (error) { - logger.error(`[${requestId}] Error listing usage logs`, { + logger.error(`[${requestId}] Error listing billing logs`, { error: getErrorMessage(error, 'Unknown error'), }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts new file mode 100644 index 00000000000..84734542d33 --- /dev/null +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckBillingBlocked, + mockCheckUsageStatus, + mockGetHighestPrioritySubscription, + mockDeriveBillingContext, + mockResolveBillingAttribution, + mockCheckAttributedBillingBlocks, + mockToUsageLimitSubscription, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckBillingBlocked: vi.fn(), + mockCheckUsageStatus: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockDeriveBillingContext: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockCheckAttributedBillingBlocks: vi.fn(), + mockToUsageLimitSubscription: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkBillingBlocked: mockCheckBillingBlocked, + checkBillingEntityBlocked: vi.fn(), + checkUsageStatus: mockCheckUsageStatus, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mockDeriveBillingContext, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mockResolveBillingAttribution, + checkAttributedBillingBlocks: mockCheckAttributedBillingBlocks, + toUsageLimitSubscription: mockToUsageLimitSubscription, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/status/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callStatus(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/status${query}`)) +} + +describe('GET /api/v2/billing/status', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) + mockDeriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-07-01T00:00:00Z'), + end: new Date('2026-08-01T00:00:00Z'), + }, + }) + mockCheckUsageStatus.mockResolvedValue({ + isExceeded: false, + currentUsage: 2.5, + limit: 100, + }) + mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) + mockCheckAttributedBillingBlocks.mockResolvedValue({ blocked: false }) + mockToUsageLimitSubscription.mockReturnValue({ + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 5, + periodStart: new Date('2026-07-01T00:00:00Z'), + periodEnd: new Date('2026-08-01T00:00:00Z'), + }) + }) + + it('returns status and allowance without ledger rows or source summaries', async () => { + const response = await callStatus() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + workspaceId: null, + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + plan: 'pro', + status: 'active', + credits: { used: 500, limit: 20000, remaining: 19500 }, + }) + expect(body.data).not.toHaveProperty('bySourceCredits') + }) + + it('reports billing blocks before usage-limit state', async () => { + mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 100, limit: 100 }) + mockCheckBillingBlocked.mockResolvedValue({ blocked: true }) + + const body = await (await callStatus()).json() + + expect(body.data.status).toBe('billing_blocked') + }) + + it('resolves a workspace billing status against the workspace payer', async () => { + mockResolveBillingAttribution.mockResolvedValue({ + actorUserId: 'user-1', + workspaceId: 'ws-1', + organizationId: 'org-1', + billedAccountUserId: 'owner-1', + billingEntity: { type: 'organization', id: 'org-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: { + id: 'sub-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 5, + periodStart: '2026-07-01T00:00:00.000Z', + periodEnd: '2026-08-01T00:00:00.000Z', + }, + }) + + const body = await (await callStatus('?workspaceId=ws-1')).json() + + expect(body.data.workspaceId).toBe('ws-1') + expect(body.data.plan).toBe('team') + expect(mockCheckUsageStatus).toHaveBeenCalledWith( + 'owner-1', + expect.objectContaining({ referenceId: 'org-1', plan: 'team' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + + const response = await callStatus('?workspaceId=ws-2') + + expect(response.status).toBe(403) + expect(mockCheckUsageStatus).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts new file mode 100644 index 00000000000..8936001c50e --- /dev/null +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2BillingStatusData, + v2GetBillingStatusContract, +} from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { + checkBillingBlocked, + checkBillingEntityBlocked, + checkUsageStatus, +} from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedBillingBlocks, + resolveBillingAttribution, + toUsageLimitSubscription, +} from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingStatusAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Current billing standing; ledger events are exposed separately by `/billing/logs`. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2GetBillingStatusContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + let data: V2BillingStatusData + if (workspaceFilter.workspaceId) { + const attribution = await resolveBillingAttribution({ + actorUserId: userId, + workspaceId: workspaceFilter.workspaceId, + }) + const [usage, block] = await Promise.all([ + checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), + checkAttributedBillingBlocks(attribution), + ]) + data = { + workspaceId: workspaceFilter.workspaceId, + period: attribution.billingPeriod, + plan: attribution.payerSubscription?.plan ?? 'free', + status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + } else { + const subscription = await getHighestPrioritySubscription(userId) + const { billingEntity, billingPeriod } = deriveBillingContext(userId, subscription) + const [usage, actorBlock, payerBlock] = await Promise.all([ + checkUsageStatus(userId, subscription), + checkBillingBlocked(userId), + billingEntity.type === 'user' && billingEntity.id === userId + ? Promise.resolve({ blocked: false }) + : checkBillingEntityBlocked(billingEntity), + ]) + data = { + workspaceId: null, + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + plan: subscription?.plan ?? 'free', + status: + actorBlock.blocked || payerBlock.blocked + ? 'billing_blocked' + : usage.isExceeded + ? 'limit_exceeded' + : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + } + + return v2Data(data, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error building billing status`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts deleted file mode 100644 index b95206d3ccc..00000000000 --- a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { apportionCredits } from '@/lib/billing/credits/conversion' - -const { mockCheckRateLimit, mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockGetUserUsageLogs: vi.fn(), - mockGetUsageCreditsByLogId: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - getUserUsageLogs: mockGetUserUsageLogs, - getUsageCreditsByLogId: mockGetUsageCreditsByLogId, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { GET } from '@/app/api/v2/billing/usage/logs/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callLogs(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage/logs${query}`)) -} - -describe('GET /api/v2/billing/usage/logs', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockGetUserUsageLogs.mockResolvedValue({ - logs: [ - { - id: 'log-1', - createdAt: '2026-07-01T00:00:00.000Z', - category: 'model', - source: 'copilot', - description: 'claude-sonnet', - cost: 0.06, - }, - ], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: false }, - }) - mockGetUsageCreditsByLogId.mockResolvedValue( - apportionCredits([{ key: 'log-1', dollars: 0.06 }]) - ) - }) - - it('returns credit-denominated rows in the cursor envelope, no dollar costs', async () => { - const res = await callLogs() - expect(res.status).toBe(200) - const body = await res.json() - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'log-1', - createdAt: '2026-07-01T00:00:00.000Z', - source: 'sim-chat', - workflowName: null, - creditCost: 12, - }, - ]) - expect(JSON.stringify(body)).not.toContain('ollarCost') - }) - - it('forwards the cursor when more rows remain', async () => { - mockGetUserUsageLogs.mockResolvedValue({ - logs: [], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: true, nextCursor: 'log-42' }, - }) - mockGetUsageCreditsByLogId.mockResolvedValue({}) - const body = await (await callLogs()).json() - expect(body.nextCursor).toBe('log-42') - }) - - it('filters sim-chat across both internal ledgers', async () => { - const res = await callLogs('?source=sim-chat') - - expect(res.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) - expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) - }) - - it('rejects internal chat source names', async () => { - const res = await callLogs('?source=copilot') - - expect(res.status).toBe(400) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('pins a workspace API key to its own workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callLogs() - expect(res.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ workspaceId: 'ws-1' }) - ) - }) - - it('403s a workspace API key asking for a different workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callLogs('?workspaceId=ws-2') - expect(res.status).toBe(403) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('rejects "custom" period without a startDate', async () => { - const res = await callLogs('?period=custom') - expect(res.status).toBe(400) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callLogs() - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts deleted file mode 100644 index db51ac8098b..00000000000 --- a/apps/sim/app/api/v2/billing/usage/route.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockGetUserUsageLogs, - mockCheckServerSideUsageLimits, - mockGetHighestPrioritySubscription, - mockDeriveBillingContext, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockGetUserUsageLogs: vi.fn(), - mockCheckServerSideUsageLimits: vi.fn(), - mockGetHighestPrioritySubscription: vi.fn(), - mockDeriveBillingContext: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, -})) - -vi.mock('@/lib/billing', () => ({ - checkServerSideUsageLimits: mockCheckServerSideUsageLimits, -})) - -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: mockGetHighestPrioritySubscription, -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - deriveBillingContext: mockDeriveBillingContext, - getUserUsageLogs: mockGetUserUsageLogs, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { GET } from '@/app/api/v2/billing/usage/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callSummary(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage${query}`)) -} - -describe('GET /api/v2/billing/usage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) - mockDeriveBillingContext.mockReturnValue({ - billingEntity: { type: 'user', id: 'user-1' }, - billingPeriod: { - start: new Date('2026-07-01T00:00:00Z'), - end: new Date('2026-08-01T00:00:00Z'), - }, - }) - mockCheckServerSideUsageLimits.mockResolvedValue({ - isExceeded: false, - currentUsage: 2.5, - limit: 100, - }) - mockGetUserUsageLogs.mockResolvedValue({ - logs: [], - summary: { - totalCost: 2.5, - bySource: { workflow: 1.9, copilot: 0.4, 'workspace-chat': 0.2 }, - }, - pagination: { hasMore: false }, - }) - }) - - it('returns the billing-period summary with per-source credits, no dollars', async () => { - const res = await callSummary() - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data).toEqual({ - period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, - totalCredits: 500, - bySourceCredits: { workflow: 380, 'sim-chat': 120 }, - limitCredits: 20000, - plan: 'pro', - }) - expect(JSON.stringify(body)).not.toContain('dollar') - }) - - it('queries the ledger summary over the derived billing period', async () => { - await callSummary() - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ - startDate: new Date('2026-07-01T00:00:00Z'), - endDate: new Date('2026-08-01T00:00:00Z'), - includeSummary: true, - }) - ) - }) - - it('pins a workspace API key to its own workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callSummary() - expect(res.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ workspaceId: 'ws-1' }) - ) - }) - - it('403s a workspace API key asking for a different workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callSummary('?workspaceId=ws-2') - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callSummary() - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts deleted file mode 100644 index d7e8fa6715a..00000000000 --- a/apps/sim/app/api/v2/billing/usage/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { type V2UsageSummaryData, v2GetUsageSummaryContract } from '@/lib/api/contracts/v2/billing' -import { parseRequest } from '@/lib/api/server' -import { checkServerSideUsageLimits } from '@/lib/billing' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { aggregateBillingUsageBySource } from '@/lib/billing/usage-sources' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2BillingUsageAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** - * GET /api/v2/billing/usage — Current-billing-period usage summary with the - * per-source credit breakdown, for external monitoring (e.g. alerting on - * Sim Chat consumption before an overage). Credits only — dollar costs and - * rate-limit internals are not part of this surface. - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'billing-usage') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2GetUsageSummaryContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - - const subscription = await getHighestPrioritySubscription(userId) - const { billingPeriod } = deriveBillingContext(userId, subscription) - - const [usageCheck, ledger] = await Promise.all([ - checkServerSideUsageLimits(userId, subscription), - getUserUsageLogs(userId, { - workspaceId: workspaceFilter.workspaceId, - startDate: billingPeriod.start, - endDate: billingPeriod.end, - limit: 1, - includeSummary: true, - }), - ]) - - const bySourceCredits = Object.fromEntries( - Object.entries(aggregateBillingUsageBySource(ledger.summary.bySource)).map( - ([source, cost]) => [source, dollarsToCredits(cost)] - ) - ) - - const data: V2UsageSummaryData = { - period: { - start: billingPeriod.start.toISOString(), - end: billingPeriod.end.toISOString(), - }, - totalCredits: dollarsToCredits(ledger.summary.totalCost), - bySourceCredits, - limitCredits: dollarsToCredits(usageCheck.limit), - plan: subscription?.plan || 'free', - } - - return v2Data(data, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error building usage summary`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts index 3e7eea9ca70..9cf95afcbef 100644 --- a/apps/sim/app/api/v2/billing/utils.ts +++ b/apps/sim/app/api/v2/billing/utils.ts @@ -1,5 +1,5 @@ import type { NextResponse } from 'next/server' -import type { RateLimitResult } from '@/app/api/v1/middleware' +import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2Error } from '@/app/api/v2/lib/response' type BillingWorkspaceFilter = @@ -8,23 +8,54 @@ type BillingWorkspaceFilter = /** * Resolves the effective `workspaceId` ledger filter for the caller's key. - * Personal keys read the account's full ledger with whatever filter they asked - * for; a workspace-scoped key is pinned to its own workspace — the filter - * defaults to the key's workspace and an explicit mismatch is rejected rather - * than silently ignored. + * Personal keys may read their account-wide ledger without a filter. When any + * key targets a workspace, the caller must have read access and the workspace's + * API-key policy must allow the key type. Workspace-scoped keys remain pinned to + * their own workspace. */ -export function v2BillingWorkspaceFilter( +export async function v2BillingWorkspaceFilter( rateLimit: RateLimitResult, requestedWorkspaceId: string | undefined -): BillingWorkspaceFilter { - if (rateLimit.keyType !== 'workspace') { - return { ok: true, workspaceId: requestedWorkspaceId } - } - if (requestedWorkspaceId && requestedWorkspaceId !== rateLimit.workspaceId) { +): Promise { + if ( + rateLimit.keyType === 'workspace' && + requestedWorkspaceId && + requestedWorkspaceId !== rateLimit.workspaceId + ) { return { ok: false, response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), } } - return { ok: true, workspaceId: rateLimit.workspaceId } + + const workspaceId = + rateLimit.keyType === 'workspace' ? rateLimit.workspaceId : requestedWorkspaceId + + if (!workspaceId) { + if (rateLimit.keyType === 'workspace') { + return { + ok: false, + response: v2Error('FORBIDDEN', 'Workspace-scoped API key is missing its workspace'), + } + } + return { ok: true, workspaceId: undefined } + } + + const userId = rateLimit.userId + if (!userId) { + return { + ok: false, + response: v2Error('UNAUTHORIZED', 'Authentication required'), + } + } + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) { + return { + ok: false, + response: v2Error('FORBIDDEN', access.message), + } + } + + return { ok: true, workspaceId } } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index ef9318b1dc6..8f80e98e33e 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,8 +1,5 @@ -import { db } from '@sim/db' -import { document, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { type V2KnowledgeDocument, @@ -12,6 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' @@ -85,40 +83,7 @@ export const GET = withRouteHandler( ) if (result instanceof NextResponse) return result - const docs = await db - .select({ - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - processingStatus: document.processingStatus, - processingError: document.processingError, - processingStartedAt: document.processingStartedAt, - processingCompletedAt: document.processingCompletedAt, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, - uploadedAt: document.uploadedAt, - connectorId: document.connectorId, - connectorType: knowledgeConnector.connectorType, - sourceUrl: document.sourceUrl, - }) - .from(document) - .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - const doc = docs[0] + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) if (!doc) return v2Error('NOT_FOUND', 'Document not found') const documentDetail: V2KnowledgeDocument = { @@ -181,21 +146,7 @@ export const DELETE = withRouteHandler( ) if (result instanceof NextResponse) return result - const docs = await db - .select({ id: document.id, filename: document.filename }) - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - const doc = docs[0] + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) if (!doc) return v2Error('NOT_FOUND', 'Document not found') const outcome = await performDeleteKnowledgeDocument({ diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index b6adfff3c07..54be2ec3882 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -104,8 +104,14 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume ) if (result instanceof NextResponse) return result - // Opaque cursor encodes the underlying offset (upgradeable to keyset later). - const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + const decodedCursor = cursor ? decodeCursor<{ offset: number }>(cursor) : null + if ( + cursor && + (!decodedCursor || !Number.isInteger(decodedCursor.offset) || decodedCursor.offset < 0) + ) { + return v2Error('BAD_REQUEST', 'Invalid cursor') + } + const offset = decodedCursor?.offset ?? 0 const documentsResult = await getDocuments( knowledgeBaseId, diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 005edb3b919..ac8ca1c4842 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -15,18 +15,15 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' import { + executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, - getQueryStrategy, - handleTagAndVectorSearch, - handleTagOnlySearch, - handleVectorOnlySearch, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -197,50 +194,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const queryEmbeddingModel = embeddingModels[0] - let results: SearchResult[] + if (!hasQuery && !hasFilters) { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + let queryEmbeddingIsBYOK: boolean | null = null + let queryVector: string | undefined - if (!hasQuery && hasFilters) { - results = await handleTagOnlySearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - structuredFilters, - }) - } else if (hasQuery && hasFilters) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) + if (hasQuery) { const queryEmbeddingResult = await generateSearchEmbedding( query!, queryEmbeddingModel, workspaceId ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else if (hasQuery) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId - ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleVectorOnlySearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else { - return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + queryVector = JSON.stringify(queryEmbeddingResult.embedding) } + const results: SearchResult[] = await executeKnowledgeSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + searchMode: 'vector', + query, + queryVector, + structuredFilters, + }) + if (queryEmbeddingIsBYOK !== null) { await recordSearchEmbeddingUsage({ userId: billingActorUserId, diff --git a/apps/sim/app/api/v2/logs/[id]/route.test.ts b/apps/sim/app/api/v2/logs/[executionId]/route.test.ts similarity index 74% rename from apps/sim/app/api/v2/logs/[id]/route.test.ts rename to apps/sim/app/api/v2/logs/[executionId]/route.test.ts index 191775ad945..4c6f12038e4 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[executionId]/route.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionData: mockMaterializeExecutionData, })) -import { GET } from '@/app/api/v2/logs/[id]/route' +import { GET } from '@/app/api/v2/logs/[executionId]/route' const RATE_LIMIT_OK = { allowed: true, @@ -47,10 +47,11 @@ const RATE_LIMIT_OK = { } const LOG_ROW = { - id: 'log-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', executionId: 'execution-1', + deploymentVersionId: 'deployment-1', + status: 'completed', level: 'info', trigger: 'api', startedAt: new Date('2024-01-01T00:00:00Z'), @@ -60,6 +61,7 @@ const LOG_ROW = { costTotal: '0.01', files: null, createdAt: new Date('2024-01-01T00:00:00Z'), + workflowState: { blocks: {}, edges: [] }, workflowName: 'Support Agent', workflowDescription: 'Handles support requests', workflowFolderId: null, @@ -70,13 +72,13 @@ const LOG_ROW = { workflowArchivedAt: null, } -const routeContext = () => ({ params: Promise.resolve({ id: 'log-1' }) }) +const routeContext = () => ({ params: Promise.resolve({ executionId: 'execution-1' }) }) function callGet() { - return GET(new NextRequest('http://localhost:3000/api/v2/logs/log-1'), routeContext()) + return GET(new NextRequest('http://localhost:3000/api/v2/logs/execution-1'), routeContext()) } -describe('GET /api/v2/logs/[id]', () => { +describe('GET /api/v2/logs/[executionId]', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -86,7 +88,7 @@ describe('GET /api/v2/logs/[id]', () => { dbChainMockFns.limit.mockResolvedValue([LOG_ROW]) }) - it('returns materialized trace spans as a first-class log detail field', async () => { + it('uses executionId as the sole public identity and includes diagnostic data', async () => { const traceSpans = [ { id: 'span-1', @@ -106,17 +108,20 @@ describe('GET /api/v2/logs/[id]', () => { const body = await response.json() expect(response.status).toBe(200) + expect(body.data.executionId).toBe('execution-1') + expect(body.data).not.toHaveProperty('id') + expect(body.data).not.toHaveProperty('executionData') expect(body.data.traceSpans).toEqual(traceSpans) - expect(body.data.executionData.traceSpans).toEqual(traceSpans) + expect(body.data.finalOutput).toEqual({ answer: 'done' }) + expect(body.data.workflowState).toEqual({ blocks: {}, edges: [] }) }) - it('returns an empty trace span array when the execution has no spans', async () => { - mockMaterializeExecutionData.mockResolvedValue({ finalOutput: { answer: 'done' } }) + it('returns empty diagnostic collections when the execution produced none', async () => { + mockMaterializeExecutionData.mockResolvedValue({}) - const response = await callGet() - const body = await response.json() + const body = await (await callGet()).json() - expect(response.status).toBe(200) expect(body.data.traceSpans).toEqual([]) + expect(body.data.finalOutput).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[executionId]/route.ts similarity index 63% rename from apps/sim/app/api/v2/logs/[id]/route.ts rename to apps/sim/app/api/v2/logs/[executionId]/route.ts index fac1104f9b1..8f39bb47fbd 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[executionId]/route.ts @@ -1,16 +1,14 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' -import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' @@ -19,8 +17,13 @@ const logger = createLogger('V2LogDetailAPI') export const revalidate = 0 +/** + * Returns the diagnostic representation of an execution. The execution ID is + * the sole public identity; the workflow-execution-log row key remains an + * internal storage and pagination detail. + */ export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { const requestId = generateId().slice(0, 8) try { @@ -37,56 +40,26 @@ export const GET = withRouteHandler( }) if (!parsed.success) return parsed.response - const { id } = parsed.data.params + const { executionId } = parsed.data.params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - workflowArchivedAt: workflow.archivedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) + const log = await getPublicWorkflowLog({ column: 'executionId', value: executionId }) - const log = rows[0] if (!log) return v2Error('NOT_FOUND', 'Log not found') - // Convert an authorization failure into 404 so existence is not leaked. const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) if (access) return v2Error('NOT_FOUND', 'Log not found') const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') - const executionData = await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } ) - const traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? []) const detail: V2LogDetail = { - id: log.id, - workflowId: log.workflowId, executionId: log.executionId, + workflowId: log.workflowId, + deploymentVersionId: log.deploymentVersionId, + status: v2LogStatusSchema.parse(log.status), level: log.level, trigger: log.trigger, startedAt: log.startedAt.toISOString(), @@ -106,8 +79,9 @@ export const GET = withRouteHandler( updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, deleted: !log.workflowName || log.workflowArchivedAt !== null, }, - executionData, - traceSpans, + workflowState: log.workflowState, + traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), + finalOutput: executionData.finalOutput ?? null, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, createdAt: log.createdAt.toISOString(), } diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts deleted file mode 100644 index 5b811960412..00000000000 --- a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2ExecutionAPI') - -export const revalidate = 0 - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { - try { - const rateLimit = await checkRateLimit(request, 'logs-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetExecutionContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { executionId } = parsed.data.params - - const rows = await db - .select() - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) - - if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') - - const workflowLog = rows[0] - - // Convert an authorization failure into 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') - - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) - .limit(1) - - if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') - - const execution: V2Execution = { - executionId, - workflowId: workflowLog.workflowId, - workflowState: snapshot.stateData, - executionMetadata: { - trigger: workflowLog.trigger, - startedAt: workflowLog.startedAt.toISOString(), - endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, - totalDurationMs: workflowLog.totalDurationMs, - cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, - }, - } - - return v2Data(execution, { rateLimit }) - } catch (error) { - logger.error('Error fetching execution data', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index b09a0a24e30..4bebe07842c 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -1,24 +1,23 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' -import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { + type V2LogListItem, + v2ListLogsContract, + v2LogStatusSchema, +} from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, v2CursorList, v2Error, v2RateLimitError, @@ -71,6 +70,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) const includesRoot = resolvedFolderIds?.includes(null) ?? false + const decodedCursor = params.cursor + ? decodePublicLogCursor(params.cursor, params.order ?? 'desc') + : null + if (params.cursor && !decodedCursor) return v2Error('BAD_REQUEST', 'Invalid cursor') + const cursor = decodedCursor ?? undefined + const filters = { workspaceId: params.workspaceId, workflowIds: params.workflowIds?.split(',').filter(Boolean), @@ -85,64 +90,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { minCost: params.minCost, maxCost: params.maxCost, model: params.model, - cursor: params.cursor - ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined - : undefined, + cursor, order: params.order, } - const conditions = buildLogFilters(filters) - const rootFolderCondition = folderPaths - ? or( - includesRoot ? isNull(workflow.folderId) : undefined, - nonRootFolderIds && nonRootFolderIds.length > 0 - ? inArray(workflow.folderId, nonRootFolderIds) - : undefined - ) - : undefined - const orderBy = getOrderBy(params.order) - - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowArchivedAt: workflow.archivedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(and(conditions, rootFolderCondition)) - .orderBy(...orderBy) - .limit(params.limit + 1) - - const hasMore = rows.length > params.limit - const data = rows.slice(0, params.limit) - - let nextCursor: string | null = null - if (hasMore && data.length > 0) { - const lastLog = data[data.length - 1] - nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) - } + const { data, nextCursor } = await listPublicWorkflowLogs({ + filters, + limit: params.limit, + includeExecutionData: params.details === 'full', + folderScope: folderPaths ? { includesRoot, folderIds: nonRootFolderIds ?? [] } : undefined, + }) type LogRow = (typeof data)[number] const buildItem = (log: LogRow): V2LogListItem => { const item: V2LogListItem = { - id: log.id, - workflowId: log.workflowId, executionId: log.executionId, + workflowId: log.workflowId, deploymentVersionId: log.deploymentVersionId, + status: v2LogStatusSchema.parse(log.status), level: log.level, trigger: log.trigger, startedAt: log.startedAt.toISOString(), diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index ec2479d09b8..9089d6a47b6 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -13,9 +13,9 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { checkRateLimit } from '@/app/api/v1/middleware' -import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowDeployAPI') @@ -52,8 +52,8 @@ export const POST = withRouteHandler( const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) return v2ValidationError(body.error) - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) - if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const { workspaceId } = target await assertWorkflowMutable(id) @@ -130,8 +130,8 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) - if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const { workflow, workspaceId } = target if (!workflow.isDeployed) { diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts index 2d3ebe1166e..9d3ff613cf5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -4,7 +4,10 @@ import type { NextRequest } from 'next/server' import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { + cancelWorkflowExecution, + WorkflowExecutionNotFoundError, +} from '@/lib/execution/cancel-workflow-execution' import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' @@ -37,6 +40,9 @@ export const POST = withRouteHandler( return v2Data(result) } catch (error) { + if (error instanceof WorkflowExecutionNotFoundError) { + return v2Error('NOT_FOUND', error.message) + } logger.error('Failed to cancel execution', { workflowId, executionId, diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts new file mode 100644 index 00000000000..e97f6787cc8 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ + mockResolveV2WorkflowAccess: vi.fn(), +})) + +vi.mock('@/app/api/v2/workflows/lib/access', () => ({ + resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +})) + +import { GET } from '@/app/api/v2/workflows/[id]/executions/route' + +const routeContext = () => ({ params: Promise.resolve({ id: 'workflow-1' }) }) +const callGet = (query = '') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/workflow-1/executions${query}`), + routeContext() + ) + +const EXECUTIONS = [ + { + rowId: 'row-2', + executionId: 'execution-2', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + startedAt: new Date('2026-08-05T00:02:00Z'), + endedAt: null, + durationMs: null, + costTotal: '0.02', + }, + { + rowId: 'row-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'schedule', + startedAt: new Date('2026-08-05T00:01:00Z'), + endedAt: new Date('2026-08-05T00:01:03Z'), + durationMs: 3000, + costTotal: null, + }, +] + +describe('GET /api/v2/workflows/[id]/executions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockResolveV2WorkflowAccess.mockResolvedValue({ + ok: true, + userId: 'user-1', + keyType: 'workspace', + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + dbChainMockFns.limit.mockResolvedValue(EXECUTIONS) + }) + + it('lists lightweight execution resources in the cursor envelope', async () => { + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + startedAt: '2026-08-05T00:02:00.000Z', + endedAt: null, + durationMs: null, + cost: { total: 0.02 }, + }, + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'schedule', + startedAt: '2026-08-05T00:01:00.000Z', + endedAt: '2026-08-05T00:01:03.000Z', + durationMs: 3000, + cost: null, + }, + ]) + }) + + it('returns an opaque cursor when another row exists', async () => { + dbChainMockFns.limit.mockResolvedValue([...EXECUTIONS, { ...EXECUTIONS[1], rowId: 'row-0' }]) + + const body = await (await callGet('?limit=2')).json() + + expect(body.data).toHaveLength(2) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ + sort: 'startedAt:desc', + keys: ['2026-08-05T00:01:00.000Z', 'row-1'], + }) + }) + + it('rejects an invalid cursor', async () => { + const response = await callGet('?cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('rejects a cursor minted under a different order', async () => { + const cursor = Buffer.from( + JSON.stringify({ + sort: 'startedAt:desc', + keys: ['2026-08-05T00:01:00.000Z', 'row-1'], + }) + ).toString('base64') + + const response = await callGet(`?order=asc&cursor=${encodeURIComponent(cursor)}`) + + expect(response.status).toBe(400) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('rejects queued as a durable-history filter', async () => { + const response = await callGet('?status=queued') + + expect(response.status).toBe(400) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('authorizes the workflow before validating filters', async () => { + mockResolveV2WorkflowAccess.mockResolvedValue({ + ok: false, + response: new Response(null, { status: 404 }), + }) + + const response = await callGet('?limit=0') + + expect(response.status).toBe(404) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/route.ts new file mode 100644 index 00000000000..765c58025d0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/route.ts @@ -0,0 +1,98 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowExecutionListItem, + v2ListWorkflowExecutionsContract, + v2WorkflowExecutionListStatusValueSchema, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries' +import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Error, + v2ValidationError, +} from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' + +const logger = createLogger('V2WorkflowExecutionsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** List the durable executions belonging to one workflow. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: workflowId } = await context.params + const access = await resolveV2WorkflowAccess(request, workflowId, 'read') + if (!access.ok) return access.response + + const parsed = await parseRequest(v2ListWorkflowExecutionsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { status, trigger, startDate, endDate, limit, cursor, order } = parsed.data.query + const sort = cursorSortKey('startedAt', order) + const decodedCursor = decodeSortedCursor(cursor, sort) + if (decodedCursor.status === 'invalid') return v2CursorSortError() + const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] + const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null + if ( + decodedCursor.status === 'ok' && + (decodedCursor.keys.length !== 2 || + !cursorDate || + Number.isNaN(cursorDate.getTime()) || + typeof cursorRowId !== 'string') + ) { + return v2CursorSortError() + } + + try { + const result = await listWorkflowExecutions({ + workflowId, + status, + trigger, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + limit, + cursor: + decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' + ? { startedAt: cursorDate, rowId: cursorRowId } + : undefined, + order, + }) + + const data: V2WorkflowExecutionListItem[] = result.data.map((row) => ({ + executionId: row.executionId, + workflowId: row.workflowId ?? workflowId, + status: v2WorkflowExecutionListStatusValueSchema.parse(row.status), + trigger: row.trigger, + startedAt: row.startedAt.toISOString(), + endedAt: row.endedAt?.toISOString() ?? null, + durationMs: row.durationMs, + cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, + })) + + const nextCursor = result.nextCursor + ? encodeSortedCursor(sort, [ + result.nextCursor.startedAt.toISOString(), + result.nextCursor.rowId, + ]) + : null + + return v2CursorList(data, nextCursor) + } catch (error) { + logger.error('Failed to list workflow executions', { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 8d1cea75788..d1cd5ec5a21 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -11,9 +11,9 @@ import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { checkRateLimit } from '@/app/api/v1/middleware' -import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowRollbackAPI') @@ -50,8 +50,8 @@ export const POST = withRouteHandler( const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) return v2ValidationError(body.error) - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) - if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const { workflow, workspaceId } = target if (!workflow.isDeployed) { diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index f184af03c30..41a1d478044 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,5 +1,3 @@ -import { db } from '@sim/db' -import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, @@ -10,7 +8,6 @@ import { } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { type V2WorkflowDetail, @@ -24,6 +21,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -63,28 +61,18 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + const snapshot = await loadWorkflowReadSnapshot(id) + const workflowData = snapshot.workflowRecord + if (!workflowData?.workspaceId || workflowData.archivedAt) { + return v2Error('NOT_FOUND', 'Workflow not found') + } // Mask an authorization failure as 404 so existence is not leaked. const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) if (access) return v2Error('NOT_FOUND', 'Workflow not found') const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - - const blockRows = await db - .select({ - id: workflowBlocks.id, - type: workflowBlocks.type, - subBlocks: workflowBlocks.subBlocks, - }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, id)) - - const blocksRecord = Object.fromEntries( - blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) - ) - const inputs = extractInputFieldsFromBlocks(blocksRecord) + const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) const detail: V2WorkflowDetail = { id: workflowData.id, diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index d8096bf5ea5..1ed021c974b 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' @@ -10,9 +9,10 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { checkRateLimit } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowVersionDetailAPI') @@ -43,12 +43,8 @@ export const GET = withRouteHandler( const { id, version } = parsed.data.params - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const row = await getWorkflowDeploymentVersion(id, version) if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index 82c26667795..6d50be6db75 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' @@ -10,7 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { checkRateLimit } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, @@ -20,6 +19,7 @@ import { v2RateLimitError, v2ValidationError, } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowVersionsAPI') @@ -57,12 +57,8 @@ export const GET = withRouteHandler( const { id } = parsed.data.params const { limit, cursor } = parsed.data.query - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') /** * A cursor that decodes to anything other than a version number is diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index b314a1500de..0d77ccc618d 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,32 +1,18 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { type V2WorkflowListItem, - type V2WorkflowSortBy, v2CreateWorkflowContract, v2ListWorkflowsContract, } from '@/lib/api/contracts/v2/workflows' -import { - encodeKeyset, - type KeysetKey, - keysetAfter, - keysetColumns, - listOrderBy, - numberKey, - searchFilter, - textKey, - timestampKey, -} from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { InvalidWorkflowListCursorError, listWorkspaceWorkflows } from '@/lib/workflows/queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, @@ -53,40 +39,6 @@ const logger = createLogger('V2WorkflowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -type WorkflowRow = { - id: string - name: string - sortOrder: number - runCount: number - createdAt: Date - updatedAt: Date -} - -/** - * The keysets behind the sortable workflow fields. `satisfies` makes the map - * total over the contract enum, so a new sortable field cannot ship without an - * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which - * is what keeps a page boundary inside a run of equal values stable. - * - * `position` keeps its historical three-part ordering: workflows share a - * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle - * every workspace's default list. - */ -const workflowId = textKey(workflow.id, (row) => row.id) -const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt) - -const WORKFLOW_SORTS = { - position: [ - numberKey(workflow.sortOrder, (row) => row.sortOrder), - workflowCreatedAt, - workflowId, - ], - name: [textKey(workflow.name, (row) => row.name), workflowId], - createdAt: [workflowCreatedAt, workflowId], - updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId], - runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId], -} satisfies Record[]> - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -124,58 +76,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const sortKey = cursorSortKey(params.sortBy, params.sortOrder) - const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] const decoded = decodeSortedCursor(params.cursor, sortKey) if (decoded.status === 'invalid') return v2CursorSortError() - // `null` here is a cursor whose values don't fit this sort — a client error, not an empty page. - const resumeAfter = - decoded.status === 'ok' ? keysetAfter(keys, decoded.keys, params.sortOrder) : undefined - if (resumeAfter === null) return v2CursorSortError() - - const conditions = [ - eq(workflow.workspaceId, params.workspaceId), - isNull(workflow.archivedAt), - params.folderPath === undefined - ? undefined - : folderId === null - ? isNull(workflow.folderId) - : folderId === undefined - ? undefined - : eq(workflow.folderId, folderId), - params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, - searchFilter(workflow.name, params.search), - resumeAfter, - ] - - const rows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - folderId: workflow.folderId, - workspaceId: workflow.workspaceId, - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, - runCount: workflow.runCount, - lastRunAt: workflow.lastRunAt, - sortOrder: workflow.sortOrder, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, + let result + try { + result = await listWorkspaceWorkflows({ + workspaceId: params.workspaceId, + folderId, + deployedOnly: params.deployedOnly, + search: params.search, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + cursorKeys: decoded.status === 'ok' ? decoded.keys : undefined, + limit: params.limit, }) - .from(workflow) - .where(and(...conditions)) - .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) - .limit(params.limit + 1) - - const hasMore = rows.length > params.limit - const data = rows.slice(0, params.limit) + } catch (error) { + if (error instanceof InvalidWorkflowListCursorError) return v2CursorSortError() + throw error + } - const last = data.at(-1) - const nextCursor = - hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null + const nextCursor = result.nextCursorKeys + ? encodeSortedCursor(sortKey, result.nextCursorKeys) + : null - const formatted: V2WorkflowListItem[] = data.map((w) => ({ + const formatted: V2WorkflowListItem[] = result.data.map((w) => ({ id: w.id, name: w.name, description: w.description, diff --git a/apps/sim/app/api/v2/workflows/utils.ts b/apps/sim/app/api/v2/workflows/utils.ts new file mode 100644 index 00000000000..450521e2cca --- /dev/null +++ b/apps/sim/app/api/v2/workflows/utils.ts @@ -0,0 +1,20 @@ +import type { PermissionType } from '@sim/platform-authz/workspace' +import { + type DeploymentWorkflowTarget, + getDeploymentWorkflowTarget, +} from '@/lib/workflows/deployments/queries' +import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' + +/** Resolves an authorized active workflow while keeping the v2 response adapter route-local. */ +export async function resolveV2WorkflowTarget( + rateLimit: RateLimitResult, + userId: string, + workflowId: string, + level: PermissionType = 'read' +): Promise { + const target = await getDeploymentWorkflowTarget(workflowId) + if (!target) return null + + const accessError = await resolveWorkspaceAccess(rateLimit, userId, target.workspaceId, level) + return accessError ? null : target +} diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index 6ee6c71aa7d..9c36ab2617b 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -24,6 +24,10 @@ const { mockReadExecutionMetaState, mockWriteEvent, mockWriteTerminalEvent, + mockWorkflowExecutionBelongsToWorkflow, + mockGetJobQueue, + mockGetJob, + mockCancelJob, } = vi.hoisted(() => ({ mockMarkExecutionCancelled: vi.fn(), mockAbortManualExecution: vi.fn(), @@ -36,6 +40,19 @@ const { mockReadExecutionMetaState: vi.fn(), mockWriteEvent: vi.fn(), mockWriteTerminalEvent: vi.fn(), + mockWorkflowExecutionBelongsToWorkflow: vi.fn(), + mockGetJobQueue: vi.fn(), + mockGetJob: vi.fn(), + mockCancelJob: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: mockGetJobQueue, +})) + +vi.mock('@/lib/workflows/executor/execution-queries', () => ({ + workflowExecutionBelongsToWorkflow: (...args: unknown[]) => + mockWorkflowExecutionBelongsToWorkflow(...args), })) vi.mock('@/lib/execution/cancellation', () => ({ @@ -98,6 +115,10 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { mockReadExecutionMetaState.mockResolvedValue({ status: 'missing' }) mockWriteEvent.mockResolvedValue({ eventId: 1 }) mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 }) + mockWorkflowExecutionBelongsToWorkflow.mockResolvedValue(true) + mockGetJob.mockResolvedValue(null) + mockCancelJob.mockResolvedValue(undefined) + mockGetJobQueue.mockResolvedValue({ getJob: mockGetJob, cancelJob: mockCancelJob }) }) it('returns success when cancellation was durably recorded', async () => { @@ -140,6 +161,29 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { }) }) + it('durably cancels a queued execution through its queue run', async () => { + mockMarkExecutionCancelled.mockResolvedValue({ + durablyRecorded: false, + reason: 'redis_unavailable', + }) + mockGetJob.mockResolvedValue({ id: 'run-1', status: 'pending' }) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + executionId: 'ex-1', + redisAvailable: false, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:ex-1') + expect(mockCancelJob).toHaveBeenCalledWith('run-1') + }) + it('returns unsuccessful response when Redis persistence fails', async () => { mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: false, @@ -288,6 +332,17 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { expect(response.status).toBe(403) }) + it('returns 404 without mutating when the execution belongs to another workflow', async () => { + mockWorkflowExecutionBelongsToWorkflow.mockResolvedValue(false) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(404) + expect(mockMarkExecutionCancelled).not.toHaveBeenCalled() + expect(mockBeginPausedCancellation).not.toHaveBeenCalled() + expect(databaseMock.db.update).not.toHaveBeenCalled() + }) + it('updates execution log status in DB when durably recorded', async () => { const mockWhere = vi.fn().mockResolvedValue(undefined) const mockSet = vi.fn(() => ({ where: mockWhere })) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 6f5656a8a7e..11ce53defcf 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -6,7 +6,10 @@ import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { + cancelWorkflowExecution, + WorkflowExecutionNotFoundError, +} from '@/lib/execution/cancel-workflow-execution' const logger = createLogger('CancelExecutionAPI') @@ -58,6 +61,9 @@ export const POST = withRouteHandler( return NextResponse.json(result) } catch (error) { + if (error instanceof WorkflowExecutionNotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }) + } logger.error('Failed to cancel execution', { workflowId, executionId, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 1a3a35bc105..ec30ce24829 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,5 +1,3 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, @@ -8,7 +6,6 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/platform-authz/workflow' -import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -17,7 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowByIdAPI') @@ -85,14 +82,7 @@ export const GET = withRouteHandler( } } - const snapshot = await db.transaction(async (tx) => { - await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) - const [normalizedData, [workflowRecord]] = await Promise.all([ - loadWorkflowFromNormalizedTables(workflowId, tx), - tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), - ]) - return { normalizedData, workflowRecord } - }) + const snapshot = await loadWorkflowReadSnapshot(workflowId) const responseWorkflowData = snapshot.workflowRecord ?? workflowData // Stamp `workflowId` from the path param on each variable so the diff --git a/apps/sim/lib/api/contracts/common.ts b/apps/sim/lib/api/contracts/common.ts index d79833499db..1b4daa14241 100644 --- a/apps/sim/lib/api/contracts/common.ts +++ b/apps/sim/lib/api/contracts/common.ts @@ -106,7 +106,7 @@ export const getStatusContract = defineRouteContract({ }, }) -const jobStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed']) +const jobStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed', 'cancelled']) const jobStatusResponseSchema = z .object({ diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index 1084d9bbecb..c91d1f04971 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1AuditLogParamsSchema, @@ -8,10 +9,10 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha /** * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The - * request schemas are reused verbatim from v1 (the query/param shape is - * unchanged); only the response envelope is upgraded to the canonical v2 - * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated - * usage endpoint, not inlined into every response. + * filters are inherited from v1, with an explicit organization selector added + * so callers never depend on whichever membership happens to be returned + * first. The response uses the canonical v2 envelope and drops the v1 `limits` + * body — usage limits live on their dedicated endpoint. */ /** @@ -37,10 +38,16 @@ export const v2AuditLogEntrySchema = z.object({ export type V2AuditLogEntry = z.output +export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema + .extend({ organizationId: organizationIdSchema }) + .strict() + +export const v2GetAuditLogQuerySchema = z.object({ organizationId: organizationIdSchema }).strict() + export const v2ListAuditLogsContract = defineRouteContract({ method: 'GET', path: '/api/v2/audit-logs', - query: v1ListAuditLogsQuerySchema, + query: v2ListAuditLogsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2AuditLogEntrySchema), @@ -51,6 +58,7 @@ export const v2GetAuditLogContract = defineRouteContract({ method: 'GET', path: '/api/v2/audit-logs/[id]', params: v1AuditLogParamsSchema, + query: v2GetAuditLogQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2AuditLogEntrySchema), diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 7fd1196b916..6922a9cdc0e 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -4,7 +4,7 @@ import { usageLogPeriodSchema, usageLogSourceSchema } from '@/lib/api/contracts/ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' /** - * v2 billing contracts — the read-only, API-key-facing usage surface. + * v2 billing contracts — separate read-only status and ledger resources. * * Deliberately separate from the session-only `/api/users/me/usage-logs` * endpoints that back the Billing settings UI: the internal surface can evolve @@ -19,43 +19,45 @@ const parseableDateSchema = z .min(1) .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) -export const v2UsageSummaryQuerySchema = z.object({ +export const v2BillingStatusQuerySchema = z.object({ /** - * Restrict the breakdown to one workspace. A workspace-scoped API key is - * always pinned to its own workspace; passing a different id returns 403. + * Resolve status against one workspace's payer. A workspace-scoped API key + * is always pinned to its own workspace; passing a different id returns 403. */ workspaceId: z.string().optional(), }) /** - * Current-billing-period usage summary. `bySourceCredits` is the source-aware - * breakdown (workflow, sim-chat, knowledge-base, …) of the account's ledger for - * the period, so a monitor can watch one source's consumption directly instead - * of estimating it by subtraction. + * Current billing standing and credit allowance. Ledger rows and source + * analytics deliberately live outside this status resource. */ -export const v2UsageSummaryDataSchema = z.object({ +export const v2BillingStatusDataSchema = z.object({ + workspaceId: z.string().nullable(), period: z.object({ start: z.string(), end: z.string() }), - totalCredits: z.number(), - bySourceCredits: z.record(z.string(), z.number()), - limitCredits: z.number(), plan: z.string(), + status: z.enum(['active', 'limit_exceeded', 'billing_blocked']), + credits: z.object({ + used: z.number(), + limit: z.number(), + remaining: z.number(), + }), }) -export type V2UsageSummaryData = z.output +export type V2BillingStatusData = z.output -export const v2GetUsageSummaryContract = defineRouteContract({ +export const v2GetBillingStatusContract = defineRouteContract({ method: 'GET', - path: '/api/v2/billing/usage', - query: v2UsageSummaryQuerySchema, + path: '/api/v2/billing/status', + query: v2BillingStatusQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2UsageSummaryDataSchema), + schema: v2DataResponse(v2BillingStatusDataSchema), }, }) -export const v2UsageLogsQuerySchema = z +export const v2BillingLogsQuerySchema = z .object({ source: usageLogSourceSchema.optional(), - /** See {@link v2UsageSummaryQuerySchema}'s `workspaceId` — same pinning rules. */ + /** See {@link v2BillingStatusQuerySchema}'s `workspaceId` — same pinning rules. */ workspaceId: z.string().optional(), period: usageLogPeriodSchema.optional().default('30d'), /** Required when `period` is `'custom'`. */ @@ -76,22 +78,28 @@ export const v2UsageLogsQuerySchema = z * legitimately be 0 for a sub-credit event once a sibling row absorbs the * shared rounding remainder. */ -export const v2UsageLogEntrySchema = z.object({ +export const v2BillingLogEntrySchema = z.object({ id: z.string(), createdAt: z.string(), source: usageLogSourceSchema, - /** Populated only when `source` is `'workflow'`. */ - workflowName: z.string().nullable(), + workspaceId: z.string().nullable(), + workflow: z + .object({ + id: z.string(), + name: z.string().nullable(), + }) + .nullable(), + executionId: z.string().nullable(), creditCost: z.number(), }) -export type V2UsageLogEntry = z.output +export type V2BillingLogEntry = z.output -export const v2ListUsageLogsContract = defineRouteContract({ +export const v2ListBillingLogsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/billing/usage/logs', - query: v2UsageLogsQuerySchema, + path: '/api/v2/billing/logs', + query: v2BillingLogsQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2UsageLogEntrySchema), + schema: v2CursorListResponse(v2BillingLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index d9578c4fd2f..c0bfa33603d 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -1,11 +1,7 @@ import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v1ExecutionParamsSchema, - v1ListLogsQuerySchema, - v1LogParamsSchema, -} from '@/lib/api/contracts/v1/logs' +import { v1ListLogsQuerySchema } from '@/lib/api/contracts/v1/logs' import { v2CursorListResponse, v2DataResponse, @@ -20,6 +16,7 @@ import { */ const v2LogCostSchema = z.object({ total: z.number() }).nullable() +export const v2LogStatusSchema = z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']) /** Execution `files` is a per-run jsonb array of attachment metadata. */ const v2LogFilesSchema = z.array(z.unknown()).nullable() @@ -32,10 +29,10 @@ const v2LogWorkflowSummarySchema = z.object({ }) export const v2LogListItemSchema = z.object({ - id: z.string(), - workflowId: z.string().nullable(), executionId: z.string(), + workflowId: z.string().nullable(), deploymentVersionId: z.string().nullable(), + status: v2LogStatusSchema, level: z.string(), trigger: z.string(), startedAt: z.string(), @@ -54,9 +51,10 @@ export const v2LogListItemSchema = z.object({ export type V2LogListItem = z.output export const v2LogDetailSchema = z.object({ - id: z.string(), - workflowId: z.string().nullable(), executionId: z.string(), + workflowId: z.string().nullable(), + deploymentVersionId: z.string().nullable(), + status: v2LogStatusSchema, level: z.string(), trigger: z.string(), startedAt: z.string(), @@ -74,32 +72,22 @@ export const v2LogDetailSchema = z.object({ updatedAt: z.string().nullable(), deleted: z.boolean(), }), - /** Materialized execution trace (block states, trace spans). */ - executionData: z.unknown(), + /** Workflow state snapshot captured for this execution. */ + workflowState: z.unknown(), /** Materialized block-level execution trace spans. */ traceSpans: traceSpansSchema, + /** Materialized final output, when the execution produced one. */ + finalOutput: z.unknown().nullable(), cost: v2LogCostSchema, createdAt: z.string(), }) export type V2LogDetail = z.output -export const v2ExecutionSchema = z.object({ - executionId: z.string(), - workflowId: z.string().nullable(), - /** Workflow state snapshot at execution time. */ - workflowState: z.unknown(), - executionMetadata: z.object({ - trigger: z.string(), - startedAt: z.string(), - endedAt: z.string().nullable(), - totalDurationMs: z.number().nullable(), - cost: v2LogCostSchema, - }), +export const v2LogParamsSchema = z.object({ + executionId: z.string().min(1, 'executionId cannot be empty'), }) -export type V2Execution = z.output - export const v2ListLogsQuerySchema = v1ListLogsQuerySchema .omit({ folderIds: true }) .extend({ @@ -140,20 +128,10 @@ export const v2ListLogsContract = defineRouteContract({ export const v2GetLogContract = defineRouteContract({ method: 'GET', - path: '/api/v2/logs/[id]', - params: v1LogParamsSchema, + path: '/api/v2/logs/[executionId]', + params: v2LogParamsSchema, response: { mode: 'json', schema: v2DataResponse(v2LogDetailSchema), }, }) - -export const v2GetExecutionContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/logs/executions/[executionId]', - params: v1ExecutionParamsSchema, - response: { - mode: 'json', - schema: v2DataResponse(v2ExecutionSchema), - }, -}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 8fcae222c9a..aa3efa413bf 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -462,6 +462,73 @@ export const v2ResumeWorkflowContract = defineRouteContract({ }, }) +export const v2WorkflowExecutionStatusValueSchema = z.enum([ + 'queued', + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled', + 'paused', +]) + +export const v2WorkflowExecutionListStatusValueSchema = z.enum([ + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled', + 'paused', +]) + +export const v2ListWorkflowExecutionsQuerySchema = z + .object({ + status: v2WorkflowExecutionListStatusValueSchema.optional(), + trigger: z.string().min(1, 'trigger cannot be empty').optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1, 'cursor cannot be empty').optional(), + order: z.enum(['asc', 'desc']).optional().default('desc'), + }) + .strict() + .refine( + (query) => + !query.startDate || + !query.endDate || + Date.parse(query.startDate) <= Date.parse(query.endDate), + { + message: 'startDate must be before or equal to endDate', + path: ['startDate'], + } + ) + +export type V2ListWorkflowExecutionsQuery = z.output + +export const v2WorkflowExecutionListItemSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: v2WorkflowExecutionListStatusValueSchema, + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + durationMs: z.number().nullable(), + cost: z.object({ total: z.number() }).nullable(), +}) + +export type V2WorkflowExecutionListItem = z.output + +export const v2ListWorkflowExecutionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/executions', + params: workflowIdParamsSchema, + query: v2ListWorkflowExecutionsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowExecutionListItemSchema), + }, +}) + /** * The polled execution resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 @@ -471,7 +538,7 @@ export const v2ResumeWorkflowContract = defineRouteContract({ export const v2WorkflowExecutionStatusSchema = z.object({ executionId: z.string(), workflowId: z.string(), - status: z.enum(['queued', 'pending', 'running', 'completed', 'failed', 'cancelled', 'paused']), + status: v2WorkflowExecutionStatusValueSchema, trigger: z.string().nullable(), startedAt: z.string().nullable(), endedAt: z.string().nullable(), diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index 8740d11a541..eb0d450705d 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -12,7 +12,8 @@ import { type SQLWrapper, sql, } from 'drizzle-orm' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' + +export type ListSortOrder = 'asc' | 'desc' /** * Runtime half of the v2 list convention declared in @@ -121,7 +122,7 @@ export function timestampKey(column: Column, read: (row: Row) => Date): Key } } -export function sortDirection(order: V2SortOrder): typeof asc { +export function sortDirection(order: ListSortOrder): typeof asc { return order === 'asc' ? asc : desc } @@ -130,7 +131,7 @@ export function sortDirection(order: V2SortOrder): typeof asc { * On a paginated list these are the keyset's keys; on a single-page list they * are just the sort plus its tiebreaker. */ -export function listOrderBy(keys: readonly SQLWrapper[], order: V2SortOrder): SQL[] { +export function listOrderBy(keys: readonly SQLWrapper[], order: ListSortOrder): SQL[] { const direction = sortDirection(order) return keys.map((key) => direction(key)) } @@ -157,7 +158,7 @@ export function encodeKeyset(keys: readonly KeysetKey[], row: Row): Cu export function keysetAfter( keys: readonly KeysetKey[], values: CursorKey[], - order: V2SortOrder + order: ListSortOrder ): SQL | null { if (values.length !== keys.length) return null diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts index 111fb7007a9..3840a867820 100644 --- a/apps/sim/lib/billing/core/billing-attribution.ts +++ b/apps/sim/lib/billing/core/billing-attribution.ts @@ -449,7 +449,7 @@ export function requireAccountBillingDecisionHeader( } } -function toUsageSubscription(attribution: BillingAttributionSnapshot) { +export function toUsageLimitSubscription(attribution: BillingAttributionSnapshot) { const snapshot = attribution.payerSubscription if (!snapshot) { if (!attribution.organizationId) return null @@ -691,7 +691,7 @@ export async function checkAttributedUsageLimits( const payerUsage = await checkUsageStatus( validatedAttribution.billedAccountUserId, - toUsageSubscription(validatedAttribution) + toUsageLimitSubscription(validatedAttribution) ) const payerSnapshot = { currentUsage: payerUsage.currentUsage, diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index caeefc28f9c..f558ba20ff4 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -69,7 +69,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) -vi.mock('@/app/api/knowledge/search/utils', () => ({ +vi.mock('@/lib/knowledge/search/queries', () => ({ executeKnowledgeSearch: vi.fn(), })) vi.mock('@/app/api/knowledge/utils', () => ({ diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 89f3e02867a..c44c667f14a 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -35,6 +35,7 @@ import { performUpdateKnowledgeDocument, performUploadKnowledgeDocument, } from '@/lib/knowledge/orchestration' +import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { createTagDefinition, @@ -48,7 +49,6 @@ import { import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getCredential } from '@/app/api/auth/oauth/utils' -import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, checkKnowledgeBaseAccess, diff --git a/apps/sim/lib/core/async-jobs/backends/database.test.ts b/apps/sim/lib/core/async-jobs/backends/database.test.ts index 1d7207031bc..719ddc1c83e 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMock, dbChainMockFns, flattenMockConditions, resetDbChainMock } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,6 +9,7 @@ vi.mock('@sim/db', () => ({ asyncJobs: { attempts: 'attempts', id: 'id', + status: 'status', }, db: dbChainMock.db, })) @@ -112,3 +113,47 @@ describe('DatabaseJobQueue batchEnqueueAndWait', () => { expect(maxInFlight).toBe(2) }) }) + +describe('DatabaseJobQueue cancelJob', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('persists cancellation as its own terminal status', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'workflow:1' }]) + const queue = new DatabaseJobQueue() + + await queue.cancelJob('workflow:1') + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'cancelled', error: 'Cancelled' }) + ) + }) + + it('does not let worker failure overwrite a terminal cancellation', async () => { + const queue = new DatabaseJobQueue() + + await queue.markJobFailed('workflow:1', 'aborted') + + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toContainEqual({ + type: 'inArray', + column: 'status', + values: ['pending', 'processing'], + }) + }) + + it('does not let worker completion overwrite a terminal cancellation', async () => { + const queue = new DatabaseJobQueue() + + await queue.completeJob('workflow:1', { ok: true }) + + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toContainEqual({ + type: 'inArray', + column: 'status', + values: ['pending', 'processing'], + }) + }) +}) diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 45feb6ed412..407dde863ae 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -2,7 +2,7 @@ import { asyncJobs, db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { AsyncJobEnqueueError, type EnqueueOptions, @@ -261,7 +261,7 @@ export class DatabaseJobQueue implements JobQueueBackend { attempts: sql`${asyncJobs.attempts} + 1`, updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where(and(eq(asyncJobs.id, jobId), eq(asyncJobs.status, JOB_STATUS.PENDING))) logger.debug('Started job', { jobId }) } @@ -277,7 +277,12 @@ export class DatabaseJobQueue implements JobQueueBackend { output: output as Record, updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where( + and( + eq(asyncJobs.id, jobId), + inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING]) + ) + ) logger.debug('Completed job', { jobId }) } @@ -293,33 +298,45 @@ export class DatabaseJobQueue implements JobQueueBackend { error, updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where( + and( + eq(asyncJobs.id, jobId), + inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING]) + ) + ) logger.debug('Marked job as failed', { jobId }) } async cancelJob(jobId: string): Promise { - // Abort any in-process inline execution first so the running workflow - // observes the signal and stops mid-flight. Then mark the row failed so - // any future poller skips it. - const controller = inlineAbortControllers.get(jobId) - let aborted = false - if (controller) { - controller.abort('Cancelled') - inlineAbortControllers.delete(jobId) - aborted = true - } - const now = new Date() - await db + const cancelledJobs = await db .update(asyncJobs) .set({ - status: JOB_STATUS.FAILED, + status: JOB_STATUS.CANCELLED, completedAt: now, error: 'Cancelled', updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where( + and( + eq(asyncJobs.id, jobId), + inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING]) + ) + ) + .returning({ id: asyncJobs.id }) + + if (cancelledJobs.length === 0) { + logger.debug('Cancel target is no longer active in DB queue', { jobId }) + return + } + + const controller = inlineAbortControllers.get(jobId) + const aborted = Boolean(controller) + if (controller) { + controller.abort('Cancelled') + inlineAbortControllers.delete(jobId) + } logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted }) } @@ -353,10 +370,16 @@ export class DatabaseJobQueue implements JobQueueBackend { await acquireSlot(concurrencyKey, concurrencyLimit) } try { + abortController.signal.throwIfAborted() await this.startJob(jobId) + abortController.signal.throwIfAborted() await runner(payload, abortController.signal) await this.completeJob(jobId, null) } catch (err) { + if (abortController.signal.aborted) { + logger.info(`[${type}] Inline job ${jobId} cancelled`) + return + } const message = toError(err).message logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) try { diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts index 7e3577dab0a..453f9000436 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts @@ -152,10 +152,27 @@ describe('TriggerDevJobQueue getJob', () => { }) expect(mockRetrieveRun).toHaveBeenNthCalledWith(2, 'run-1') expect(job).toMatchObject({ - id: 'workflow-execution:execution-1', + id: 'run-1', status: 'completed', output: { output: { answer: 42 } }, metadata: { workflowId: 'workflow-1' }, }) }) + + it('preserves a cancelled Trigger.dev run as cancelled', async () => { + mockRetrieveRun.mockResolvedValueOnce({ + id: 'run-cancelled', + taskIdentifier: 'workflow-execution', + payload: { workflowId: 'workflow-1' }, + status: 'CANCELED', + createdAt: '2026-08-05T12:00:00.000Z', + finishedAt: '2026-08-05T12:00:01.000Z', + attemptCount: 0, + }) + const queue = new TriggerDevJobQueue() + + const job = await queue.getJob('run-cancelled') + + expect(job).toMatchObject({ id: 'run-cancelled', status: 'cancelled' }) + }) }) diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 4059f3066dc..d0b0ea1880b 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -63,6 +63,7 @@ function mapTriggerDevStatus(status: string): JobStatus { case 'COMPLETED': return JOB_STATUS.COMPLETED case 'CANCELED': + return JOB_STATUS.CANCELLED case 'FAILED': case 'CRASHED': case 'INTERRUPTED': @@ -221,7 +222,7 @@ export class TriggerDevJobQueue implements JobQueueBackend { } return { - id: jobId, + id: run.id, type: run.taskIdentifier as JobType, payload: run.payload, status: mapTriggerDevStatus(run.status), diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 9a1ee04aefa..fb31c200b77 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -2,10 +2,10 @@ * Types and constants for the async job queue system */ -/** Retention period for completed/failed jobs (in hours) */ +/** Retention period for terminal jobs (in hours) */ export const JOB_RETENTION_HOURS = 24 -/** Retention period for completed/failed jobs (in seconds, for Redis TTL) */ +/** Retention period for terminal jobs (in seconds, for Redis TTL) */ export const JOB_RETENTION_SECONDS = JOB_RETENTION_HOURS * 60 * 60 /** Max lifetime for jobs in Redis (in seconds) - cleanup for stuck pending/processing jobs */ @@ -16,6 +16,7 @@ export const JOB_STATUS = { PROCESSING: 'processing', COMPLETED: 'completed', FAILED: 'failed', + CANCELLED: 'cancelled', } as const export type JobStatus = (typeof JOB_STATUS)[keyof typeof JOB_STATUS] diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts index 8a1b35af297..feb2fc95518 100644 --- a/apps/sim/lib/execution/cancel-workflow-execution.ts +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -3,6 +3,7 @@ import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { and, eq } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs' import { type ExecutionCancellationRecordResult, markExecutionCancelled, @@ -10,12 +11,28 @@ import { import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' +import { workflowExecutionBelongsToWorkflow } from '@/lib/workflows/executor/execution-queries' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' const logger = createLogger('CancelWorkflowExecution') const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 const PAUSED_CANCELLATION_DB_RETRY_MS = 200 +async function cancelActiveWorkflowJob(executionId: string): Promise { + try { + const queue = await getJobQueue() + const job = await queue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) + if (!job || (job.status !== 'pending' && job.status !== 'processing')) return false + await queue.cancelJob(job.id) + logger.info('Cancelled active workflow queue job', { executionId, jobId: job.id }) + return true + } catch (error) { + logger.warn('Failed to cancel active workflow queue job', { executionId, error }) + return false + } +} + /** * Cancellation outcome vocabulary. `recorded`/`redis_unavailable`/ * `redis_write_failed` come from the Redis record step; the two `paused_*` @@ -120,6 +137,13 @@ export interface CancelWorkflowExecutionInput { workspaceId?: string } +export class WorkflowExecutionNotFoundError extends Error { + constructor() { + super('Execution not found') + this.name = 'WorkflowExecutionNotFoundError' + } +} + /** * Cancels a workflow execution across the Redis abort record, the in-process * aborter, and the paused-HITL machinery. The interleaving is order-sensitive @@ -131,6 +155,9 @@ export async function cancelWorkflowExecution( ): Promise { const { executionId, workflowId, userId, workspaceId } = input + const belongsToWorkflow = await workflowExecutionBelongsToWorkflow(executionId, workflowId) + if (!belongsToWorkflow) throw new WorkflowExecutionNotFoundError() + let pausedCancellationStarted = false let pausedCancelled = false try { @@ -153,11 +180,16 @@ export async function cancelWorkflowExecution( ? { durablyRecorded: false, reason: 'redis_unavailable' } : await markExecutionCancelled(executionId) const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) + const queuedJobCancelled = isPausedCancellationPath + ? false + : await cancelActiveWorkflowJob(executionId) if (pausedCancellationStarted) { logger.info('Paused execution cancellation reserved in database', { executionId }) } else if (cancellation.durablyRecorded) { logger.info('Execution marked as cancelled in Redis', { executionId }) + } else if (queuedJobCancelled) { + logger.info('Execution cancelled in workflow queue', { executionId }) } else if (locallyAborted) { logger.info('Execution cancelled via local in-process fallback', { executionId }) } else if (!pausedCancellationStarted) { @@ -167,7 +199,10 @@ export async function cancelWorkflowExecution( }) } - if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { + if ( + !isPausedCancellationPath && + (cancellation.durablyRecorded || queuedJobCancelled || locallyAborted) + ) { await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( (error) => { logger.warn('Failed to block queued paused resumes after cancellation', { @@ -235,7 +270,7 @@ export async function cancelWorkflowExecution( ) } - if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { + if ((cancellation.durablyRecorded || queuedJobCancelled || locallyAborted) && !pausedCancelled) { try { await db .update(workflowExecutionLogs) @@ -257,7 +292,7 @@ export async function cancelWorkflowExecution( const success = (isPausedCancellationPath ? pausedCancelled && pausedCancellationPublished - : cancellation.durablyRecorded) || locallyAborted + : cancellation.durablyRecorded || queuedJobCancelled) || locallyAborted if (success) { captureServerEvent( @@ -270,7 +305,7 @@ export async function cancelWorkflowExecution( const durablyRecorded = isPausedCancellationPath ? pausedCancellationPublished - : pausedCancelled || cancellation.durablyRecorded + : pausedCancelled || cancellation.durablyRecorded || queuedJobCancelled const reason: CancelWorkflowExecutionReason = pausedCancellationPublishFailed ? 'paused_event_publish_failed' : !pausedCancelled && isPausedCancellationPath @@ -279,7 +314,9 @@ export async function cancelWorkflowExecution( ? 'paused_event_publish_failed' : pausedCancelled || isPausedCancellationPath ? 'recorded' - : cancellation.reason + : queuedJobCancelled + ? 'recorded' + : cancellation.reason return { success, diff --git a/apps/sim/lib/execution/cancellation.test.ts b/apps/sim/lib/execution/cancellation.test.ts index 2a59904326c..a10eec2f8f8 100644 --- a/apps/sim/lib/execution/cancellation.test.ts +++ b/apps/sim/lib/execution/cancellation.test.ts @@ -1,5 +1,6 @@ import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { JOB_MAX_LIFETIME_SECONDS } from '@/lib/core/async-jobs/types' const { mockRedisSet, mockPublish, mockSubscribe } = vi.hoisted(() => ({ mockRedisSet: vi.fn(), @@ -46,6 +47,12 @@ describe('markExecutionCancelled', () => { durablyRecorded: true, reason: 'recorded', }) + expect(mockRedisSet).toHaveBeenCalledWith( + 'execution:cancel:execution-1', + '1', + 'EX', + JOB_MAX_LIFETIME_SECONDS + ) }) it('returns redis_write_failed when Redis write throws', async () => { diff --git a/apps/sim/lib/execution/cancellation.ts b/apps/sim/lib/execution/cancellation.ts index a08ea280ed4..9911d93f020 100644 --- a/apps/sim/lib/execution/cancellation.ts +++ b/apps/sim/lib/execution/cancellation.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' +import { JOB_MAX_LIFETIME_SECONDS } from '@/lib/core/async-jobs/types' import { getRedisClient } from '@/lib/core/config/redis' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' const logger = createLogger('ExecutionCancellation') const EXECUTION_CANCEL_PREFIX = 'execution:cancel:' -const EXECUTION_CANCEL_EXPIRY = 60 * 60 +const EXECUTION_CANCEL_EXPIRY = JOB_MAX_LIFETIME_SECONDS const EXECUTION_CANCEL_CHANNEL = 'execution:cancel' export interface ExecutionCancelEvent { diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 70b6dc6c691..15d3af4cca4 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -12,7 +12,18 @@ import { sha256Hex } from '@sim/security/hash' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' -import { and, asc, desc, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' +import { + and, + asc, + desc, + eq, + getTableColumns, + inArray, + isNotNull, + isNull, + type SQL, + sql, +} from 'drizzle-orm' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, @@ -1504,6 +1515,36 @@ export async function getDocuments( } } +export type ActiveKnowledgeDocument = typeof document.$inferSelect & { + connectorType: string | null +} + +/** Loads one visible document and its connector metadata for every API adapter. */ +export async function getKnowledgeDocument( + knowledgeBaseId: string, + documentId: string +): Promise { + const [row] = await db + .select({ + ...getTableColumns(document), + connectorType: knowledgeConnector.connectorType, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + return row ? { ...row, connectorType: row.connectorType ?? null } : null +} + export async function createSingleDocument( documentData: { filename: string diff --git a/apps/sim/lib/knowledge/documents/tag-filter.ts b/apps/sim/lib/knowledge/documents/tag-filter.ts index a41a4cf337e..740e97bf65d 100644 --- a/apps/sim/lib/knowledge/documents/tag-filter.ts +++ b/apps/sim/lib/knowledge/documents/tag-filter.ts @@ -44,7 +44,7 @@ function escapeLikePattern(s: string): string { * * Text comparisons are case-insensitive and date comparisons are evaluated on * the calendar day, matching the semantics of the knowledge base search filter - * (`app/api/knowledge/search/utils.ts`). Returns `undefined` when the slot, + * (`lib/knowledge/search/queries.ts`). Returns `undefined` when the slot, * operator, or value is not usable so the caller can skip the condition. */ export function buildTagFilterCondition(filter: TagFilterCondition): SQL | undefined { diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/lib/knowledge/search/queries.ts similarity index 99% rename from apps/sim/app/api/knowledge/search/utils.ts rename to apps/sim/lib/knowledge/search/queries.ts index 1a3b62fbf87..4a6c29a5a2d 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -5,7 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' -const logger = createLogger('KnowledgeSearch') +const logger = createLogger('KnowledgeSearchQueries') export interface DocumentMetadata { filename: string diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 2ad0c43ffa2..b455b72eff0 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -1,12 +1,5 @@ import { db } from '@sim/db' -import { - jobExecutionLogs, - pausedExecutions, - usageLog, - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, -} from '@sim/db/schema' +import { jobExecutionLogs, usageLog } from '@sim/db/schema' import { and, eq, type SQL } from 'drizzle-orm' import type { CostLedger } from '@/lib/api/contracts/logs' import { @@ -16,6 +9,7 @@ import { pickLatestStartedMarker, } from '@/lib/logs/execution/progress-markers' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type LookupColumn = 'id' | 'executionId' @@ -97,51 +91,7 @@ export async function fetchLogDetail({ const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.hasAccess) return null - const workflowMatch: SQL = - lookupColumn === 'id' - ? eq(workflowExecutionLogs.id, lookupValue) - : eq(workflowExecutionLogs.executionId, lookupValue) - - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) - .where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId))) - .limit(1) - - const log = rows[0] + const log = await getPublicWorkflowLog({ column: lookupColumn, value: lookupValue }, workspaceId) if (log) { const workflowSummary = log.workflowId diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/lib/logs/public-filters.ts similarity index 98% rename from apps/sim/app/api/v1/logs/filters.ts rename to apps/sim/lib/logs/public-filters.ts index ab540813893..639ba77ca12 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/lib/logs/public-filters.ts @@ -1,6 +1,7 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +/** Query filters shared by the v1 and v2 public log adapters. */ export interface LogFilters { workspaceId: string workflowIds?: string[] diff --git a/apps/sim/lib/logs/public-queries.test.ts b/apps/sim/lib/logs/public-queries.test.ts new file mode 100644 index 00000000000..8e3e4092a65 --- /dev/null +++ b/apps/sim/lib/logs/public-queries.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { decodePublicLogCursor, encodePublicLogCursor } from '@/lib/logs/public-queries' + +describe('public log cursor', () => { + const cursor = { + startedAt: '2026-08-05T00:01:00.000Z', + id: 'log-1', + order: 'desc' as const, + } + + it('round-trips under the order that minted it', () => { + expect(decodePublicLogCursor(encodePublicLogCursor(cursor), 'desc')).toEqual(cursor) + }) + + it('rejects reuse under a different order', () => { + expect(decodePublicLogCursor(encodePublicLogCursor(cursor), 'asc')).toBeNull() + }) + + it('rejects legacy cursors without an order binding', () => { + const legacyCursor = Buffer.from( + JSON.stringify({ startedAt: cursor.startedAt, id: cursor.id }) + ).toString('base64') + + expect(decodePublicLogCursor(legacyCursor, 'desc')).toBeNull() + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts new file mode 100644 index 00000000000..f372bbf74ab --- /dev/null +++ b/apps/sim/lib/logs/public-queries.ts @@ -0,0 +1,185 @@ +import { db } from '@sim/db' +import { + pausedExecutions, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, + workflowExecutionSnapshots, +} from '@sim/db/schema' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { buildLogFilters, getOrderBy, type LogFilters } from '@/lib/logs/public-filters' + +export interface PublicLogCursor { + startedAt: string + id: string + order: 'asc' | 'desc' +} + +export function encodePublicLogCursor(cursor: PublicLogCursor): string { + return Buffer.from(JSON.stringify(cursor)).toString('base64') +} + +export function decodePublicLogCursor( + cursor: string, + expectedOrder: 'asc' | 'desc' +): PublicLogCursor | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) as Record + if ( + typeof parsed.startedAt !== 'string' || + typeof parsed.id !== 'string' || + (parsed.order !== 'asc' && parsed.order !== 'desc') || + parsed.order !== expectedOrder + ) { + return null + } + const startedAt = new Date(parsed.startedAt) + if (Number.isNaN(startedAt.getTime())) return null + return { startedAt: parsed.startedAt, id: parsed.id, order: parsed.order } + } catch { + return null + } +} + +export interface ListPublicWorkflowLogsInput { + filters: LogFilters + limit: number + includeExecutionData: boolean + folderScope?: { + includesRoot: boolean + folderIds: string[] + } +} + +/** + * Reads the workflow-execution log page shared by the v1 and v2 public + * adapters. Folder path resolution remains an adapter concern; this query takes + * the resulting ids and applies one coherent root/non-root predicate. + */ +export async function listPublicWorkflowLogs(input: ListPublicWorkflowLogsInput) { + const filters = input.folderScope ? { ...input.filters, folderIds: undefined } : input.filters + const conditions = buildLogFilters(filters) + const folderCondition = input.folderScope + ? or( + input.folderScope.includesRoot ? isNull(workflow.folderId) : undefined, + input.folderScope.folderIds.length > 0 + ? inArray(workflow.folderId, input.folderScope.folderIds) + : undefined + ) + : undefined + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: input.includeExecutionData ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(and(conditions, folderCondition)) + .orderBy(...getOrderBy(input.filters.order)) + .limit(input.limit + 1) + + const hasMore = rows.length > input.limit + const data = rows.slice(0, input.limit) + const last = data.at(-1) + const nextCursor = + hasMore && last + ? encodePublicLogCursor({ + startedAt: last.startedAt.toISOString(), + id: last.id, + order: input.filters.order ?? 'desc', + }) + : null + + return { data, nextCursor } +} + +export type PublicWorkflowLogLookup = + | { column: 'id'; value: string } + | { column: 'executionId'; value: string } + +/** + * Loads one workflow log and its optional workflow snapshot. The snapshot join + * is deliberately left-sided: a missing snapshot does not make an otherwise + * valid execution disappear from the log resource. + */ +export async function getPublicWorkflowLog(lookup: PublicWorkflowLogLookup, workspaceId?: string) { + const lookupCondition = + lookup.column === 'id' + ? eq(workflowExecutionLogs.id, lookup.value) + : eq(workflowExecutionLogs.executionId, lookup.value) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowState: workflowExecutionSnapshots.stateData, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + }) + .from(workflowExecutionLogs) + .leftJoin( + workflowExecutionSnapshots, + eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id) + ) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where( + and( + lookupCondition, + workspaceId ? eq(workflowExecutionLogs.workspaceId, workspaceId) : undefined + ) + ) + .limit(1) + + return rows[0] ?? null +} diff --git a/apps/sim/lib/workflows/deployments/queries.ts b/apps/sim/lib/workflows/deployments/queries.ts new file mode 100644 index 00000000000..9301cc03784 --- /dev/null +++ b/apps/sim/lib/workflows/deployments/queries.ts @@ -0,0 +1,15 @@ +import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow' + +export interface DeploymentWorkflowTarget { + workflow: ActiveWorkflowRecord + workspaceId: string +} + +/** Loads the active workflow facts shared by every deployment adapter. */ +export async function getDeploymentWorkflowTarget( + workflowId: string +): Promise { + const workflow = await getActiveWorkflowRecord(workflowId) + if (!workflow?.workspaceId) return null + return { workflow, workspaceId: workflow.workspaceId } +} diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index 4e8992bafd4..b55515a1427 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -4,14 +4,18 @@ import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservati import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' import type { CoreTriggerType } from '@/stores/logs/filters/types' const logger = createLogger('WorkflowEnqueueExecution') const ASYNC_ENQUEUE_ATTEMPTS = 2 -export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' -export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:' + +export { + RESUME_EXECUTION_JOB_ID_PREFIX, + WORKFLOW_EXECUTION_JOB_ID_PREFIX, +} from '@/lib/workflows/executor/execution-job-ids' export interface EnqueueWorkflowExecutionParams { requestId: string diff --git a/apps/sim/lib/workflows/executor/execution-job-ids.ts b/apps/sim/lib/workflows/executor/execution-job-ids.ts new file mode 100644 index 00000000000..4198f70ca10 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-job-ids.ts @@ -0,0 +1,2 @@ +export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' +export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:' diff --git a/apps/sim/lib/workflows/executor/execution-queries.test.ts b/apps/sim/lib/workflows/executor/execution-queries.test.ts new file mode 100644 index 00000000000..ec3c77a5f77 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-queries.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing/mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetJob, mockGetJobQueue } = vi.hoisted(() => ({ + mockGetJob: vi.fn(), + mockGetJobQueue: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: mockGetJobQueue, +})) + +import { workflowExecutionBelongsToWorkflow } from '@/lib/workflows/executor/execution-queries' + +describe('workflowExecutionBelongsToWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetJobQueue.mockResolvedValue({ getJob: mockGetJob }) + }) + + it('accepts a durable execution bound to the requested workflow', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [{ workflowId: 'workflow-1' }]) + + await expect(workflowExecutionBelongsToWorkflow('execution-1', 'workflow-1')).resolves.toBe( + true + ) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + + it('rejects a durable execution bound to another workflow', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [{ workflowId: 'workflow-2' }]) + + await expect(workflowExecutionBelongsToWorkflow('execution-1', 'workflow-1')).resolves.toBe( + false + ) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + + it('checks deterministic queue metadata before the durable log exists', async () => { + mockGetJob.mockResolvedValue({ metadata: { workflowId: 'workflow-1' } }) + + await expect(workflowExecutionBelongsToWorkflow('execution-1', 'workflow-1')).resolves.toBe( + true + ) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-queries.ts b/apps/sim/lib/workflows/executor/execution-queries.ts new file mode 100644 index 00000000000..89053c73153 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-queries.ts @@ -0,0 +1,130 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' +import { and, asc, desc, eq, gt, gte, lt, lte, or, sql } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' + +export type WorkflowExecutionStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'paused' + +export interface WorkflowExecutionCursor { + startedAt: Date + rowId: string +} + +export interface ListWorkflowExecutionsInput { + workflowId: string + status?: WorkflowExecutionStatus + trigger?: string + startDate?: Date + endDate?: Date + limit: number + cursor?: WorkflowExecutionCursor + order: 'asc' | 'desc' +} + +const executionStatus = sql`CASE + WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed') THEN 'paused' + ELSE ${workflowExecutionLogs.status} +END` + +/** Lists the durable execution projection for a workflow. */ +export async function listWorkflowExecutions(input: ListWorkflowExecutionsInput) { + const cursorCondition = input.cursor + ? input.order === 'desc' + ? or( + lt(workflowExecutionLogs.startedAt, input.cursor.startedAt), + and( + eq(workflowExecutionLogs.startedAt, input.cursor.startedAt), + lt(workflowExecutionLogs.id, input.cursor.rowId) + ) + ) + : or( + gt(workflowExecutionLogs.startedAt, input.cursor.startedAt), + and( + eq(workflowExecutionLogs.startedAt, input.cursor.startedAt), + gt(workflowExecutionLogs.id, input.cursor.rowId) + ) + ) + : undefined + + const rows = await db + .select({ + rowId: workflowExecutionLogs.id, + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + status: executionStatus, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + durationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + }) + .from(workflowExecutionLogs) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .where( + and( + eq(workflowExecutionLogs.workflowId, input.workflowId), + input.status ? eq(executionStatus, input.status) : undefined, + input.trigger ? eq(workflowExecutionLogs.trigger, input.trigger) : undefined, + input.startDate ? gte(workflowExecutionLogs.startedAt, input.startDate) : undefined, + input.endDate ? lte(workflowExecutionLogs.startedAt, input.endDate) : undefined, + cursorCondition + ) + ) + .orderBy( + input.order === 'desc' + ? desc(workflowExecutionLogs.startedAt) + : asc(workflowExecutionLogs.startedAt), + input.order === 'desc' ? desc(workflowExecutionLogs.id) : asc(workflowExecutionLogs.id) + ) + .limit(input.limit + 1) + + const hasMore = rows.length > input.limit + const data = rows.slice(0, input.limit) + const last = data.at(-1) + return { + data, + nextCursor: hasMore && last ? { startedAt: last.startedAt, rowId: last.rowId } : null, + } +} + +/** + * Checks the durable and queued execution records without trusting the workflow + * id supplied by an HTTP path. Mutating callers must use this before operating + * on an execution id because execution ids are globally unique, not nested DB + * keys under a workflow. + */ +export async function workflowExecutionBelongsToWorkflow( + executionId: string, + workflowId: string +): Promise { + const [logRows, pausedRows] = await Promise.all([ + db + .select({ workflowId: workflowExecutionLogs.workflowId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1), + db + .select({ workflowId: pausedExecutions.workflowId }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1), + ]) + + const durableWorkflowIds = [logRows[0]?.workflowId, pausedRows[0]?.workflowId].filter( + (value): value is string => typeof value === 'string' + ) + if (durableWorkflowIds.length > 0) { + return durableWorkflowIds.every((value) => value === workflowId) + } + + const queue = await getJobQueue() + const job = await queue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) + return job?.metadata.workflowId === workflowId +} diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index dfbc0719224..ff28142f9e5 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -12,9 +12,16 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), })) -vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ - RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:', - WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +vi.mock('@/lib/logs/execution/functional-outputs', () => ({ + collectFunctionalBlockOutputs: vi.fn().mockReturnValue(new Map()), +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: vi.fn(), +})) + +vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ + getAutomaticResumeWaitingMetadata: vi.fn().mockReturnValue(null), })) import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' @@ -56,6 +63,25 @@ describe('getWorkflowExecutionStatus queue projection', () => { expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') }) + it('preserves queue cancellation as a cancelled execution resource', async () => { + mockGetJob.mockResolvedValue({ + status: 'cancelled', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + completedAt: new Date('2026-08-05T12:00:01.000Z'), + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'cancelled', + level: 'info', + endedAt: '2026-08-05T12:00:01.000Z', + error: null, + }) + }) + it('uses the resume entry ID when the queued work is a resume attempt', async () => { queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }]) mockGetJob.mockResolvedValueOnce({ diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index f90ef18e1c6..62d0b066045 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -12,7 +12,7 @@ import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { RESUME_EXECUTION_JOB_ID_PREFIX, WORKFLOW_EXECUTION_JOB_ID_PREFIX, -} from '@/lib/workflows/executor/enqueue-execution' +} from '@/lib/workflows/executor/execution-job-ids' import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' import type { PausePoint } from '@/executor/types' diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 4659f0938f9..2d543003747 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -1,11 +1,153 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' -import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import type { WorkflowListItem } from '@/lib/api/contracts/workflows' +import { + type CursorKey, + encodeKeyset, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowListScope = 'active' | 'archived' | 'all' +export type WorkflowSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' +export type WorkflowSortOrder = 'asc' | 'desc' + +export interface WorkspaceWorkflowListRow { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string | null + isDeployed: boolean + deployedAt: Date | null + runCount: number + lastRunAt: Date | null + sortOrder: number + createdAt: Date + updatedAt: Date +} + +const workspaceWorkflowId = textKey(workflow.id, (row) => row.id) +const workspaceWorkflowCreatedAt = timestampKey( + workflow.createdAt, + (row) => row.createdAt +) + +const WORKFLOW_SORTS = { + position: [ + numberKey(workflow.sortOrder, (row) => row.sortOrder), + workspaceWorkflowCreatedAt, + workspaceWorkflowId, + ], + name: [textKey(workflow.name, (row) => row.name), workspaceWorkflowId], + createdAt: [workspaceWorkflowCreatedAt, workspaceWorkflowId], + updatedAt: [ + timestampKey(workflow.updatedAt, (row) => row.updatedAt), + workspaceWorkflowId, + ], + runCount: [ + numberKey(workflow.runCount, (row) => row.runCount), + workspaceWorkflowId, + ], +} satisfies Record[]> + +export class InvalidWorkflowListCursorError extends Error { + constructor() { + super('Cursor does not match the requested workflow sort') + this.name = 'InvalidWorkflowListCursorError' + } +} + +export interface ListWorkspaceWorkflowsInput { + workspaceId: string + folderId?: string | null + deployedOnly: boolean + search?: string + sortBy: WorkflowSortBy + sortOrder: WorkflowSortOrder + cursorKeys?: CursorKey[] + limit: number +} + +/** Cursor-paged active workflow query used by the public workflow adapter. */ +export async function listWorkspaceWorkflows(input: ListWorkspaceWorkflowsInput) { + const keys = WORKFLOW_SORTS[input.sortBy] + const resumeAfter = input.cursorKeys + ? keysetAfter(keys, input.cursorKeys, input.sortOrder) + : undefined + if (resumeAfter === null) throw new InvalidWorkflowListCursorError() + + const folderCondition: SQL | undefined = + input.folderId === undefined + ? undefined + : input.folderId === null + ? isNull(workflow.folderId) + : eq(workflow.folderId, input.folderId) + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where( + and( + eq(workflow.workspaceId, input.workspaceId), + isNull(workflow.archivedAt), + folderCondition, + input.deployedOnly ? eq(workflow.isDeployed, true) : undefined, + searchFilter(workflow.name, input.search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), input.sortOrder)) + .limit(input.limit + 1) + + const hasMore = rows.length > input.limit + const data = rows.slice(0, input.limit) + const last = data.at(-1) + return { + data, + nextCursorKeys: hasMore && last ? encodeKeyset(keys, last) : null, + } +} + +/** + * Loads one consistent workflow record + normalized definition snapshot. Both + * the editor route and public metadata route derive their own response from + * this read rather than issuing independent block/workflow queries. + */ +export async function loadWorkflowReadSnapshot(workflowId: string) { + return db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [normalizedData, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), + ]) + return { normalizedData, workflowRecord: workflowRecord ?? null } + }) +} + /** * Project only the columns declared in `workflowListItemSchema` so the result * matches the contract wire shape exactly. The full row is larger (`state`,