@deepblend/dsh-blender-contracts 0.2.0 → 0.2.2
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.
- package/lib/index.js +26 -1
- package/lib/redact.js +31 -1
- package/lib/ui-api.js +165 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -193,6 +193,26 @@ export const BlenderErrorCode = Object.freeze({
|
|
|
193
193
|
|
|
194
194
|
/** The requested render profile is not defined in the SceneSpec. */
|
|
195
195
|
RENDER_PROFILE_MISSING: 'RENDER_PROFILE_MISSING',
|
|
196
|
+
// ---- M2: the vision reviewer's own route ---------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The configured vision model cannot be used for a review — it is not in the provider's
|
|
200
|
+
* catalog, or it does not accept images.
|
|
201
|
+
*
|
|
202
|
+
* MEASURED, and it had been happening for a day before anything noticed: the shipped default
|
|
203
|
+
* was `deepseek-flash`, which the provider stopped serving (`The model 'deepseek-flash' does
|
|
204
|
+
* not exist`, HTTP 404), and EVERY visual review in the store recorded the same line —
|
|
205
|
+
* "the vision reviewer could not be consulted, so this review carries measurements and a sheet
|
|
206
|
+
* but no second opinion". The loop degraded exactly as designed and nobody read the note, so a
|
|
207
|
+
* headline capability of M2 was dead while every suite stayed green.
|
|
208
|
+
*
|
|
209
|
+
* The refusal exists so the next occurrence is not a 404 from inside a stream: the route is
|
|
210
|
+
* checked BEFORE the sheet is uploaded and before a token is spent, and the detail names what
|
|
211
|
+
* the catalog actually offers — including which of those models can see an image, because a
|
|
212
|
+
* text-only model would silently omit the sheet rather than fail.
|
|
213
|
+
*/
|
|
214
|
+
VISUAL_REVIEW_MODEL_UNAVAILABLE: 'VISUAL_REVIEW_MODEL_UNAVAILABLE',
|
|
215
|
+
|
|
196
216
|
/** A render was refused because its estimated cost exceeds the configured budget. */
|
|
197
217
|
RENDER_BUDGET_EXCEEDED: 'RENDER_BUDGET_EXCEEDED',
|
|
198
218
|
/** Blender rendered but produced no image file. */
|
|
@@ -667,7 +687,7 @@ export {
|
|
|
667
687
|
} from './scene-spec.js'
|
|
668
688
|
|
|
669
689
|
export { ASSET_HEAD_BYTES, assetContentVerdict, describeAssetContent } from './asset-content.js'
|
|
670
|
-
export { redactUrl } from './redact.js'
|
|
690
|
+
export { redactHome, redactUrl } from './redact.js'
|
|
671
691
|
export { assertKnownConfigKeys, declaredConfigKeys, describeUnknownConfigKeys } from './config-surface.js'
|
|
672
692
|
|
|
673
693
|
export {
|
|
@@ -807,6 +827,11 @@ export {
|
|
|
807
827
|
buildJobView,
|
|
808
828
|
buildProjectView,
|
|
809
829
|
buildSettingsCard,
|
|
830
|
+
buildDiagnosticsBundle,
|
|
831
|
+
DIAGNOSTICS_FORMAT,
|
|
832
|
+
DIAGNOSTICS_FORMAT_VERSION,
|
|
833
|
+
DIAGNOSTICS_JOB_LIMIT,
|
|
834
|
+
DIAGNOSTICS_FAILURE_LIMIT,
|
|
810
835
|
describeJobForHuman,
|
|
811
836
|
formatDuration,
|
|
812
837
|
parseToolCallTarget,
|
package/lib/redact.js
CHANGED
|
@@ -23,7 +23,16 @@
|
|
|
23
23
|
* instead. That is not redaction and does not pretend to be: it bounds what a record can accumulate
|
|
24
24
|
* from an arbitrary string, and the caller that passed a non-URL has already seen it.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
26
|
+
* AND A SECOND KIND OF SECRET, WHICH IS WHY `redactHome` IS HERE
|
|
27
|
+
* -------------------------------------------------------------
|
|
28
|
+
* The diagnostic bundle (`ui-api.js` `buildDiagnosticsBundle`) is the first thing this product makes
|
|
29
|
+
* for a user to SEND TO SOMEBODY ELSE, and the first thing it would carry is absolute paths — the
|
|
30
|
+
* store root, the project directory, the Blender executable. Those are what a maintainer needs, so
|
|
31
|
+
* they stay; what goes is the user's account name at the front of them, which is nobody's business
|
|
32
|
+
* and is not what the path is being read for. Same rule as the URL one: the replacement is a
|
|
33
|
+
* recognizable character (`~`), not a mask, so a reader can tell a real path from a redacted one.
|
|
34
|
+
*
|
|
35
|
+
* Owner: DeepBlend Studio — M5; `redactHome` — commercial readiness (ledger C10)
|
|
27
36
|
*/
|
|
28
37
|
|
|
29
38
|
/** How much of an unparseable source is kept. Long enough to recognise, short enough to bound a record. */
|
|
@@ -51,3 +60,24 @@ export function redactUrl(raw) {
|
|
|
51
60
|
if (removed.length === 0) return clean
|
|
52
61
|
return `${clean} (${removed.join(' and ')} removed)`
|
|
53
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The same text with the user's home directory written as `~`.
|
|
66
|
+
*
|
|
67
|
+
* Applied to any string, not only to a path: a job record's message can quote a path in the middle of
|
|
68
|
+
* a sentence, and a redaction that only understood "this string is a path" would miss exactly the
|
|
69
|
+
* messages that matter. Every occurrence is replaced, because a bundle holds many paths and the first
|
|
70
|
+
* one is not the only one.
|
|
71
|
+
*
|
|
72
|
+
* An empty or absent `home` leaves the text alone rather than guessing: replacing `''` would put a `~`
|
|
73
|
+
* between every character, which is the kind of redaction that destroys the evidence it was protecting.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} raw
|
|
76
|
+
* @param {string|null|undefined} home
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function redactHome(raw, home) {
|
|
80
|
+
const text = typeof raw === 'string' ? raw : String(raw)
|
|
81
|
+
if (typeof home !== 'string' || home.length === 0) return text
|
|
82
|
+
return text.split(home).join('~')
|
|
83
|
+
}
|
package/lib/ui-api.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { RENDER_JOB_TERMINAL_STATUSES } from './render-job.js'
|
|
22
|
+
import { redactHome } from './redact.js'
|
|
22
23
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Routes
|
|
@@ -43,6 +44,11 @@ export const UI_REST_MARKER = '*'
|
|
|
43
44
|
*/
|
|
44
45
|
export const UI_ROUTES = Object.freeze([
|
|
45
46
|
{ id: 'capabilities', method: 'GET', path: '/deepblend/capabilities', write: false, summary: 'Blender capabilities and the settings card.' },
|
|
47
|
+
// The first route this product serves FOR SOMEBODY ELSE TO READ. Everything else on this surface
|
|
48
|
+
// exists so the workbench can render itself; this one exists so a user can hand a maintainer the
|
|
49
|
+
// state of their machine. It is a READ route and it must stay one: an export that also changed
|
|
50
|
+
// something would be a new write path wearing a diagnostic's name.
|
|
51
|
+
{ id: 'diagnostics', method: 'GET', path: '/deepblend/diagnostics', write: false, summary: 'A shareable diagnostic bundle: versions, configuration, store summary and recent failures.' },
|
|
46
52
|
// The one route whose response is an HTML document rather than JSON: the
|
|
47
53
|
// standalone fullscreen workbench (SPEC §20 M6). It is a READ route — it
|
|
48
54
|
// answers with a page, and every write that page performs goes through the
|
|
@@ -620,6 +626,165 @@ export function buildSettingsCard(data) {
|
|
|
620
626
|
}
|
|
621
627
|
}
|
|
622
628
|
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// The diagnostic bundle
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
/** What this document is. A reader who is handed one file needs to know before parsing it. */
|
|
634
|
+
export const DIAGNOSTICS_FORMAT = 'deepblend-diagnostics'
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The bundle's own version, bumped when a reader would have to change.
|
|
638
|
+
*
|
|
639
|
+
* NOT the product's version. The two move for different reasons: this one changes when the SHAPE of
|
|
640
|
+
* the document changes, and the product's changes when anything ships. A maintainer reading a bundle
|
|
641
|
+
* from an older release needs the first number to be meaningful on its own.
|
|
642
|
+
*/
|
|
643
|
+
export const DIAGNOSTICS_FORMAT_VERSION = 1
|
|
644
|
+
|
|
645
|
+
/** How many jobs per project the bundle carries. A cap, and the bundle says it was applied. */
|
|
646
|
+
export const DIAGNOSTICS_JOB_LIMIT = 20
|
|
647
|
+
|
|
648
|
+
/** How many failures the bundle carries in total. */
|
|
649
|
+
export const DIAGNOSTICS_FAILURE_LIMIT = 50
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Every string in a value, with the home prefix replaced. Keys are left alone: a key is this
|
|
653
|
+
* product's own vocabulary, and a path can never be one.
|
|
654
|
+
*
|
|
655
|
+
* @param {unknown} value
|
|
656
|
+
* @param {string|null} home
|
|
657
|
+
* @returns {unknown}
|
|
658
|
+
*/
|
|
659
|
+
function redactHomeDeep(value, home) {
|
|
660
|
+
if (typeof value === 'string') return redactHome(value, home)
|
|
661
|
+
if (Array.isArray(value)) return value.map(entry => redactHomeDeep(entry, home))
|
|
662
|
+
if (value !== null && typeof value === 'object') {
|
|
663
|
+
/** @type {Record<string, unknown>} */
|
|
664
|
+
const out = {}
|
|
665
|
+
for (const [key, entry] of Object.entries(value)) out[key] = redactHomeDeep(entry, home)
|
|
666
|
+
return out
|
|
667
|
+
}
|
|
668
|
+
return value
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* The shareable diagnostic bundle (ledger C10).
|
|
673
|
+
*
|
|
674
|
+
* WHY IT IS A PURE BUILDER IN THIS PACKAGE
|
|
675
|
+
* ----------------------------------------
|
|
676
|
+
* The same reason `buildSettingsCard` is: what a user sends to somebody else is a contract, and a
|
|
677
|
+
* contract that is assembled inline in an HTTP handler can only be tested through a running server.
|
|
678
|
+
* Everything here is passed IN — including `generatedAt`, because a builder that read the clock would
|
|
679
|
+
* make its own test non-deterministic.
|
|
680
|
+
*
|
|
681
|
+
* WHAT IT CARRIES, AND WHAT IT REFUSES TO
|
|
682
|
+
* ---------------------------------------
|
|
683
|
+
* Versions, configuration, a store summary, recent failures with their error codes, and the route
|
|
684
|
+
* table. What it does NOT carry, and says so in `redaction.excluded`: scene documents, artifact
|
|
685
|
+
* bytes, model prompts and replies, environment variables, and credentials. The rule for the last
|
|
686
|
+
* one is not a filter applied here — asset URLs were already quoted through `redactUrl` at the
|
|
687
|
+
* source, so a bundle cannot leak what no record ever held.
|
|
688
|
+
*
|
|
689
|
+
* `config` IS AN ALLOWLIST, NOT A SPREAD. The caller names the keys it means; a future configuration
|
|
690
|
+
* key is then absent by default rather than published by accident, which is the direction this
|
|
691
|
+
* decision has to fail in.
|
|
692
|
+
*
|
|
693
|
+
* @param {{
|
|
694
|
+
* generatedAt: string,
|
|
695
|
+
* product: { name: string, version: string|null, hostApiVersion: number|null },
|
|
696
|
+
* environment: { node: string|null, platform: string|null, arch: string|null, home: string|null },
|
|
697
|
+
* blender: { probed: boolean, note: string, capabilities?: Record<string, any>|null, error?: { code: string, message: string }|null },
|
|
698
|
+
* config: Record<string, unknown>,
|
|
699
|
+
* store: { projectsRoot: string|null, workspaceRoot: string|null, projects: Array<Record<string, unknown>> },
|
|
700
|
+
* failures: Array<Record<string, unknown>>,
|
|
701
|
+
* notes?: string[],
|
|
702
|
+
* }} input
|
|
703
|
+
* @returns {Record<string, unknown>}
|
|
704
|
+
*/
|
|
705
|
+
export function buildDiagnosticsBundle(input) {
|
|
706
|
+
const home = typeof input?.environment?.home === 'string' && input.environment.home.length > 0
|
|
707
|
+
? input.environment.home
|
|
708
|
+
: null
|
|
709
|
+
const projects = (input?.store?.projects ?? []).slice(0, undefined)
|
|
710
|
+
const failures = (input?.failures ?? []).slice(0, DIAGNOSTICS_FAILURE_LIMIT)
|
|
711
|
+
const capabilities = input?.blender?.capabilities ?? null
|
|
712
|
+
const probeError = input?.blender?.error ?? null
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
format: DIAGNOSTICS_FORMAT,
|
|
716
|
+
formatVersion: DIAGNOSTICS_FORMAT_VERSION,
|
|
717
|
+
generatedAt: input?.generatedAt ?? null,
|
|
718
|
+
product: {
|
|
719
|
+
name: input?.product?.name ?? 'DeepBlend Studio',
|
|
720
|
+
version: input?.product?.version ?? null,
|
|
721
|
+
hostApiVersion: input?.product?.hostApiVersion ?? null,
|
|
722
|
+
},
|
|
723
|
+
environment: {
|
|
724
|
+
node: input?.environment?.node ?? null,
|
|
725
|
+
platform: input?.environment?.platform ?? null,
|
|
726
|
+
arch: input?.environment?.arch ?? null,
|
|
727
|
+
},
|
|
728
|
+
// A PROJECTION, not the canonical blob: the canonical answer carries per-engine probe details
|
|
729
|
+
// that a reader of a shared file does not need, and every field kept here is one a maintainer
|
|
730
|
+
// would otherwise have to ask for in a follow-up message.
|
|
731
|
+
blender: {
|
|
732
|
+
probed: input?.blender?.probed === true,
|
|
733
|
+
note: input?.blender?.note ?? null,
|
|
734
|
+
error: probeError === null ? null : { code: probeError.code ?? null, message: redactHome(probeError.message ?? '', home) },
|
|
735
|
+
installed: capabilities?.installed === true,
|
|
736
|
+
version: capabilities?.version ?? null,
|
|
737
|
+
pythonVersion: capabilities?.pythonVersion ?? null,
|
|
738
|
+
executable: {
|
|
739
|
+
requested: redactHome(capabilities?.executable?.requested ?? null, home),
|
|
740
|
+
resolved: redactHome(capabilities?.executable?.resolved ?? null, home),
|
|
741
|
+
advice: redactHome(capabilities?.executable?.advice ?? null, home),
|
|
742
|
+
},
|
|
743
|
+
engines: Object.entries(capabilities?.engines ?? {}).filter(([, probe]) => probe?.available).map(([id]) => id),
|
|
744
|
+
bestAvailableEngine: capabilities?.bestAvailableEngine ?? null,
|
|
745
|
+
gpu: {
|
|
746
|
+
available: capabilities?.gpu?.available === true,
|
|
747
|
+
devices: capabilities?.gpu?.devices ?? [],
|
|
748
|
+
preferredBackend: capabilities?.gpu?.preferredBackend ?? null,
|
|
749
|
+
},
|
|
750
|
+
renderSmokeTest: capabilities?.renderSmokeTest === null || capabilities?.renderSmokeTest === undefined
|
|
751
|
+
? null
|
|
752
|
+
: {
|
|
753
|
+
ok: capabilities.renderSmokeTest.ok === true,
|
|
754
|
+
engine: capabilities.renderSmokeTest.engine ?? null,
|
|
755
|
+
bytes: capabilities.renderSmokeTest.bytes ?? null,
|
|
756
|
+
error: capabilities.renderSmokeTest.error ?? null,
|
|
757
|
+
},
|
|
758
|
+
warnings: (capabilities?.warnings ?? []).map(entry => ({ code: entry.code ?? null, message: redactHome(entry.message ?? '', home) })),
|
|
759
|
+
probedAt: capabilities?.probedAt ?? null,
|
|
760
|
+
},
|
|
761
|
+
config: redactHomeDeep(input?.config ?? {}, home),
|
|
762
|
+
store: {
|
|
763
|
+
projectsRoot: redactHome(input?.store?.projectsRoot ?? null, home),
|
|
764
|
+
workspaceRoot: redactHome(input?.store?.workspaceRoot ?? null, home),
|
|
765
|
+
projectCount: projects.length,
|
|
766
|
+
projects: redactHomeDeep(projects, home),
|
|
767
|
+
},
|
|
768
|
+
failures: redactHomeDeep(failures, home),
|
|
769
|
+
failuresTruncated: (input?.failures ?? []).length > failures.length,
|
|
770
|
+
routes: UI_ROUTES.map(route => `${route.method} ${route.path}`),
|
|
771
|
+
redaction: {
|
|
772
|
+
homePrefix: '~',
|
|
773
|
+
replaced: [
|
|
774
|
+
'every path under the home directory is written with ~ in place of the home prefix',
|
|
775
|
+
],
|
|
776
|
+
excluded: [
|
|
777
|
+
'scene documents and SceneSpec contents',
|
|
778
|
+
'artifact bytes (previews, contact sheets, rendered frames, videos)',
|
|
779
|
+
'model prompts and model replies',
|
|
780
|
+
'environment variables',
|
|
781
|
+
'credentials and tokens (asset URLs are quoted through redactUrl where they are recorded)',
|
|
782
|
+
],
|
|
783
|
+
},
|
|
784
|
+
notes: input?.notes ?? [],
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
623
788
|
/**
|
|
624
789
|
* The project list entry.
|
|
625
790
|
*
|