Skip to content

Commit 3bc192f

Browse files
committed
feat(logs): allow omitting workflow snapshots from diagnostic reads
1 parent 113a204 commit 3bc192f

11 files changed

Lines changed: 149 additions & 17 deletions

File tree

‎apps/docs/content/docs/cli/logs.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ sim logs get <runId> [options]
3131

3232
| Option | Required | Description |
3333
| --- | --- | --- |
34+
| `--include-workflow-state` | No | Include the saved workflow snapshot. Set false to omit block configuration from a log read. Other run fields are unchanged. |
35+
| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
3436
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
3537

3638
</CommandTable>

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2731,6 +2731,8 @@ sim logs get <runId> [options]
27312731

27322732
| Option | Required | Description |
27332733
| --- | --- | --- |
2734+
| `--include-workflow-state` | No | Include the saved workflow snapshot. Set false to omit block configuration from a log read. Other run fields are unchanged. |
2735+
| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
27342736
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
27352737

27362738
</CommandTable>

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,16 @@
383383
"pattern": "^[A-Za-z0-9._:-]+$",
384384
"description": "Unique workflow run identifier."
385385
}
386+
},
387+
{
388+
"name": "includeWorkflowState",
389+
"in": "query",
390+
"required": false,
391+
"description": "Include the saved workflow snapshot. Set false to omit block configuration from a log read. Other run fields are unchanged.",
392+
"schema": {
393+
"description": "Include the saved workflow snapshot. Set false to omit block configuration from a log read. Other run fields are unchanged.",
394+
"type": "boolean"
395+
}
386396
}
387397
],
388398
"responses": {
@@ -1602,7 +1612,7 @@
16021612
"type": "null"
16031613
}
16041614
],
1605-
"description": "Workflow graph captured for the run, or null if unavailable. Sensitive values are redacted to null; environment-variable references may be preserved."
1615+
"description": "Workflow graph captured for the run, or null if unavailable or includeWorkflowState=false. Sensitive values are redacted to null; environment-variable references may be preserved."
16061616
},
16071617
"traceSpans": {
16081618
"type": "array",

‎apps/sim/app/api/v2/logs/[runId]/route.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('GET /api/v2/logs/[runId]', () => {
9999
expect(body.data).not.toHaveProperty('executionData')
100100
expect(mocks.execute).toHaveBeenCalledWith({
101101
principal: auth.principal,
102-
input: { runId: 'run-1' },
102+
input: { runId: 'run-1', includeWorkflowState: true },
103103
request,
104104
})
105105
})

‎apps/sim/app/api/v2/logs/[runId]/route.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export const GET = defineV2JsonRoute({
3636
operation: logOperations.readDetail,
3737
rateLimit: v2RateLimits.publicApi,
3838
errorPolicy: v2LogErrorPolicies.concealDetailAuthorization,
39-
mapInput: ({ params }) => ({ runId: params.runId }),
39+
mapInput: ({ params, query }) => ({
40+
runId: params.runId,
41+
includeWorkflowState: query.includeWorkflowState,
42+
}),
4043
useCase: getPublicLog,
4144
present: ({ log, workflowFolderPath, executionData, costLedger }) => {
4245
const detail: V2LogDetail = {

‎apps/sim/lib/api/contracts/v2/logs.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { z } from 'zod'
22
import { traceSpansSchema } from '@/lib/api/contracts/logs'
33
import {
44
booleanQueryFlagSchema,
5-
noInputSchema,
65
runIdSchema,
76
workspaceIdSchema,
87
} from '@/lib/api/contracts/primitives'
@@ -24,9 +23,8 @@ import { v2RunFileSchema } from '@/lib/api/contracts/v2/workflows'
2423
import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types'
2524

2625
/**
27-
* v2 logs contracts. The query schemas are reused verbatim from v1 (the request
28-
* shape is unchanged); only the response envelope is upgraded to the canonical
29-
* v2 shapes with concrete item schemas.
26+
* v2 logs contracts. List queries retain the v1 filters; responses use the
27+
* canonical v2 envelope and concrete item schemas.
3028
*/
3129

3230
const v2LogCostSchema = z
@@ -164,7 +162,7 @@ const v2LogWorkflowStateSchema = z
164162
)
165163
.nullable()
166164
.describe(
167-
'Workflow graph captured for the run, or null if unavailable. Sensitive values are redacted to null; environment-variable references may be preserved.'
165+
'Workflow graph captured for the run, or null if unavailable or includeWorkflowState=false. Sensitive values are redacted to null; environment-variable references may be preserved.'
168166
)
169167

170168
const v2LogWorkflowSummarySchema = z.object({
@@ -717,7 +715,21 @@ export const v2ListLogsContract = defineRouteContract({
717715
export const v2GetLogContract = defineRouteContract({
718716
method: 'GET',
719717
path: '/api/v2/logs/[runId]',
720-
query: noInputSchema,
718+
query: z
719+
.object({
720+
includeWorkflowState: booleanQueryFlagSchema
721+
.default(true)
722+
.describe(
723+
'Include the saved workflow snapshot. Set false to omit block configuration from a log read. Other run fields are unchanged.'
724+
),
725+
})
726+
.strict()
727+
.meta({
728+
id: 'GetLogQuery',
729+
title: 'Execution log detail options',
730+
description: 'Controls whether a log detail read includes its saved workflow snapshot.',
731+
examples: [{ includeWorkflowState: false }],
732+
}),
721733
params: v2LogParamsSchema,
722734
response: {
723735
mode: 'json',

‎apps/sim/lib/logs/application/get-public-log.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface PublicLogContext extends ActiveWorkspaceApplicationContext {
3030

3131
export interface GetPublicLogInput {
3232
runId: string
33+
includeWorkflowState?: boolean
3334
}
3435

3536
export interface GetPublicLogResult {
@@ -94,7 +95,7 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({
9495
return { ...workspace, executionId: scope.executionId, workflowId: scope.workflowId }
9596
},
9697
authorizationOptions: logDelegationAuthorization<PublicLogContext>(),
97-
execute: async ({ principal, context }): Promise<GetPublicLogResult> => {
98+
execute: async ({ principal, input, context }): Promise<GetPublicLogResult> => {
9899
/**
99100
* Attribution and the projection subject in one value; a workspace API key
100101
* represents no user and therefore reads the run whole. See
@@ -118,7 +119,8 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({
118119

119120
const log = await getPublicWorkflowLog(
120121
{ column: 'executionId', value: context.executionId },
121-
context.workspaceId
122+
context.workspaceId,
123+
{ includeWorkflowState: input.includeWorkflowState }
122124
)
123125
if (!log || log.workflowId !== context.workflowId) {
124126
throw new OrchestrationError('not_found', 'Log not found')

‎apps/sim/lib/logs/application/public-log-use-cases.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ describe('public log application use cases', () => {
139139
expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1')
140140
expect(mocks.getLog).toHaveBeenCalledWith(
141141
{ column: 'executionId', value: 'run-1' },
142-
'workspace-1'
142+
'workspace-1',
143+
{ includeWorkflowState: undefined }
143144
)
144145
expect(mocks.materialize).toHaveBeenCalledWith(
145146
{ pointer: true },

‎apps/sim/lib/logs/public-queries.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,11 @@ export async function getPublicWorkflowLogScope(executionId: string) {
454454
* is deliberately left-sided: a missing snapshot does not make an otherwise
455455
* valid execution disappear from the log resource.
456456
*/
457-
export async function getPublicWorkflowLog(lookup: PublicWorkflowLogLookup, workspaceId?: string) {
457+
export async function getPublicWorkflowLog(
458+
lookup: PublicWorkflowLogLookup,
459+
workspaceId?: string,
460+
options: { includeWorkflowState?: boolean } = {}
461+
) {
458462
const lookupCondition =
459463
lookup.column === 'id'
460464
? eq(workflowExecutionLogs.id, lookup.value)
@@ -478,7 +482,10 @@ export async function getPublicWorkflowLog(lookup: PublicWorkflowLogLookup, work
478482
costTotal: workflowExecutionLogs.costTotal,
479483
files: workflowExecutionLogs.files,
480484
createdAt: workflowExecutionLogs.createdAt,
481-
workflowState: workflowExecutionSnapshots.stateData,
485+
workflowState:
486+
options.includeWorkflowState === false
487+
? sql<null>`null`
488+
: workflowExecutionSnapshots.stateData,
482489
workflowName: workflow.name,
483490
workflowDescription: workflow.description,
484491
workflowFolderId: workflow.folderId,

‎apps/sim/lib/workspaces/__integration__/http-cli.integration.ts‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
permissions,
1010
user,
1111
workflow,
12+
workflowExecutionLogs,
13+
workflowExecutionSnapshots,
1214
workspace,
1315
workspaceOperationReceipt,
1416
} from '@sim/db/schema'
@@ -17,6 +19,7 @@ import { eq } from 'drizzle-orm'
1719
import { NextRequest } from 'next/server'
1820
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
1921
import { hashApiKey } from '@/lib/api-key/crypto'
22+
import { GET as logGet } from '@/app/api/v2/logs/[runId]/route'
2023
import { POST as importPreview } from '@/app/api/v2/workflows/import/preview/route'
2124
import { POST as importApply } from '@/app/api/v2/workflows/import/route'
2225
import { POST as forkPreview } from '@/app/api/v2/workspaces/[workspaceId]/fork/preview/route'
@@ -29,6 +32,10 @@ const userId = generateId()
2932
const workspaceId = generateId()
3033
const personalKey = `sk-sim-fixture-${generateId()}`
3134
const workspaceKey = `sk-sim-fixture-${generateId()}`
35+
const logRunId = generateId()
36+
const logSnapshotId = generateId()
37+
const foreignWorkspaceId = generateId()
38+
const logSnapshot = { blocks: {}, edges: [], variables: { fixture: 'configuration'.repeat(2000) } }
3239
const childWorkspaceIds: string[] = []
3340
let endpoint: string
3441
let directory: string
@@ -118,6 +125,29 @@ describe('v2 and CLI workflow protocol against PostgreSQL', () => {
118125
entityId: workspaceId,
119126
permissionType: 'admin',
120127
})
128+
await db.insert(workspace).values({
129+
id: foreignWorkspaceId,
130+
name: 'Inaccessible log fixture',
131+
ownerId: userId,
132+
billedAccountUserId: userId,
133+
})
134+
await db.insert(workflowExecutionSnapshots).values({
135+
id: logSnapshotId,
136+
stateHash: generateId(),
137+
stateData: logSnapshot,
138+
})
139+
await db.insert(workflowExecutionLogs).values({
140+
id: generateId(),
141+
workspaceId,
142+
executionId: logRunId,
143+
stateSnapshotId: logSnapshotId,
144+
level: 'info',
145+
status: 'completed',
146+
trigger: 'manual',
147+
startedAt: now,
148+
endedAt: now,
149+
executionData: { finalOutput: { delivered: false }, traceSpans: [] },
150+
})
121151
await db.insert(apiKey).values([
122152
{
123153
id: generateId(),
@@ -160,8 +190,10 @@ describe('v2 and CLI workflow protocol against PostgreSQL', () => {
160190
const path = new URL(request.url).pathname
161191
const match = path.match(/^\/api\/v2\/workspaces\/([^/]+)\/(.*)$/)
162192
const context = { params: Promise.resolve({ workspaceId: match?.[1] ?? workspaceId }) }
163-
const response =
164-
path === '/api/v2/workflows/import/preview'
193+
const logMatch = path.match(/^\/api\/v2\/logs\/([^/]+)$/)
194+
const response = logMatch
195+
? await logGet(request, { params: Promise.resolve({ runId: logMatch[1] }) })
196+
: path === '/api/v2/workflows/import/preview'
165197
? await importPreview(request, { params: Promise.resolve({}) })
166198
: path === '/api/v2/workflows/import'
167199
? await importApply(request, { params: Promise.resolve({}) })
@@ -207,11 +239,63 @@ describe('v2 and CLI workflow protocol against PostgreSQL', () => {
207239
})
208240
for (const id of childWorkspaceIds) await db.delete(workspace).where(eq(workspace.id, id))
209241
await db.delete(workspace).where(eq(workspace.id, workspaceId))
242+
await db.delete(workspace).where(eq(workspace.id, foreignWorkspaceId))
243+
await db
244+
.delete(workflowExecutionSnapshots)
245+
.where(eq(workflowExecutionSnapshots.id, logSnapshotId))
210246
await db.delete(user).where(eq(user.id, userId))
211247
await rm(directory, { recursive: true, force: true })
212248
await db.$client.end()
213249
})
214250

251+
it('omits only the requested log snapshot through the CLI and preserves the default read', async () => {
252+
const args = ['logs', 'get', logRunId]
253+
const before = await cli(args)
254+
expect(before.code, before.stderr).toBe(0)
255+
const original = JSON.parse(before.stdout)
256+
expect(original.workflowState.variables).toEqual(logSnapshot.variables)
257+
258+
const explicit = await cli([...args, '--include-workflow-state'])
259+
expect(explicit.code, explicit.stderr).toBe(0)
260+
expect(JSON.parse(explicit.stdout)).toEqual(original)
261+
const compact = await cli([...args, '--no-include-workflow-state'])
262+
expect(compact.code, compact.stderr).toBe(0)
263+
expect(JSON.parse(compact.stdout)).toEqual({ ...original, workflowState: null })
264+
expect(Buffer.byteLength(compact.stdout)).toBeLessThan(Buffer.byteLength(before.stdout) / 2)
265+
const after = await cli(args)
266+
expect(after.code, after.stderr).toBe(0)
267+
expect(JSON.parse(after.stdout)).toEqual(original)
268+
})
269+
270+
it('validates log query flags and retains authorization for compact reads over HTTP', async () => {
271+
const path = `${endpoint}/api/v2/logs/${logRunId}`
272+
const headers = { 'X-API-Key': workspaceKey }
273+
const invalid = await fetch(`${path}?includeWorkflowState=invalid`, { headers })
274+
expect(invalid.status).toBe(400)
275+
expect(await invalid.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } })
276+
const unknown = await fetch(`${path}?includeWorkflowState=false&unknown=true`, { headers })
277+
expect(unknown.status).toBe(400)
278+
const unauthenticated = await fetch(`${path}?includeWorkflowState=false`)
279+
expect(unauthenticated.status).toBe(401)
280+
281+
await db
282+
.update(workflowExecutionLogs)
283+
.set({ workspaceId: foreignWorkspaceId })
284+
.where(eq(workflowExecutionLogs.executionId, logRunId))
285+
try {
286+
for (const query of ['', '?includeWorkflowState=false']) {
287+
const concealed = await fetch(`${path}${query}`, { headers })
288+
expect(concealed.status).toBe(404)
289+
expect(await concealed.json()).toMatchObject({ error: { code: 'NOT_FOUND' } })
290+
}
291+
} finally {
292+
await db
293+
.update(workflowExecutionLogs)
294+
.set({ workspaceId })
295+
.where(eq(workflowExecutionLogs.executionId, logRunId))
296+
}
297+
})
298+
215299
it('previews stdin JSON, applies @file input, waits, and returns the same receipt on retry', async () => {
216300
const preview = await cli(
217301
['workflows', 'import-preview', '--workflow', '@-', '--mappings', '@mappings.json'],

0 commit comments

Comments
 (0)