@deepblend/dsh-blender-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +1381 -0
  2. package/lib/index.js +599 -0
  3. package/package.json +33 -0
package/lib/index.js ADDED
@@ -0,0 +1,599 @@
1
+ /**
2
+ * @deepblend/dsh-blender-ui
3
+ *
4
+ * The DeepBlend **Host half** of the workbench UI (SPEC §5.1, §14).
5
+ *
6
+ * It owns exactly one thing: the typed HTTP surface the browser half reads and
7
+ * writes through. Every handler answers by calling the authoritative
8
+ * `blenderStudio` Host service; nothing here reads a project file, starts a
9
+ * process, or keeps a cache. That is what makes SPEC §14.3 and the M4 acceptance
10
+ * conditions true by construction rather than by review:
11
+ *
12
+ * 不进入文件系统即可管理项目 the browser only ever calls these routes
13
+ * UI 刷新后可从 Host 恢复权威状态 every response is computed per request
14
+ * 浏览器不直接启动 Blender no route exposes an execution entry
15
+ * 所有写操作经过 Host the four write routes call blenderStudio
16
+ *
17
+ * The route table is `UI_ROUTES` in `@deepblend/dsh-blender-contracts`: a path is
18
+ * spelled once, and the handler table below is asserted to be exactly that set —
19
+ * a closed set, because a handler with no route in the table would be an
20
+ * unlisted capability.
21
+ *
22
+ * Owner: DeepBlend Studio — M0 (settings card) / M4 (the workbench API)
23
+ * Plane: Host composition (the Web Client half is `./client.js`)
24
+ */
25
+
26
+ import { Service } from '@deepseek-ai/cordis'
27
+ import z from '@deepseek-ai/schemastery'
28
+
29
+ import {
30
+ BlenderError,
31
+ BlenderErrorCode,
32
+ HOST_API_VERSION,
33
+ UI_PANEL_ID,
34
+ UI_ROUTES,
35
+ UI_ROUTE_PREFIX,
36
+ buildJobView,
37
+ buildProjectView,
38
+ buildQaView,
39
+ buildRevisionDiff,
40
+ buildSceneTree,
41
+ buildSettingsCard,
42
+ matchUiRoute,
43
+ assertKnownConfigKeys,
44
+ } from '@deepblend/dsh-blender-contracts'
45
+
46
+ /** Route path serving the settings card document (M0's surface, kept verbatim). */
47
+ export const CAPABILITIES_ROUTE = '/deepblend/capabilities'
48
+
49
+ /** Service key for the UI-facing host controller. */
50
+ export const BLENDER_UI_SERVICE = 'blenderUi'
51
+
52
+ /**
53
+ * Configuration schema.
54
+ *
55
+ * Named `UiConfig`, NOT `Config`: the class below declares `static Config`, so a
56
+ * module-scope `Config` would be shadowed inside the class body and
57
+ * `static Config = Config` would throw a temporal-dead-zone ReferenceError at
58
+ * module evaluation.
59
+ */
60
+ export const UiConfig = z.object({
61
+ /** Register the HTTP routes on the composed web server. */
62
+ serveRoute: z.boolean().default(true),
63
+ })
64
+
65
+ /** Body ceiling for a POSTed document (a ScenePatch is small; a whole spec is not huge). */
66
+ const MAX_BODY_BYTES = 4 * 1024 * 1024
67
+
68
+ export default class BlenderUiHost extends Service {
69
+ /**
70
+ * `blenderStudio` is a hard dependency: the API has no authority without it.
71
+ *
72
+ * `webServer` is deliberately NOT declared here. It is optional (a headless
73
+ * profile composes none), and it is not guaranteed to be up when this row
74
+ * activates — the UI row necessarily activates *after* `blenderStudio`, which
75
+ * activates after `blenderRuntime` waits on `subprocess`, so the order is not
76
+ * ours to fix. Reading it once in the constructor silently registered no route
77
+ * whenever the server had not started yet, which is why the endpoint answered
78
+ * 404 on a real boot while every service still resolved.
79
+ *
80
+ * `ctx.inject` below is the correct seam: it runs the callback now if the
81
+ * service is already present, and otherwise re-runs it when `webServer`
82
+ * arrives.
83
+ */
84
+ static inject = ['blenderStudio']
85
+
86
+ static Config = UiConfig
87
+
88
+ /**
89
+ * @param {import('@deepseek-ai/cordis').Context} ctx
90
+ * @param {import('z').infer<typeof UiConfig>} config
91
+ */
92
+ constructor(ctx, config) {
93
+ super(ctx, BLENDER_UI_SERVICE)
94
+ // Same rule as the other two rows (SPEC §17's nested shape is accepted by the schema and read by
95
+ // nobody): a key this row does not read stops the composition instead of doing nothing.
96
+ assertKnownConfigKeys(UiConfig, config, 'deepblend-blender-ui')
97
+ this.config = config
98
+ this._routeDisposer = null
99
+
100
+ // One handler per route id, and no other way in. A test asserts the key set
101
+ // equals UI_ROUTES exactly, in both directions.
102
+ this._handlers = createHandlers(this.ctx)
103
+
104
+ if (!config.serveRoute) return
105
+
106
+ ctx.inject(['webServer'], (serverCtx) => {
107
+ // Route registration belongs to this fiber, so unmounting the row removes
108
+ // the route (SPEC §4.4 / lifecycle: every side effect is reversible).
109
+ const dispose = serverCtx.webServer.register({
110
+ kind: 'prefix',
111
+ path: UI_ROUTE_PREFIX,
112
+ handler: (request, response) => this._handle(request, response),
113
+ })
114
+ this._routeDisposer = dispose
115
+ return () => {
116
+ this._routeDisposer = null
117
+ dispose()
118
+ }
119
+ })
120
+ }
121
+
122
+ /** The handler table, exposed for the closed-set assertion. */
123
+ get handlers() {
124
+ return this._handlers
125
+ }
126
+
127
+ /**
128
+ * Serve one UI request.
129
+ *
130
+ * Always answers with a structured JSON body: a failure the UI can render
131
+ * (`{ ok: false, error: { code, message } }`) is worth more than a transport
132
+ * error, and the browser half has no other source of truth to fall back on.
133
+ *
134
+ * @param {import('node:http').IncomingMessage} request
135
+ * @param {import('node:http').ServerResponse} response
136
+ * @returns {Promise<void>}
137
+ */
138
+ async _handle(request, response) {
139
+ const url = new URL(request.url ?? '/', 'http://localhost')
140
+ const matched = matchUiRoute(request.method ?? 'GET', url.pathname)
141
+
142
+ if (matched === null) {
143
+ this._sendJson(response, 404, {
144
+ ok: false,
145
+ error: {
146
+ code: BlenderErrorCode.UI_ROUTE_NOT_FOUND,
147
+ message: `${request.method} ${url.pathname} is not a DeepBlend route.`,
148
+ detail: { routes: UI_ROUTES.map(route => `${route.method} ${route.path}`) },
149
+ },
150
+ })
151
+ return
152
+ }
153
+
154
+ try {
155
+ if (matched.route.id === 'artifacts.open') {
156
+ await this._sendArtifact(request, response, matched.params)
157
+ return
158
+ }
159
+ const body = await readRequestBody(request)
160
+ const result = await this._handlers[matched.route.id]({
161
+ params: matched.params,
162
+ query: Object.fromEntries(url.searchParams.entries()),
163
+ body,
164
+ })
165
+ this._sendJson(response, 200, { ok: true, route: matched.route.id, hostApiVersion: HOST_API_VERSION, ...result })
166
+ } catch (cause) {
167
+ const error = cause instanceof BlenderError
168
+ ? cause
169
+ : new BlenderError(
170
+ BlenderErrorCode.UI_REQUEST_FAILED,
171
+ cause instanceof Error ? cause.message : String(cause),
172
+ )
173
+ this._sendJson(response, statusForError(error), {
174
+ ok: false,
175
+ route: matched.route.id,
176
+ hostApiVersion: HOST_API_VERSION,
177
+ error: error.toJSON(),
178
+ })
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Serve one artifact as bytes.
184
+ *
185
+ * The only route whose response is not JSON, and the only one where a URL
186
+ * parameter reaches the filesystem — so the project id and the path are both
187
+ * handed to `blenderStudio.readArtifact`, which resolves them inside the
188
+ * project directory and refuses anything else (SPEC §15.2).
189
+ *
190
+ * @param {import('node:http').IncomingMessage} request
191
+ * @param {import('node:http').ServerResponse} response
192
+ * @param {Record<string, string>} params
193
+ */
194
+ async _sendArtifact(request, response, params) {
195
+ const artifact = await this.ctx.blenderStudio.readArtifact({ projectId: params.projectId, path: params.rest })
196
+ response.setHeader('content-type', artifact.contentType)
197
+ // A re-rendered view must never be shown from a cache: the sheet for a
198
+ // revision that was re-reviewed is a different file with the same path.
199
+ response.setHeader('cache-control', 'no-store')
200
+ response.setHeader('content-length', String(artifact.size))
201
+ response.end(request.method === 'HEAD' ? undefined : artifact.bytes)
202
+ }
203
+
204
+ /**
205
+ * @param {import('node:http').ServerResponse} response
206
+ * @param {number} status
207
+ * @param {Record<string, unknown>} payload
208
+ */
209
+ _sendJson(response, status, payload) {
210
+ const body = JSON.stringify(payload)
211
+ response.setHeader('content-type', 'application/json; charset=utf-8')
212
+ // Nothing here may be cached: every value is a live read of Host state, and a
213
+ // cached copy would let the UI disagree with the tool after a write
214
+ // (SPEC §14.3 「刷新后可从 Host 恢复权威状态」).
215
+ response.setHeader('cache-control', 'no-store')
216
+ response.statusCode = status
217
+ response.end(body)
218
+ }
219
+
220
+ /**
221
+ * The settings-card view model.
222
+ *
223
+ * Kept server-side so the browser holds no derivation logic and the card can
224
+ * never drift from what the tool reports (SPEC §14.3).
225
+ *
226
+ * @param {Record<string, any>} data - canonical capabilities
227
+ * @returns {Record<string, unknown>}
228
+ */
229
+ static buildCard(data) {
230
+ return buildSettingsCard(data)
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Where a coded failure lands on the wire.
236
+ *
237
+ * A missing project or revision is a 404 — the UI has to tell "not there" from
238
+ * "the Host refused", and both are normal answers rather than crashes.
239
+ *
240
+ * @param {BlenderError} error
241
+ * @returns {number}
242
+ */
243
+ export function statusForError(error) {
244
+ switch (error.code) {
245
+ case BlenderErrorCode.PROJECT_NOT_FOUND:
246
+ case BlenderErrorCode.REVISION_NOT_FOUND:
247
+ case BlenderErrorCode.RENDER_JOB_NOT_FOUND:
248
+ case BlenderErrorCode.ARTIFACT_NOT_FOUND:
249
+ case BlenderErrorCode.UI_ROUTE_NOT_FOUND:
250
+ return 404
251
+ case BlenderErrorCode.PATH_OUTSIDE_WORKSPACE:
252
+ case BlenderErrorCode.SCENE_PATCH_INVALID:
253
+ return 400
254
+ case BlenderErrorCode.RENDER_JOB_CONFLICT:
255
+ return 409
256
+ default:
257
+ return 500
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Read a request body as JSON.
263
+ *
264
+ * @param {import('node:http').IncomingMessage} request
265
+ * @returns {Promise<Record<string, unknown>>}
266
+ */
267
+ async function readRequestBody(request) {
268
+ if (request.method === 'GET' || request.method === 'HEAD') return {}
269
+ const chunks = []
270
+ let size = 0
271
+ for await (const chunk of request) {
272
+ size += chunk.length
273
+ if (size > MAX_BODY_BYTES) {
274
+ throw new BlenderError(
275
+ BlenderErrorCode.SCENE_PATCH_INVALID,
276
+ `The request body exceeded ${MAX_BODY_BYTES} bytes.`,
277
+ )
278
+ }
279
+ chunks.push(chunk)
280
+ }
281
+ if (size === 0) return {}
282
+ const text = Buffer.concat(chunks).toString('utf8')
283
+ let parsed
284
+ try {
285
+ parsed = JSON.parse(text)
286
+ } catch (cause) {
287
+ throw new BlenderError(
288
+ BlenderErrorCode.SCENE_PATCH_INVALID,
289
+ `The request body is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
290
+ )
291
+ }
292
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
293
+ throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, 'The request body is not a JSON object.')
294
+ }
295
+ return parsed
296
+ }
297
+
298
+ /**
299
+ * The handler table: one function per route id.
300
+ *
301
+ * Every entry calls `blenderStudio` and projects its answer with a pure builder
302
+ * from the contracts package. There is no filesystem call, no process spawn and
303
+ * no `eval` anywhere in this file — which is what the M4 acceptance condition
304
+ * 「浏览器不直接启动 Blender」 means when it is written as a test rather than as
305
+ * a sentence.
306
+ *
307
+ * @param {import('@deepseek-ai/cordis').Context} ctx
308
+ * @returns {Record<string, (input: { params: Record<string, string>, query: Record<string, string>, body: Record<string, unknown> }) => Promise<Record<string, unknown>> | Record<string, unknown>>}
309
+ */
310
+ export function createHandlers(ctx) {
311
+ const studio = () => ctx.blenderStudio
312
+ const approvalThreshold = () => studio().config?.requireApprovalAboveFrames ?? Number.POSITIVE_INFINITY
313
+
314
+ return {
315
+ /** SPEC §14.4 `blenderSettings.getCapabilities`; M0's route, unchanged in shape. */
316
+ capabilities: async ({ query }) => {
317
+ const refresh = query.refresh === '1'
318
+ try {
319
+ const data = await studio().describeCapabilities({ refresh })
320
+ return { card: buildSettingsCard(data), data }
321
+ } catch (cause) {
322
+ // A probe failure is still structured, so the card renders the reason
323
+ // instead of the browser showing a transport error. M0 behaviour, kept:
324
+ // a Blender-less machine is a normal state, not an error page.
325
+ const error = cause instanceof BlenderError
326
+ ? cause
327
+ : new BlenderError(
328
+ BlenderErrorCode.CAPABILITY_PROBE_FAILED,
329
+ cause instanceof Error ? cause.message : String(cause),
330
+ )
331
+ return { card: null, data: null, error: error.toJSON() }
332
+ }
333
+ },
334
+
335
+ /** Everything the panel needs to render itself from scratch, in one request. */
336
+ state: async ({ query }) => {
337
+ const listed = await studio().listProjects()
338
+ const projectId = query.projectId ?? listed.projects[0]?.projectId ?? null
339
+ return {
340
+ panelId: UI_PANEL_ID,
341
+ projects: listed.projects.map(record => buildProjectView(record)),
342
+ projectsRoot: listed.projectsRoot,
343
+ selected: projectId === null
344
+ ? null
345
+ : await buildProjectState(studio(), projectId, query.revision ?? undefined, approvalThreshold()),
346
+ }
347
+ },
348
+
349
+ 'projects.list': async () => {
350
+ const listed = await studio().listProjects()
351
+ return { projects: listed.projects.map(record => buildProjectView(record)), projectsRoot: listed.projectsRoot }
352
+ },
353
+
354
+ 'projects.create': async ({ body }) => {
355
+ const created = await studio().createProject({
356
+ title: body.title,
357
+ goal: body.goal,
358
+ sceneSpec: body.sceneSpec,
359
+ projectId: body.projectId,
360
+ saveCheckpoint: true,
361
+ renderPreview: body.renderPreview === true,
362
+ })
363
+ return { project: created }
364
+ },
365
+
366
+ 'project.overview': async ({ params, query }) => buildProjectState(studio(), params.projectId, query.revision, approvalThreshold()),
367
+
368
+ 'project.scene': async ({ params, query }) => {
369
+ const detail = await studio().getScene(params.projectId, { revision: query.revision, full: true })
370
+ return {
371
+ revision: detail.revision,
372
+ digest: detail.digest,
373
+ scene: buildSceneTree(detail.spec, {
374
+ revision: detail.revision,
375
+ digest: detail.digest,
376
+ compiled: detail.compiledSpec ?? null,
377
+ }),
378
+ }
379
+ },
380
+
381
+ 'project.revisions': async ({ params }) => {
382
+ const project = await studio().getProject(params.projectId)
383
+ return { projectId: params.projectId, currentRevision: project.currentRevision ?? null, revisions: project.revisions }
384
+ },
385
+
386
+ 'project.revision': async ({ params }) => {
387
+ const detail = await studio().getRevisionDetail({ projectId: params.projectId, revision: params.revision })
388
+ return { detail }
389
+ },
390
+
391
+ 'project.diff': async ({ params, query }) => {
392
+ const pair = await studio().readRevisionPair({ projectId: params.projectId, from: query.from, to: query.to })
393
+ return {
394
+ fromRevision: pair.fromRevision,
395
+ toRevision: pair.toRevision,
396
+ diff: buildRevisionDiff(pair.from, pair.to, { fromRevision: pair.fromRevision, toRevision: pair.toRevision }),
397
+ }
398
+ },
399
+
400
+ 'project.qa': async ({ params, query }) => {
401
+ const record = await studio().getQaRecord({ projectId: params.projectId, revision: query.revision })
402
+ return {
403
+ qa: buildQaView({
404
+ projectId: record.projectId,
405
+ revision: record.revision,
406
+ validation: record.validation,
407
+ review: record.review,
408
+ }),
409
+ reviewCount: record.reviewCount,
410
+ reviewArtifact: record.reviewArtifact,
411
+ }
412
+ },
413
+
414
+ 'project.previews': async ({ params }) => {
415
+ const sets = await studio().listPreviewSets({ projectId: params.projectId })
416
+ return { previews: sets, artifactBase: `${UI_ROUTE_PREFIX}/artifacts/${encodeURIComponent(params.projectId)}/` }
417
+ },
418
+
419
+ 'project.preview': async ({ params, body }) => {
420
+ // `renderViews` answers with the rendered PNG bytes in `pngs`, which is not
421
+ // lossless JSON and is not what the browser needs: the panel displays the
422
+ // images through the artifact route, so the response carries the paths,
423
+ // the measurements and the warnings — never the buffers.
424
+ const result = await studio().renderViews({
425
+ projectId: params.projectId,
426
+ revision: body.revision,
427
+ samples: numberOrUndefined(body.samples),
428
+ maxViews: numberOrUndefined(body.maxViews),
429
+ reason: body.reason ?? 'preview rendered from the workbench UI',
430
+ })
431
+ return {
432
+ preview: {
433
+ projectId: params.projectId,
434
+ revision: result.revision ?? body.revision ?? null,
435
+ digest: result.digest ?? null,
436
+ profile: result.profile ?? null,
437
+ artifacts: result.artifacts ?? [],
438
+ // The pair the panel compares: the sheet this render composed and the one
439
+ // the previous render left behind (no `pngs` — the browser displays images
440
+ // through the artifact route, never as a JSON body).
441
+ sheets: result.previewSheets ?? null,
442
+ views: (result.views ?? []).map(view => ({
443
+ viewId: view.viewId,
444
+ role: view.role ?? null,
445
+ cameraId: view.cameraId ?? null,
446
+ frame: view.frame ?? null,
447
+ path: view.path ?? null,
448
+ caption: view.caption ?? null,
449
+ })),
450
+ warnings: result.warnings ?? [],
451
+ durationMs: result.durationMs ?? null,
452
+ },
453
+ }
454
+ },
455
+
456
+ 'project.patch': async ({ params, body }) => {
457
+ // The patch is passed through verbatim: the Host validates it against the
458
+ // ScenePatch schema and the base revision, and it is the only writer.
459
+ const patch = { ...(body.patch ?? body), projectId: params.projectId }
460
+ const result = await studio().applyScenePatch(patch)
461
+ return { revision: result }
462
+ },
463
+
464
+ 'project.restore': async ({ params, body }) => {
465
+ const result = await studio().restoreRevision({
466
+ projectId: params.projectId,
467
+ revision: body.revision,
468
+ reason: body.reason ?? 'restored from the workbench UI',
469
+ actor: body.actor ?? 'ui',
470
+ })
471
+ return { revision: result }
472
+ },
473
+
474
+ 'project.jobs': async ({ params }) => {
475
+ const jobs = await studio().listJobs({ projectId: params.projectId })
476
+ return {
477
+ projectId: params.projectId,
478
+ jobs: jobs.jobs.map(job => buildJobView(job, { threshold: approvalThreshold() })),
479
+ unfinished: jobs.unfinished,
480
+ recovery: jobs.recovery,
481
+ }
482
+ },
483
+
484
+ 'project.job': async ({ params }) => {
485
+ const job = await studio().getJob({ projectId: params.projectId, jobId: params.jobId })
486
+ return { job: buildJobView(job.renderJob ?? job, { threshold: approvalThreshold() }) }
487
+ },
488
+
489
+ 'project.job.cancel': async ({ params, body }) => {
490
+ const result = await studio().cancelJob({
491
+ projectId: params.projectId,
492
+ jobId: params.jobId,
493
+ reason: body.reason ?? 'cancelled from the workbench UI',
494
+ })
495
+ return { cancelled: result }
496
+ },
497
+
498
+ 'project.render': async ({ params, body }) => {
499
+ const resumeJobId = typeof body.resumeJobId === 'string' && body.resumeJobId.length > 0 ? body.resumeJobId : null
500
+ const request = {
501
+ projectId: params.projectId,
502
+ revision: body.revision,
503
+ frameStart: numberOrUndefined(body.frameStart),
504
+ frameEnd: numberOrUndefined(body.frameEnd),
505
+ samples: numberOrUndefined(body.samples),
506
+ profile: body.profile,
507
+ reason: body.reason ?? 'started from the workbench UI',
508
+ }
509
+ // A resume is a different host entry point, and the UI must not have to
510
+ // know which one to call: `resumeJobId` is the caller's whole intent.
511
+ //
512
+ // The parameter names differ on purpose on the two sides of the boundary —
513
+ // the tool/UI vocabulary says `resumeJobId` because that is what it MEANS,
514
+ // while `resumeRenderJob` takes `jobId` because that is what it USES. The
515
+ // mapping is therefore here, in one place, and asserted by the plane suite:
516
+ // passing `resumeJobId` straight through reads as a missing job id at the
517
+ // host, which a user would see as "RENDER_JOB_NOT_FOUND" on a job that is
518
+ // sitting right there in the panel.
519
+ const result = resumeJobId === null
520
+ ? await studio().startFinalRender(request)
521
+ : await studio().resumeRenderJob({ ...request, jobId: resumeJobId })
522
+ return { job: result, resumed: resumeJobId !== null }
523
+ },
524
+ }
525
+ }
526
+
527
+ /**
528
+ * One project's whole panel state: summary, scene, revisions, jobs and QA.
529
+ *
530
+ * Assembled from separate Host reads rather than from a Host-side aggregate, so
531
+ * each answer is the same one the corresponding tool would get.
532
+ *
533
+ * @param {any} studio
534
+ * @param {string} projectId
535
+ * @param {string|undefined} revision
536
+ * @param {number} threshold
537
+ * @returns {Promise<Record<string, unknown>>}
538
+ */
539
+ async function buildProjectState(studio, projectId, revision, threshold) {
540
+ const overview = await studio.getProject(projectId, revision === undefined ? {} : { revision })
541
+ const listed = await studio.listProjects()
542
+ const record = listed.projects.find(entry => entry.projectId === projectId) ?? null
543
+ const requested = revision ?? overview.currentRevision
544
+ // `full: true` is load-bearing. `getScene` returns a DIGEST by default (the
545
+ // model-facing rule: do not spend context on a document nobody asked for), and
546
+ // buildSceneTree fed with a digest renders an empty tree — a silent wrong
547
+ // answer, "this project has 0 entities", for a project with 17 of them. The
548
+ // workbench asks for the document because the Scene Tree IS the document.
549
+ const scene = await studio.getScene(projectId, { revision: requested, full: true })
550
+ const jobs = await studio.listJobs({ projectId })
551
+ const qa = await studio.getQaRecord({ projectId, revision: scene.revision })
552
+ return {
553
+ project: buildProjectView({
554
+ projectId,
555
+ title: overview.title,
556
+ goal: record?.goal ?? null,
557
+ currentRevision: overview.currentRevision,
558
+ revisionCount: overview.revisionCount,
559
+ createdAt: record?.createdAt ?? null,
560
+ updatedAt: record?.updatedAt ?? null,
561
+ }, { sceneSummary: overview.scene ?? null, jobs: jobs.jobs }),
562
+ // The revision everything below was read at: the scene's own answer, so the
563
+ // panel can never label one revision's tree with another revision's id.
564
+ currentRevision: scene.revision,
565
+ scene: buildSceneTree(scene.spec, {
566
+ revision: scene.revision,
567
+ digest: scene.digest,
568
+ compiled: scene.compiledSpec ?? null,
569
+ }),
570
+ revisions: overview.revisions ?? [],
571
+ jobs: jobs.jobs.map(job => buildJobView(job, { threshold })),
572
+ unfinishedJobs: jobs.unfinished,
573
+ qa: buildQaView({
574
+ projectId,
575
+ revision: qa.revision,
576
+ validation: qa.validation,
577
+ review: qa.review,
578
+ }),
579
+ }
580
+ }
581
+
582
+ /** A query/body value that is a finite number, or undefined. @param {unknown} value */
583
+ function numberOrUndefined(value) {
584
+ if (value === undefined || value === null || value === '') return undefined
585
+ const parsed = typeof value === 'number' ? value : Number(value)
586
+ return Number.isFinite(parsed) ? parsed : undefined
587
+ }
588
+
589
+ /**
590
+ * Build the settings-card view model from canonical capabilities.
591
+ *
592
+ * Re-exported from the contracts package because the card is a view model, and
593
+ * this repository has paid five times for one vocabulary written twice (D38,
594
+ * D43, D57, D60) — the sixth copy is the one nobody runs.
595
+ *
596
+ * @param {Record<string, any>} data
597
+ * @returns {Record<string, unknown>}
598
+ */
599
+ export { buildSettingsCard }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@deepblend/dsh-blender-ui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "dsh": {
17
+ "client": {
18
+ "platform": "web"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@deepblend/dsh-blender-contracts": "0.1.0"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/pearjelly/deep-blend.git"
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/cordis": ">=4.0.0 <5",
31
+ "@deepseek-ai/schemastery": ">=3.18.0 <4"
32
+ }
33
+ }