@deepblend/dsh-blender-contracts 0.2.0 → 0.2.1

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 CHANGED
@@ -667,7 +667,7 @@ export {
667
667
  } from './scene-spec.js'
668
668
 
669
669
  export { ASSET_HEAD_BYTES, assetContentVerdict, describeAssetContent } from './asset-content.js'
670
- export { redactUrl } from './redact.js'
670
+ export { redactHome, redactUrl } from './redact.js'
671
671
  export { assertKnownConfigKeys, declaredConfigKeys, describeUnknownConfigKeys } from './config-surface.js'
672
672
 
673
673
  export {
@@ -807,6 +807,11 @@ export {
807
807
  buildJobView,
808
808
  buildProjectView,
809
809
  buildSettingsCard,
810
+ buildDiagnosticsBundle,
811
+ DIAGNOSTICS_FORMAT,
812
+ DIAGNOSTICS_FORMAT_VERSION,
813
+ DIAGNOSTICS_JOB_LIMIT,
814
+ DIAGNOSTICS_FAILURE_LIMIT,
810
815
  describeJobForHuman,
811
816
  formatDuration,
812
817
  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
- * Owner: DeepBlend Studio — M5
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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-contracts",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
6
6
  "main": "lib/index.js",