Skip to content

Commit 4cc80af

Browse files
feat(dashboards): report dashboard parse errors on file writes
The v2 file create, replace and edit responses now carry `diagnostics` for a dashboard file: its parse errors, or an empty list. Writes are never blocked, like the page lint; table columns and queries are still checked when panels render. Parse errors are reported as `path: message` lines, and a block with no recognized kind names the allowed kinds and unknown keys instead of Zod's union dump. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent 54dfd1b commit 4cc80af

13 files changed

Lines changed: 298 additions & 20 deletions

File tree

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@
248248
"content": {
249249
"application/json": {
250250
"schema": {
251-
"$ref": "#/components/schemas/V2FileResponse"
251+
"$ref": "#/components/schemas/V2CreatedFileResponse"
252252
}
253253
}
254254
}
@@ -4044,18 +4044,115 @@
40444044
}
40454045
]
40464046
},
4047-
"V2FileResponse": {
4047+
"V2CreatedFile": {
4048+
"type": "object",
4049+
"properties": {
4050+
"id": {
4051+
"type": "string",
4052+
"description": "Unique file identifier.",
4053+
"examples": ["wf_V1StGXR8z5jdHi6BmyT91"]
4054+
},
4055+
"webUrl": {
4056+
"type": "string",
4057+
"format": "uri",
4058+
"description": "Canonical absolute URL for opening this resource in the Sim web application."
4059+
},
4060+
"name": {
4061+
"type": "string",
4062+
"description": "Original file name.",
4063+
"examples": ["data.csv"]
4064+
},
4065+
"size": {
4066+
"type": "number",
4067+
"minimum": 0,
4068+
"description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.",
4069+
"examples": [1024]
4070+
},
4071+
"type": {
4072+
"type": "string",
4073+
"description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.",
4074+
"examples": ["text/csv"]
4075+
},
4076+
"key": {
4077+
"type": "string",
4078+
"description": "Storage key for the file.",
4079+
"examples": ["workspace/example/data.csv"]
4080+
},
4081+
"folderPath": {
4082+
"type": "string",
4083+
"title": "Folder path",
4084+
"description": "Canonical containing-folder path. `/` is the workspace root.",
4085+
"maxLength": 4096
4086+
},
4087+
"uploadedByEmail": {
4088+
"type": "string",
4089+
"format": "email",
4090+
"pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
4091+
"description": "Current email address of the uploader.",
4092+
"examples": ["jane@example.com"]
4093+
},
4094+
"uploadedAt": {
4095+
"type": "string",
4096+
"description": "ISO 8601 timestamp when the file was uploaded.",
4097+
"format": "date-time",
4098+
"examples": ["2026-01-15T10:30:00Z"]
4099+
},
4100+
"updatedAt": {
4101+
"type": "string",
4102+
"description": "ISO 8601 timestamp of the last content or metadata write.",
4103+
"format": "date-time",
4104+
"examples": ["2026-01-15T10:30:00Z"]
4105+
},
4106+
"deletedAt": {
4107+
"anyOf": [
4108+
{
4109+
"type": "string"
4110+
},
4111+
{
4112+
"type": "null"
4113+
}
4114+
],
4115+
"description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.",
4116+
"format": "date-time",
4117+
"examples": ["2026-01-16T09:00:00Z"]
4118+
},
4119+
"diagnostics": {
4120+
"description": "For a dashboard file, the YAML parse errors in the written content. Empty when it parses; the file is saved either way. Absent for other file types.",
4121+
"type": "array",
4122+
"items": {
4123+
"type": "string"
4124+
}
4125+
}
4126+
},
4127+
"required": [
4128+
"id",
4129+
"webUrl",
4130+
"name",
4131+
"size",
4132+
"type",
4133+
"key",
4134+
"folderPath",
4135+
"uploadedByEmail",
4136+
"uploadedAt",
4137+
"updatedAt",
4138+
"deletedAt"
4139+
],
4140+
"additionalProperties": false,
4141+
"title": "Created file",
4142+
"description": "A newly created workspace file, with any dashboard parse errors."
4143+
},
4144+
"V2CreatedFileResponse": {
40484145
"type": "object",
40494146
"properties": {
40504147
"data": {
40514148
"description": "Response data.",
4052-
"$ref": "#/components/schemas/V2File"
4149+
"$ref": "#/components/schemas/V2CreatedFile"
40534150
}
40544151
},
40554152
"required": ["data"],
40564153
"additionalProperties": false,
4057-
"title": "File response",
4058-
"description": "A single workspace file.",
4154+
"title": "Created file response",
4155+
"description": "A newly created workspace file, with any dashboard parse errors.",
40594156
"examples": [
40604157
{
40614158
"data": {
@@ -5115,6 +5212,36 @@
51155212
"title": "Delete file response",
51165213
"description": "Deletion confirmation for one file."
51175214
},
5215+
"V2FileResponse": {
5216+
"type": "object",
5217+
"properties": {
5218+
"data": {
5219+
"description": "Response data.",
5220+
"$ref": "#/components/schemas/V2File"
5221+
}
5222+
},
5223+
"required": ["data"],
5224+
"additionalProperties": false,
5225+
"title": "File response",
5226+
"description": "A single workspace file.",
5227+
"examples": [
5228+
{
5229+
"data": {
5230+
"id": "wf_V1StGXR8z5jdHi6BmyT91",
5231+
"webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91",
5232+
"name": "data.csv",
5233+
"size": 1024,
5234+
"type": "text/csv",
5235+
"key": "workspace/example/data.csv",
5236+
"folderPath": "/Engineering",
5237+
"uploadedByEmail": "jane@example.com",
5238+
"uploadedAt": "2026-01-15T10:30:00Z",
5239+
"updatedAt": "2026-01-15T10:30:00Z",
5240+
"deletedAt": null
5241+
}
5242+
}
5243+
]
5244+
},
51185245
"RenameFileRequest": {
51195246
"type": "object",
51205247
"properties": {
@@ -5832,6 +5959,13 @@
58325959
"revision": {
58335960
"description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.",
58345961
"type": "string"
5962+
},
5963+
"diagnostics": {
5964+
"description": "For a dashboard file, the YAML parse errors in the written content. Empty when it parses; the file is saved either way. Absent for other file types.",
5965+
"type": "array",
5966+
"items": {
5967+
"type": "string"
5968+
}
58355969
}
58365970
},
58375971
"required": ["file", "lineCount"],
@@ -6234,6 +6368,13 @@
62346368
"revision": {
62356369
"description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.",
62366370
"type": "string"
6371+
},
6372+
"diagnostics": {
6373+
"description": "For a dashboard file, the YAML parse errors in the written content. Empty when it parses; the file is saved either way. Absent for other file types.",
6374+
"type": "array",
6375+
"items": {
6376+
"type": "string"
6377+
}
62376378
}
62386379
},
62396380
"required": [

‎apps/sim/app/api/v2/files/[fileId]/content/route.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ export const PUT = defineV2JsonRoute({
4040
expectedRevision: body.expectedRevision,
4141
}),
4242
useCase: updateWorkspaceFileContent,
43-
present: async ({ file }) => ({
44-
data: { ...(await toV2File(file)), ...workspaceFileRevisionField(file) },
43+
present: async ({ file, diagnostics }) => ({
44+
data: {
45+
...(await toV2File(file)),
46+
...workspaceFileRevisionField(file),
47+
...(diagnostics ? { diagnostics } : {}),
48+
},
4549
}),
4650
})
4751

@@ -77,7 +81,12 @@ export const PATCH = defineV2JsonRoute({
7781
expectedRevision: body.expectedRevision,
7882
}),
7983
useCase: editWorkspaceFileContent,
80-
present: async ({ file, lineCount }) => ({
81-
data: { file: await toV2File(file), lineCount, ...workspaceFileRevisionField(file) },
84+
present: async ({ file, lineCount, diagnostics }) => ({
85+
data: {
86+
file: await toV2File(file),
87+
lineCount,
88+
...workspaceFileRevisionField(file),
89+
...(diagnostics ? { diagnostics } : {}),
90+
},
8291
}),
8392
})

‎apps/sim/app/api/v2/files/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,7 @@ export const POST = defineV2JsonRoute({
9393
exactName: true,
9494
}),
9595
useCase: createWorkspaceFile,
96-
present: async ({ file }) => ({ data: await toV2File(file) }),
96+
present: async ({ file, diagnostics }) => ({
97+
data: { ...(await toV2File(file)), ...(diagnostics ? { diagnostics } : {}) },
98+
}),
9799
})

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -758,14 +758,30 @@ export const v2ListFilesContract = defineRouteContract({
758758
},
759759
})
760760

761+
/** Dashboard parse errors for the written content; only present for dashboard files. */
762+
const fileDiagnosticsSchema = z
763+
.array(z.string())
764+
.optional()
765+
.describe(
766+
'For a dashboard file, the YAML parse errors in the written content. Empty when it parses; the file is saved either way. Absent for other file types.'
767+
)
768+
769+
export const v2CreatedFileSchema = v2FileSchema
770+
.extend({ diagnostics: fileDiagnosticsSchema })
771+
.meta({
772+
id: 'V2CreatedFile',
773+
title: 'Created file',
774+
description: 'A newly created workspace file, with any dashboard parse errors.',
775+
})
776+
761777
export const v2CreateFileContract = defineRouteContract({
762778
method: 'POST',
763779
path: '/api/v2/files',
764780
query: noInputSchema,
765781
body: v2CreateFileBodySchema,
766782
response: {
767783
mode: 'json',
768-
schema: v2DataResponse(v2FileSchema),
784+
schema: v2DataResponse(v2CreatedFileSchema),
769785
status: 201,
770786
},
771787
})
@@ -1265,6 +1281,7 @@ export const v2EditedFileSchema = z
12651281
file: v2FileSchema.describe('The file after the edit.'),
12661282
lineCount: z.number().int().nonnegative().describe('Lines the file holds after the edit.'),
12671283
revision: writtenFileRevisionSchema,
1284+
diagnostics: fileDiagnosticsSchema,
12681285
})
12691286
.strict()
12701287
.meta({
@@ -1496,7 +1513,7 @@ export const v2EditFileContentContract = defineRouteContract({
14961513
})
14971514

14981515
export const v2WrittenFileSchema = v2FileSchema
1499-
.extend({ revision: writtenFileRevisionSchema })
1516+
.extend({ revision: writtenFileRevisionSchema, diagnostics: fileDiagnosticsSchema })
15001517
.meta({
15011518
id: 'V2WrittenFile',
15021519
title: 'Written file',

‎apps/sim/lib/api/contracts/v2/openapi/files-audit.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,9 @@ const declaredRoutes = [
208208
),
209209
response: documentedSchema(
210210
v2CreateFileContract.response.schema,
211-
'V2FileResponse',
212-
'File response',
213-
'A single workspace file.',
211+
'V2CreatedFileResponse',
212+
'Created file response',
213+
'A newly created workspace file, with any dashboard parse errors.',
214214
[{ data: FILE_EXAMPLE }]
215215
),
216216
}

‎apps/sim/lib/dashboards/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A dashboard is a workspace file with MIME type `text/x-sim-dashboard`, handled l
44

55
The implementation has three boundaries:
66

7-
- `spec.ts` validates a bounded YAML document and normalizes ECharts options through the existing `.chart` safety rules. It rejects unknown layout/source keys and reports errors in the viewer. Nothing validates the file on write.
7+
- `spec.ts` validates a bounded YAML document and normalizes ECharts options through the existing `.chart` safety rules. It rejects unknown layout/source keys and reports errors in the viewer. Writes are never blocked: the v2 file create, replace and edit responses carry `diagnostics` for a dashboard file (parse errors as `path: message` lines, or an empty list), like the page lint. Table columns and queries are only checked when panels render.
88
- `table/analytics` computes exact aggregates over authorized table rows. The internal POST `/api/table/[tableId]/analytics` is a session-authenticated adapter for `tables.rows.analytics`, requiring the current viewer's workspace read role and `tables.use`. The operation is session-only because this release's sole query caller is the workspace renderer. Public/versioned query APIs, workflow/executor callers and log queries are deferred.
99
- `components/dashboards` owns EMCN layout, controls and states. `components/charts/echarts-view.tsx` also renders existing `.chart` files, using the local EMCN tokens for its canvas theme. `.chart` retains its existing sampled source behavior; dashboard aggregation is performed on the server.
1010

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file'
2+
import { parseDashboardSpec } from '@/lib/dashboards/spec'
3+
4+
/**
5+
* Parse errors for a dashboard file's content, reported on write without blocking it, like
6+
* the page lint. Undefined for other file types; an empty list means the YAML parsed. Table
7+
* columns and queries are only checked when the dashboard renders.
8+
*/
9+
export function dashboardDiagnostics(
10+
contentType: string,
11+
content: Buffer | string
12+
): string[] | undefined {
13+
if (contentType !== DASHBOARD_CONTENT_TYPE) return undefined
14+
const { error } = parseDashboardSpec(content.toString())
15+
return error ? [error] : []
16+
}

‎apps/sim/lib/dashboards/spec.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,25 @@ describe('UTC ranges', () => {
110110
expect(() => parseDashboardCustomRange(from, to)).toThrow()
111111
)
112112
})
113+
114+
describe('dashboard parse errors', () => {
115+
it('names the block path, the allowed block kinds and any unknown key', () => {
116+
expect(parseDashboardSpec('title: Probe\nblocks:\n - bogus: 1\n').error).toBe(
117+
'blocks.0: expected a block with one of text, stat, chart, table, row, tabs; unknown key "bogus"'
118+
)
119+
})
120+
121+
it('does not describe a measure union as a block', () => {
122+
const error = parseDashboardSpec(
123+
'title: Probe\nsource: {tableId: tbl_1}\nblocks:\n - stat: Total\n source: {aggregate: {n: {op: nope}}}\n'
124+
).error
125+
expect(error).toMatch(/^source\.aggregate\.n\.op: |^blocks\.0\.source\.aggregate\.n/)
126+
expect(error).not.toContain('expected a block')
127+
})
128+
129+
it('reports field errors at their path', () => {
130+
expect(parseDashboardSpec('title: Probe\nblocks: []\n').error).toBe(
131+
'blocks: Too small: expected array to have >=1 items'
132+
)
133+
})
134+
})

0 commit comments

Comments
 (0)