@skyphusion-labs/vivijure-core 0.9.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 (267) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +87 -0
  3. package/dist/audio-routing.d.ts +2 -0
  4. package/dist/audio-routing.d.ts.map +1 -0
  5. package/dist/audio-routing.js +4 -0
  6. package/dist/audio-routing.js.map +1 -0
  7. package/dist/audio-stage.d.ts +4 -0
  8. package/dist/audio-stage.d.ts.map +1 -0
  9. package/dist/audio-stage.js +25 -0
  10. package/dist/audio-stage.js.map +1 -0
  11. package/dist/beat-analyze.d.ts +22 -0
  12. package/dist/beat-analyze.d.ts.map +1 -0
  13. package/dist/beat-analyze.js +111 -0
  14. package/dist/beat-analyze.js.map +1 -0
  15. package/dist/beat-sync-types.d.ts +33 -0
  16. package/dist/beat-sync-types.d.ts.map +1 -0
  17. package/dist/beat-sync-types.js +4 -0
  18. package/dist/beat-sync-types.js.map +1 -0
  19. package/dist/bundle-assembler.d.ts +54 -0
  20. package/dist/bundle-assembler.d.ts.map +1 -0
  21. package/dist/bundle-assembler.js +376 -0
  22. package/dist/bundle-assembler.js.map +1 -0
  23. package/dist/bundle-durations.d.ts +5 -0
  24. package/dist/bundle-durations.d.ts.map +1 -0
  25. package/dist/bundle-durations.js +68 -0
  26. package/dist/bundle-durations.js.map +1 -0
  27. package/dist/bundle-storyboard.d.ts +11 -0
  28. package/dist/bundle-storyboard.d.ts.map +1 -0
  29. package/dist/bundle-storyboard.js +82 -0
  30. package/dist/bundle-storyboard.js.map +1 -0
  31. package/dist/captions.d.ts +25 -0
  32. package/dist/captions.d.ts.map +1 -0
  33. package/dist/captions.js +56 -0
  34. package/dist/captions.js.map +1 -0
  35. package/dist/cast-db.d.ts +61 -0
  36. package/dist/cast-db.d.ts.map +1 -0
  37. package/dist/cast-db.js +324 -0
  38. package/dist/cast-db.js.map +1 -0
  39. package/dist/cast-lora-train.d.ts +18 -0
  40. package/dist/cast-lora-train.d.ts.map +1 -0
  41. package/dist/cast-lora-train.js +215 -0
  42. package/dist/cast-lora-train.js.map +1 -0
  43. package/dist/cast-loras.d.ts +21 -0
  44. package/dist/cast-loras.d.ts.map +1 -0
  45. package/dist/cast-loras.js +69 -0
  46. package/dist/cast-loras.js.map +1 -0
  47. package/dist/clip-content-validate.d.ts +43 -0
  48. package/dist/clip-content-validate.d.ts.map +1 -0
  49. package/dist/clip-content-validate.js +115 -0
  50. package/dist/clip-content-validate.js.map +1 -0
  51. package/dist/clip-job-model.d.ts +42 -0
  52. package/dist/clip-job-model.d.ts.map +1 -0
  53. package/dist/clip-job-model.js +8 -0
  54. package/dist/clip-job-model.js.map +1 -0
  55. package/dist/clip-validate.d.ts +33 -0
  56. package/dist/clip-validate.d.ts.map +1 -0
  57. package/dist/clip-validate.js +247 -0
  58. package/dist/clip-validate.js.map +1 -0
  59. package/dist/d1-retry.d.ts +20 -0
  60. package/dist/d1-retry.d.ts.map +1 -0
  61. package/dist/d1-retry.js +67 -0
  62. package/dist/d1-retry.js.map +1 -0
  63. package/dist/db-env.d.ts +6 -0
  64. package/dist/db-env.d.ts.map +1 -0
  65. package/dist/db-env.js +2 -0
  66. package/dist/db-env.js.map +1 -0
  67. package/dist/dialogue-lines.d.ts +23 -0
  68. package/dist/dialogue-lines.d.ts.map +1 -0
  69. package/dist/dialogue-lines.js +84 -0
  70. package/dist/dialogue-lines.js.map +1 -0
  71. package/dist/film-advance-lease.d.ts +9 -0
  72. package/dist/film-advance-lease.d.ts.map +1 -0
  73. package/dist/film-advance-lease.js +19 -0
  74. package/dist/film-advance-lease.js.map +1 -0
  75. package/dist/film-model.d.ts +415 -0
  76. package/dist/film-model.d.ts.map +1 -0
  77. package/dist/film-model.js +541 -0
  78. package/dist/film-model.js.map +1 -0
  79. package/dist/film-orchestrator.d.ts +216 -0
  80. package/dist/film-orchestrator.d.ts.map +1 -0
  81. package/dist/film-orchestrator.js +2204 -0
  82. package/dist/film-orchestrator.js.map +1 -0
  83. package/dist/film-render-bridge.d.ts +26 -0
  84. package/dist/film-render-bridge.d.ts.map +1 -0
  85. package/dist/film-render-bridge.js +176 -0
  86. package/dist/film-render-bridge.js.map +1 -0
  87. package/dist/finish-hash.d.ts +10 -0
  88. package/dist/finish-hash.d.ts.map +1 -0
  89. package/dist/finish-hash.js +60 -0
  90. package/dist/finish-hash.js.map +1 -0
  91. package/dist/index.d.ts +48 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +45 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/key-safety.d.ts +5 -0
  96. package/dist/key-safety.d.ts.map +1 -0
  97. package/dist/key-safety.js +23 -0
  98. package/dist/key-safety.js.map +1 -0
  99. package/dist/lora-bundle.d.ts +19 -0
  100. package/dist/lora-bundle.d.ts.map +1 -0
  101. package/dist/lora-bundle.js +75 -0
  102. package/dist/lora-bundle.js.map +1 -0
  103. package/dist/lora-keys.d.ts +2 -0
  104. package/dist/lora-keys.d.ts.map +1 -0
  105. package/dist/lora-keys.js +4 -0
  106. package/dist/lora-keys.js.map +1 -0
  107. package/dist/modules/conformance.d.ts +58 -0
  108. package/dist/modules/conformance.d.ts.map +1 -0
  109. package/dist/modules/conformance.js +369 -0
  110. package/dist/modules/conformance.js.map +1 -0
  111. package/dist/modules/manifest-validate.d.ts +5 -0
  112. package/dist/modules/manifest-validate.d.ts.map +1 -0
  113. package/dist/modules/manifest-validate.js +60 -0
  114. package/dist/modules/manifest-validate.js.map +1 -0
  115. package/dist/modules/registry.d.ts +184 -0
  116. package/dist/modules/registry.d.ts.map +1 -0
  117. package/dist/modules/registry.js +653 -0
  118. package/dist/modules/registry.js.map +1 -0
  119. package/dist/modules/render-pipeline.d.ts +38 -0
  120. package/dist/modules/render-pipeline.d.ts.map +1 -0
  121. package/dist/modules/render-pipeline.js +40 -0
  122. package/dist/modules/render-pipeline.js.map +1 -0
  123. package/dist/modules/types.d.ts +544 -0
  124. package/dist/modules/types.d.ts.map +1 -0
  125. package/dist/modules/types.js +57 -0
  126. package/dist/modules/types.js.map +1 -0
  127. package/dist/operator-config.d.ts +20 -0
  128. package/dist/operator-config.d.ts.map +1 -0
  129. package/dist/operator-config.js +85 -0
  130. package/dist/operator-config.js.map +1 -0
  131. package/dist/output-extract.d.ts +8 -0
  132. package/dist/output-extract.d.ts.map +1 -0
  133. package/dist/output-extract.js +129 -0
  134. package/dist/output-extract.js.map +1 -0
  135. package/dist/planner-prompt.d.ts +12 -0
  136. package/dist/planner-prompt.d.ts.map +1 -0
  137. package/dist/planner-prompt.js +337 -0
  138. package/dist/planner-prompt.js.map +1 -0
  139. package/dist/planner-yaml.d.ts +15 -0
  140. package/dist/planner-yaml.d.ts.map +1 -0
  141. package/dist/planner-yaml.js +157 -0
  142. package/dist/planner-yaml.js.map +1 -0
  143. package/dist/platform/fetcher.d.ts +3 -0
  144. package/dist/platform/fetcher.d.ts.map +1 -0
  145. package/dist/platform/fetcher.js +7 -0
  146. package/dist/platform/fetcher.js.map +1 -0
  147. package/dist/platform/index.d.ts +6 -0
  148. package/dist/platform/index.d.ts.map +1 -0
  149. package/dist/platform/index.js +5 -0
  150. package/dist/platform/index.js.map +1 -0
  151. package/dist/platform/object-store-r2.d.ts +26 -0
  152. package/dist/platform/object-store-r2.d.ts.map +1 -0
  153. package/dist/platform/object-store-r2.js +74 -0
  154. package/dist/platform/object-store-r2.js.map +1 -0
  155. package/dist/platform/orchestrator-context.d.ts +20 -0
  156. package/dist/platform/orchestrator-context.d.ts.map +1 -0
  157. package/dist/platform/orchestrator-context.js +28 -0
  158. package/dist/platform/orchestrator-context.js.map +1 -0
  159. package/dist/platform/r2-types.d.ts +41 -0
  160. package/dist/platform/r2-types.d.ts.map +1 -0
  161. package/dist/platform/r2-types.js +3 -0
  162. package/dist/platform/r2-types.js.map +1 -0
  163. package/dist/platform/types.d.ts +90 -0
  164. package/dist/platform/types.d.ts.map +1 -0
  165. package/dist/platform/types.js +23 -0
  166. package/dist/platform/types.js.map +1 -0
  167. package/dist/preflight.d.ts +57 -0
  168. package/dist/preflight.d.ts.map +1 -0
  169. package/dist/preflight.js +243 -0
  170. package/dist/preflight.js.map +1 -0
  171. package/dist/presign.d.ts +5 -0
  172. package/dist/presign.d.ts.map +1 -0
  173. package/dist/presign.js +24 -0
  174. package/dist/presign.js.map +1 -0
  175. package/dist/public-id.d.ts +8 -0
  176. package/dist/public-id.d.ts.map +1 -0
  177. package/dist/public-id.js +25 -0
  178. package/dist/public-id.js.map +1 -0
  179. package/dist/render-adopt.d.ts +3 -0
  180. package/dist/render-adopt.d.ts.map +1 -0
  181. package/dist/render-adopt.js +92 -0
  182. package/dist/render-adopt.js.map +1 -0
  183. package/dist/render-log.d.ts +25 -0
  184. package/dist/render-log.d.ts.map +1 -0
  185. package/dist/render-log.js +139 -0
  186. package/dist/render-log.js.map +1 -0
  187. package/dist/render-module-config.d.ts +28 -0
  188. package/dist/render-module-config.d.ts.map +1 -0
  189. package/dist/render-module-config.js +124 -0
  190. package/dist/render-module-config.js.map +1 -0
  191. package/dist/render-mux.d.ts +18 -0
  192. package/dist/render-mux.d.ts.map +1 -0
  193. package/dist/render-mux.js +69 -0
  194. package/dist/render-mux.js.map +1 -0
  195. package/dist/render-orchestrator.d.ts +71 -0
  196. package/dist/render-orchestrator.d.ts.map +1 -0
  197. package/dist/render-orchestrator.js +376 -0
  198. package/dist/render-orchestrator.js.map +1 -0
  199. package/dist/render-sweep.d.ts +5 -0
  200. package/dist/render-sweep.d.ts.map +1 -0
  201. package/dist/render-sweep.js +62 -0
  202. package/dist/render-sweep.js.map +1 -0
  203. package/dist/renders-db.d.ts +129 -0
  204. package/dist/renders-db.d.ts.map +1 -0
  205. package/dist/renders-db.js +752 -0
  206. package/dist/renders-db.js.map +1 -0
  207. package/dist/runpod-submit.d.ts +151 -0
  208. package/dist/runpod-submit.d.ts.map +1 -0
  209. package/dist/runpod-submit.js +469 -0
  210. package/dist/runpod-submit.js.map +1 -0
  211. package/dist/runpod-types.d.ts +2 -0
  212. package/dist/runpod-types.d.ts.map +1 -0
  213. package/dist/runpod-types.js +4 -0
  214. package/dist/runpod-types.js.map +1 -0
  215. package/dist/scatter-notify.d.ts +4 -0
  216. package/dist/scatter-notify.d.ts.map +1 -0
  217. package/dist/scatter-notify.js +43 -0
  218. package/dist/scatter-notify.js.map +1 -0
  219. package/dist/scatter-orchestrator-types.d.ts +56 -0
  220. package/dist/scatter-orchestrator-types.d.ts.map +1 -0
  221. package/dist/scatter-orchestrator-types.js +3 -0
  222. package/dist/scatter-orchestrator-types.js.map +1 -0
  223. package/dist/scatter-orchestrator.d.ts +70 -0
  224. package/dist/scatter-orchestrator.d.ts.map +1 -0
  225. package/dist/scatter-orchestrator.js +614 -0
  226. package/dist/scatter-orchestrator.js.map +1 -0
  227. package/dist/scatter.d.ts +35 -0
  228. package/dist/scatter.d.ts.map +1 -0
  229. package/dist/scatter.js +171 -0
  230. package/dist/scatter.js.map +1 -0
  231. package/dist/secret-store.d.ts +5 -0
  232. package/dist/secret-store.d.ts.map +1 -0
  233. package/dist/secret-store.js +19 -0
  234. package/dist/secret-store.js.map +1 -0
  235. package/dist/shot-durations-parse.d.ts +3 -0
  236. package/dist/shot-durations-parse.d.ts.map +1 -0
  237. package/dist/shot-durations-parse.js +43 -0
  238. package/dist/shot-durations-parse.js.map +1 -0
  239. package/dist/srt.d.ts +11 -0
  240. package/dist/srt.d.ts.map +1 -0
  241. package/dist/srt.js +54 -0
  242. package/dist/srt.js.map +1 -0
  243. package/dist/storyboard-ids.d.ts +2 -0
  244. package/dist/storyboard-ids.d.ts.map +1 -0
  245. package/dist/storyboard-ids.js +12 -0
  246. package/dist/storyboard-ids.js.map +1 -0
  247. package/dist/storyboard-projects-db.d.ts +31 -0
  248. package/dist/storyboard-projects-db.d.ts.map +1 -0
  249. package/dist/storyboard-projects-db.js +153 -0
  250. package/dist/storyboard-projects-db.js.map +1 -0
  251. package/dist/storyboard-validate.d.ts +72 -0
  252. package/dist/storyboard-validate.d.ts.map +1 -0
  253. package/dist/storyboard-validate.js +595 -0
  254. package/dist/storyboard-validate.js.map +1 -0
  255. package/dist/structured-events.d.ts +6 -0
  256. package/dist/structured-events.d.ts.map +1 -0
  257. package/dist/structured-events.js +15 -0
  258. package/dist/structured-events.js.map +1 -0
  259. package/dist/tar.d.ts +9 -0
  260. package/dist/tar.d.ts.map +1 -0
  261. package/dist/tar.js +151 -0
  262. package/dist/tar.js.map +1 -0
  263. package/dist/voices.d.ts +12 -0
  264. package/dist/voices.d.ts.map +1 -0
  265. package/dist/voices.js +37 -0
  266. package/dist/voices.js.map +1 -0
  267. package/package.json +71 -0
@@ -0,0 +1,653 @@
1
+ // The module registry: the core's index of what is plugged in.
2
+ //
3
+ // On demand, the core scans its env for module service bindings (named `MODULE_*`), reads each
4
+ // module's manifest (GET /module.json), and indexes them by hook. That index drives two things: the
5
+ // pipeline (which module answers a hook) and the frontend (GET /api/modules, so the studio UI renders
6
+ // only what is installed). A bare deploy with no modules bound is a valid, lean studio.
7
+ //
8
+ // Everything here is best-effort and total: a module that fails to respond, or serves a malformed or
9
+ // wrong-version manifest, is dropped from the registry with a console warning, never crashing the
10
+ // core. The pure helpers (validation, indexing, the response shape) are unit-tested without bindings.
11
+ import { MODULE_API, HOOK_BLURBS, HOOK_CARDINALITY, HOOK_NAMES, } from "./types.js";
12
+ import { validateManifest } from "./manifest-validate.js";
13
+ function isFetcher(v) {
14
+ return !!v && typeof v.fetch === "function";
15
+ }
16
+ function isDispatch(v) {
17
+ return !!v && typeof v.get === "function";
18
+ }
19
+ /** Structural twin of `isDemoMode` in ../auth-gate (which is canonical): same AUTH_MODE
20
+ * normalization, duplicated here ONLY because the registry stays import-free of Env by rule
21
+ * (see DispatchLike above). If the demo-mode definition ever changes, change BOTH. */
22
+ function isDemoEnv(env) {
23
+ return typeof env.AUTH_MODE === "string" && env.AUTH_MODE.trim() === "demo";
24
+ }
25
+ /** The env key naming the WfP dispatch-namespace binding. Excluded from the `MODULE_*` service scan
26
+ * (it is a DispatchNamespace, NOT a module Fetcher): the `isFetcher` guard already rejects it by shape
27
+ * (a namespace has `.get()`, not `.fetch()`), but the exclusion is made EXPLICIT so a future
28
+ * workers-types change can never accidentally enroll the namespace as a module (docs/module-dispatch
29
+ * section 6.3). */
30
+ export const DISPATCH_BINDING = "MODULE_DISPATCH";
31
+ /** The reserved prefix marking a `RegisteredModule.binding` ref as a WfP dispatch script rather than a
32
+ * service binding: `dispatch:<script-name>`. It cannot collide with a real `MODULE_*` env key, so the
33
+ * one `binding` string carries the module's transport through job state and resolveFetcher parses it. */
34
+ export const DISPATCH_REF_PREFIX = "dispatch:";
35
+ /** Build a dispatch binding ref from a namespace script name. */
36
+ export function dispatchRef(scriptName) {
37
+ return DISPATCH_REF_PREFIX + scriptName;
38
+ }
39
+ /** Resolve a module `binding` ref to a Fetcher, transport-aware. A `dispatch:<script>` ref resolves
40
+ * through the namespace binding (`MODULE_DISPATCH.get(script)`, which THROWS if the script is absent --
41
+ * guarded so a missing script becomes an honest null / ok:false degrade, never a core crash); any other
42
+ * ref is a service-binding env key. Returns null when the transport is unavailable (no namespace bound,
43
+ * script absent, or binding unbound), which every caller already treats as "module not reachable". This
44
+ * is the SINGLE resolution primitive: everything after "got a Fetcher" (the invoke/poll/cancel envelope)
45
+ * is identical for both transports (docs/module-dispatch.md 3.2). */
46
+ export function resolveFetcher(env, binding) {
47
+ if (binding.startsWith(DISPATCH_REF_PREFIX)) {
48
+ const ns = env[DISPATCH_BINDING];
49
+ if (!isDispatch(ns))
50
+ return null;
51
+ try {
52
+ return ns.get(binding.slice(DISPATCH_REF_PREFIX.length));
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ const v = env[binding];
59
+ return isFetcher(v) ? v : null;
60
+ }
61
+ /** The env keys that name module SERVICE bindings. Convention: `MODULE_<NAME>` service bindings;
62
+ * the dispatch-namespace binding is explicitly skipped (it is not a module, it is the transport to N). */
63
+ export function moduleBindingNames(env) {
64
+ return Object.keys(env)
65
+ .filter((k) => k.startsWith("MODULE_") && k !== DISPATCH_BINDING && isFetcher(env[k]))
66
+ .sort();
67
+ }
68
+ // --------------------------------------------------------------------------- config validation
69
+ /** Clamp + coerce a user's config values against a module's declared schema. Unknown keys are
70
+ * dropped; missing keys fall back to the field default; numbers are clamped to [min, max]; an
71
+ * out-of-set enum falls back to its default. The result is exactly what the core sends a module as
72
+ * `config`, so a module never has to defend against junk. (Mirrors the backend's forgiving
73
+ * config parsing: clamp, do not throw.) */
74
+ export function validateConfig(schema, user) {
75
+ const out = {};
76
+ if (!schema)
77
+ return out;
78
+ const u = user ?? {};
79
+ for (const [key, field] of Object.entries(schema)) {
80
+ const v = u[key];
81
+ switch (field.type) {
82
+ case "int":
83
+ case "float": {
84
+ let n = typeof v === "number" ? v : Number(v);
85
+ if (!Number.isFinite(n))
86
+ n = field.default;
87
+ if (typeof field.min === "number")
88
+ n = Math.max(field.min, n);
89
+ if (typeof field.max === "number")
90
+ n = Math.min(field.max, n);
91
+ out[key] = field.type === "int" ? Math.round(n) : n;
92
+ break;
93
+ }
94
+ case "bool":
95
+ out[key] = typeof v === "boolean" ? v : field.default;
96
+ break;
97
+ case "enum":
98
+ out[key] = field.values.includes(v) ? v : field.default;
99
+ break;
100
+ case "string":
101
+ out[key] = typeof v === "string" ? v : field.default;
102
+ break;
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ // --------------------------------------------------------------------------- indexing + response
108
+ /** Index modules by the hook they serve, preserving `ui.order` (then name) within each hook so a
109
+ * chain hook folds in a stable, declared order. */
110
+ export function indexByHook(modules) {
111
+ const byHook = {};
112
+ const ordered = [...modules].sort((a, b) => (a.ui?.order ?? 100) - (b.ui?.order ?? 100) || a.name.localeCompare(b.name));
113
+ for (const m of ordered) {
114
+ for (const hook of m.hooks) {
115
+ (byHook[hook] ??= []).push(m.name);
116
+ }
117
+ }
118
+ return byHook;
119
+ }
120
+ /** The static hook catalog (every hook + its blurb + cardinality), independent of what is
121
+ * installed, so the frontend renders the pipeline panel as a projection of the contract. */
122
+ export function hookCatalog() {
123
+ return HOOK_NAMES.map((name) => ({
124
+ name,
125
+ blurb: HOOK_BLURBS[name],
126
+ cardinality: HOOK_CARDINALITY[name],
127
+ }));
128
+ }
129
+ /** Strip a registered module down to its PUBLIC view: the manifest, minus the internal `binding` ref.
130
+ * The /api/modules route is unauthenticated, so what serves a hook (a service binding OR a namespace
131
+ * script -- internal module-host topology) must not cross the wire; the frontend renders from the
132
+ * manifest alone (#18). */
133
+ function toPublic({ binding: _binding, ...manifest }) {
134
+ return manifest;
135
+ }
136
+ /** The GET /api/modules payload the frontend renders itself from. Modules are exposed as the PUBLIC
137
+ * view (no `binding`); the hook index keeps using names, so dispatch (which needs the binding) stays
138
+ * core-side off the registered modules, never the wire payload. */
139
+ /** Build the GET /api/modules projection. `render` (core-owned render config, e.g. quality tiers) is
140
+ * passed in by the route rather than imported here: it lives in render-module-config, which imports
141
+ * the registry, so taking it as a param keeps this module free of that back-edge. */
142
+ export function modulesResponse(modules, render, host) {
143
+ return {
144
+ api: MODULE_API,
145
+ modules: modules.map(toPublic),
146
+ hooks: indexByHook(modules),
147
+ catalog: hookCatalog(),
148
+ render,
149
+ ...(host ? { host } : {}),
150
+ };
151
+ }
152
+ // --------------------------------------------------------------------------- discovery (I/O)
153
+ /** Fetch + validate the manifest from one bound module worker. Returns the registered module or
154
+ * null (logged) on any failure, so one bad module never poisons the registry. */
155
+ // One slow/hung module must not stall discovery for all the others (they are read together under
156
+ // Promise.all). Bound the manifest read with a per-call timeout; on timeout the fetch aborts, the
157
+ // catch below logs it, and the module is simply skipped (issue #17).
158
+ const MANIFEST_READ_TIMEOUT_MS = 3000;
159
+ // A dropped module silently SHORTENS a chain hook (e.g. the finish chain), changing the output with
160
+ // no error -- a talking film's shot lost its lip-sync because a transient manifest blip dropped the
161
+ // module at enterFinishPhase time. So retry a TRANSIENT failure (5xx / timeout / network throw) a few
162
+ // times before giving up; a real error (4xx, invalid manifest) is NOT retried -- that module is
163
+ // genuinely broken and is dropped as before (issue #17). Mirrors the D1 self-heal philosophy.
164
+ const MANIFEST_READ_ATTEMPTS = 3;
165
+ const MANIFEST_RETRY_BASE_MS = 120;
166
+ /** A non-2xx manifest status worth retrying (the module is up but momentarily unhappy / warming). A
167
+ * 4xx is a real, stable error (bad route / auth) -- do not retry. */
168
+ function isRetryableManifestStatus(status) {
169
+ return status === 408 || status === 429 || (status >= 500 && status <= 599);
170
+ }
171
+ export async function readManifest(binding, fetcher) {
172
+ let lastReason = "";
173
+ for (let attempt = 0; attempt < MANIFEST_READ_ATTEMPTS; attempt++) {
174
+ const lastAttempt = attempt === MANIFEST_READ_ATTEMPTS - 1;
175
+ try {
176
+ const res = await fetcher.fetch("https://module/module.json", {
177
+ signal: AbortSignal.timeout(MANIFEST_READ_TIMEOUT_MS),
178
+ });
179
+ if (!res.ok) {
180
+ // Transient (5xx/timeout-class) -> retry so a blip never silently drops the module from a
181
+ // chain. A 4xx is stable -> skip now.
182
+ if (isRetryableManifestStatus(res.status) && !lastAttempt) {
183
+ lastReason = `GET /module.json -> ${res.status}`;
184
+ await new Promise((r) => setTimeout(r, MANIFEST_RETRY_BASE_MS * (attempt + 1)));
185
+ continue;
186
+ }
187
+ console.warn(`module ${binding}: GET /module.json -> ${res.status}; skipping`);
188
+ return null;
189
+ }
190
+ const parsed = validateManifest(await readModuleJson(res));
191
+ if (typeof parsed === "string") {
192
+ console.warn(`module ${binding}: invalid manifest (${parsed}); skipping`); // real error: don't retry
193
+ return null;
194
+ }
195
+ return { ...parsed, binding };
196
+ }
197
+ catch (e) {
198
+ // fetch threw (timeout / network) -> transient: retry unless this was the last attempt.
199
+ lastReason = e.message;
200
+ if (!lastAttempt) {
201
+ await new Promise((r) => setTimeout(r, MANIFEST_RETRY_BASE_MS * (attempt + 1)));
202
+ continue;
203
+ }
204
+ }
205
+ }
206
+ console.warn(`module ${binding}: unreachable after ${MANIFEST_READ_ATTEMPTS} attempts (${lastReason}); skipping`);
207
+ return null;
208
+ }
209
+ // Per-isolate discovery cache for the /api/modules route (issue #17 follow-up). That route re-ran N
210
+ // service-binding manifest fetches on EVERY request; with a short TTL it serves a cached registry
211
+ // instead. Module bindings are static per deploy and a module only changes its manifest on its own
212
+ // redeploy, so 60s of staleness is fine. This holds only module metadata (identical for every user),
213
+ // so the module-scoped cache leaks nothing cross-request. OPT-IN: only the route passes a TTL; every
214
+ // dispatch-path caller passes nothing and keeps the old always-fresh behavior.
215
+ let discoveryCache = null;
216
+ /** Test-only: drop the per-isolate discovery cache so a suite starts clean. */
217
+ export function _resetModuleDiscoveryCache() {
218
+ discoveryCache = null;
219
+ }
220
+ /** Discover the modules installed via Workers-for-Platforms dynamic dispatch: read the
221
+ * `installed_modules` D1 table (enabled rows), reconstruct each `RegisteredModule` from its stored,
222
+ * conformance-checked manifest, and tag it with its `dispatch` descriptor (the namespace analogue of
223
+ * `binding`). Gated on `env.MODULE_DISPATCH` (the namespace binding) being present: a deploy WITHOUT
224
+ * WfP short-circuits here and never touches D1, so non-WfP / self-host deploys pay ZERO overhead and
225
+ * behave exactly as before. ONE deliberate exception (#625): the public demo studio binds no
226
+ * namespace on purpose (its zero-spend proof is money bindings being ABSENT) but seeds
227
+ * `installed_modules` with captured real manifests so `/api/modules` projects the authentic catalog.
228
+ * Demo mode therefore reads the table too -- display-only by construction: AUTH_MODE=demo denies
229
+ * every mutation at the gate, and the seeded dispatch refs name scripts no namespace serves, so
230
+ * nothing discovered this way is invocable. Best-effort + total, like the service-binding scan: a
231
+ * D1 error or a drifted manifest row is logged and dropped, never crashes discovery.
232
+ * (docs/module-dispatch.md 3.1/3.4) */
233
+ export async function discoverDispatchModules(env) {
234
+ const ns = env[DISPATCH_BINDING];
235
+ // no WfP namespace bound -> nothing to discover, no D1 read (unless this is the demo studio)
236
+ if (!isDispatch(ns) && !isDemoEnv(env))
237
+ return [];
238
+ const db = env.DB;
239
+ if (!db || typeof db.prepare !== "function")
240
+ return [];
241
+ let rows;
242
+ try {
243
+ const res = await db
244
+ .prepare("SELECT name, script_name, manifest_json, api FROM installed_modules WHERE enabled = 1")
245
+ .all();
246
+ rows = res.results ?? [];
247
+ }
248
+ catch (e) {
249
+ console.warn(`dispatch discovery: installed_modules read failed (${e.message}); skipping`);
250
+ return [];
251
+ }
252
+ const out = [];
253
+ for (const row of rows) {
254
+ let raw;
255
+ try {
256
+ raw = JSON.parse(row.manifest_json);
257
+ }
258
+ catch {
259
+ console.warn(`dispatch module ${row.name}: manifest_json is not valid JSON; skipping`);
260
+ continue;
261
+ }
262
+ const parsed = validateManifest(raw);
263
+ if (typeof parsed === "string") {
264
+ console.warn(`dispatch module ${row.name}: stored manifest invalid (${parsed}); skipping`);
265
+ continue;
266
+ }
267
+ out.push({ ...parsed, binding: dispatchRef(row.script_name) });
268
+ }
269
+ return out;
270
+ }
271
+ /** Merge the service-binding registry with the dispatch registry into one list the pipeline +
272
+ * /api/modules consume. On a NAME collision (a module mid-migration that is both service-bound AND
273
+ * installed as a namespace upload -- allowed transiently, section 6.3), the service binding WINS (it
274
+ * is the deliberate, in-tree, deploy-tested one) and the dispatch duplicate is dropped with a warning.
275
+ * Deterministic + order-preserving: service entries first, then any dispatch-only entries. */
276
+ export function mergeRegistries(service, dispatch) {
277
+ const byName = new Map();
278
+ for (const m of service)
279
+ byName.set(m.name, m);
280
+ for (const m of dispatch) {
281
+ if (byName.has(m.name)) {
282
+ console.warn(`module ${m.name}: installed via dispatch AND service-bound; service binding wins (migration overlap)`);
283
+ continue;
284
+ }
285
+ byName.set(m.name, m);
286
+ }
287
+ return [...byName.values()];
288
+ }
289
+ /** Discover every installed module: the `MODULE_*` service-binding scan (read each binding's manifest
290
+ * in parallel) MERGED with the WfP dispatch modules from D1 (discoverDispatchModules, a no-op unless
291
+ * MODULE_DISPATCH is bound). Both kinds land in ONE registry, agnostic to how each later resolves to a
292
+ * Fetcher. With `opts.cacheTtlMs > 0` the result is cached per-isolate for that many ms (the
293
+ * /api/modules route uses this); without it discovery runs fresh (the pipeline paths). `opts.nowMs` is
294
+ * injectable for tests. */
295
+ export async function discoverModules(env, opts = {}) {
296
+ const ttl = opts.cacheTtlMs ?? 0;
297
+ const now = opts.nowMs ?? Date.now();
298
+ if (ttl > 0 && discoveryCache && now < discoveryCache.expiresAt) {
299
+ return discoveryCache.modules;
300
+ }
301
+ const names = moduleBindingNames(env);
302
+ const [read, dispatch] = await Promise.all([
303
+ Promise.all(names.map((n) => readManifest(n, env[n]))),
304
+ discoverDispatchModules(env),
305
+ ]);
306
+ const service = read.filter((m) => m !== null);
307
+ const modules = mergeRegistries(service, dispatch);
308
+ if (ttl > 0)
309
+ discoveryCache = { modules, expiresAt: now + ttl };
310
+ return modules;
311
+ }
312
+ /** Look up the module binding that should answer a `pick_one` hook for a given choice (by module
313
+ * name), or the first registered for that hook when no choice is given. Returns null if none. */
314
+ export function resolvePickOne(modules, hook, choice) {
315
+ const serving = modules.filter((m) => m.hooks.includes(hook));
316
+ if (serving.length === 0)
317
+ return null;
318
+ if (choice)
319
+ return serving.find((m) => m.name === choice) ?? null;
320
+ return serving[0];
321
+ }
322
+ // --------------------------------------------------------------------------- dispatch (I/O)
323
+ /** The modules serving a hook, in the same `ui.order` then name order `indexByHook` uses, so a
324
+ * chain folds in the declared order and a pick_one default is the first. */
325
+ export function servingForHook(modules, hook) {
326
+ return [...modules]
327
+ .filter((m) => m.hooks.includes(hook))
328
+ .sort((a, b) => (a.ui?.order ?? 100) - (b.ui?.order ?? 100) || a.name.localeCompare(b.name));
329
+ }
330
+ // ---- locality classification (#379 ui.locality, wired by the S6 debt sprint) -------------------
331
+ // The core classifies motion.backend modules by their DECLARED ui.locality, never by module name.
332
+ // "cloud" = pay-per-render provider; "byo" = the operator's own RunPod endpoint + keys; "local" =
333
+ // a homelab card. An UNDECLARED locality counts as cloud: that preserves the pre-locality behavior
334
+ // (anything that was not the gpu door was offered as a cloud model), and it is the safe default
335
+ // for a third-party module that never heard of the field.
336
+ /** Motion modules the cloud selectors may offer: declared (or defaulted) locality "cloud". */
337
+ export function cloudMotionModules(modules) {
338
+ return servingForHook(modules, "motion.backend").filter((m) => (m.ui?.locality ?? "cloud") === "cloud");
339
+ }
340
+ /** Motion modules that render on hardware the operator controls (byo or local): the "gpu" lane of
341
+ * hybrid renders and the gpu bucket of progress counters. */
342
+ export function gpuDoorMotionModules(modules) {
343
+ const l = (m) => m.ui?.locality;
344
+ return servingForHook(modules, "motion.backend").filter((m) => l(m) === "byo" || l(m) === "local");
345
+ }
346
+ /** The default gpu door when a render does not name one: the order-first byo module (the studio's
347
+ * canonical own-GPU render path), else the order-first local module (a local door is normally an
348
+ * explicit pick, so it only becomes the default when it is the ONLY gpu door). Undefined when no
349
+ * gpu door is installed -- callers fail honestly instead of submitting to a hardcoded name. */
350
+ export function defaultGpuDoorModule(modules) {
351
+ const doors = gpuDoorMotionModules(modules);
352
+ return doors.find((m) => m.ui?.locality === "byo") ?? doors[0];
353
+ }
354
+ /** Preflight for a FULL (non-keyframesOnly) render: the caller MUST name an explicit, serving
355
+ * motion.backend module. Relying on the registry's serving[0] default is the #500 trap -- it can
356
+ * silently land on a bound-but-non-operational module (e.g. an unseeded local door), so the film
357
+ * burns its keyframes then dies at assemble with "no clips rendered to assemble". Returns a
358
+ * novice-readable 400 message that names the problem AND lists the installed choices, or null when
359
+ * the choice resolves. keyframesOnly renders never call this (they have no motion leg). Pure +
360
+ * reusable so hStartFilm / scatter can adopt it once their callers always send a backend. */
361
+ export function motionBackendPreflightError(modules, explicitChoice) {
362
+ const names = servingForHook(modules, "motion.backend").map((m) => m.name);
363
+ if (names.length === 0) {
364
+ return "no motion.backend module is installed, so a full film cannot be rendered. Install a motion backend, or submit a keyframes-only render.";
365
+ }
366
+ const choice = (explicitChoice ?? "").trim();
367
+ if (!choice) {
368
+ return `choose a motion backend for a full render -- a full film needs one to turn keyframes into video. Installed: ${names.join(", ")}. (Or submit a keyframes-only render, which needs no motion backend.)`;
369
+ }
370
+ if (!names.includes(choice)) {
371
+ return `motion backend "${choice}" is not an installed, serving module. Choose one of: ${names.join(", ")}.`;
372
+ }
373
+ return null;
374
+ }
375
+ /** Strict schema check for a CALLER-SUPPLIED module config at the submit boundary (#577). The
376
+ * invoke-path clamp (validateConfig) is deliberately forgiving -- clamp, never throw -- which is
377
+ * right mid-pipeline but hides a caller's mistake at the API door: the bad value silently degrades
378
+ * to the field default, or (when the schema itself over-promised, the #577 trigger) sails through
379
+ * and fails at the provider only AFTER the keyframe phase has spent GPU time. Returns one
380
+ * violation string per bad key, each naming what IS allowed, or [] when the config is clean.
381
+ * Absent keys are fine (defaults apply); only what the caller actually sent is judged. */
382
+ export function configPreflightViolations(schema, user) {
383
+ const out = [];
384
+ const entries = Object.entries(user ?? {});
385
+ if (!entries.length)
386
+ return out;
387
+ const declared = Object.keys(schema ?? {});
388
+ for (const [key, v] of entries) {
389
+ const field = schema?.[key];
390
+ if (!field) {
391
+ out.push(declared.length
392
+ ? `unknown key "${key}" (declared keys: ${declared.join(", ")})`
393
+ : `unknown key "${key}" (this module declares no config keys)`);
394
+ continue;
395
+ }
396
+ switch (field.type) {
397
+ case "int":
398
+ case "float": {
399
+ const n = typeof v === "number" ? v : Number(v);
400
+ if (!Number.isFinite(n)) {
401
+ out.push(`"${key}": expected a number, got ${JSON.stringify(v)}`);
402
+ }
403
+ else if ((typeof field.min === "number" && n < field.min) ||
404
+ (typeof field.max === "number" && n > field.max)) {
405
+ out.push(`"${key}": ${n} is out of range [${field.min ?? "-inf"}, ${field.max ?? "inf"}]`);
406
+ }
407
+ break;
408
+ }
409
+ case "bool":
410
+ if (typeof v !== "boolean")
411
+ out.push(`"${key}": expected true or false, got ${JSON.stringify(v)}`);
412
+ break;
413
+ case "enum":
414
+ if (!field.values.includes(v)) {
415
+ out.push(`"${key}": ${JSON.stringify(v)} is not supported (allowed: ${field.values.join(", ")})`);
416
+ }
417
+ break;
418
+ case "string":
419
+ if (typeof v !== "string")
420
+ out.push(`"${key}": expected a string, got ${JSON.stringify(v)}`);
421
+ break;
422
+ }
423
+ }
424
+ return out;
425
+ }
426
+ /** Preflight the caller's raw motion config against the CHOSEN motion.backend's declared schema,
427
+ * BEFORE any keyframe dispatch (#577: film-c9c44dcc burned ~17min of final-tier keyframes before
428
+ * the motion phase rejected its resolution). Runs only when the backend name resolves -- an
429
+ * unresolved name is motionBackendPreflightError's problem, not this check's. Returns a
430
+ * novice-readable 400 message naming the module and every violation, or null when clean. */
431
+ export function motionConfigPreflightError(modules, backendName, userConfig) {
432
+ const name = (backendName ?? "").trim();
433
+ if (!name)
434
+ return null;
435
+ const module = servingForHook(modules, "motion.backend").find((m) => m.name === name);
436
+ if (!module)
437
+ return null;
438
+ const violations = configPreflightViolations(module.config_schema, userConfig);
439
+ return violations.length
440
+ ? `motion_config rejected by "${name}" before any GPU spend: ${violations.join("; ")}.`
441
+ : null;
442
+ }
443
+ /** A short label for a module's transport, for error/log messages that must name it without leaking to
444
+ * the wire (these strings stay core-side, in the pipeline's degrade log). */
445
+ function transportLabel(module) {
446
+ return module.binding.startsWith(DISPATCH_REF_PREFIX)
447
+ ? module.binding // already "dispatch:<script>"
448
+ : `binding ${module.binding}`;
449
+ }
450
+ /** Resolve a registered module to a Fetcher by its transport-encoded `binding` ref. Thin wrapper over
451
+ * resolveFetcher (the single primitive); everything AFTER "got a Fetcher" is identical for both
452
+ * transports. */
453
+ function fetcherFor(env, module) {
454
+ return resolveFetcher(env, module.binding);
455
+ }
456
+ // F5: a module is untrusted (community territory). Read its response through a size cap so a
457
+ // malicious/buggy module cannot OOM the core with a giant body -- envelopes are small JSON
458
+ // metadata (heavy artifacts live in R2, referenced by key). Oversized/unreadable -> throw, which
459
+ // the callers turn into an honest ok:false degrade.
460
+ const MAX_MODULE_RESPONSE_BYTES = 1024 * 1024; // 1MB
461
+ async function readModuleJson(res) {
462
+ const body = res.body;
463
+ if (!body)
464
+ return res.json(); // no stream (e.g. a test stub) -> fall back
465
+ const reader = body.getReader();
466
+ const chunks = [];
467
+ let total = 0;
468
+ for (;;) {
469
+ const { done, value } = await reader.read();
470
+ if (done)
471
+ break;
472
+ if (!value)
473
+ continue;
474
+ total += value.length;
475
+ if (total > MAX_MODULE_RESPONSE_BYTES) {
476
+ try {
477
+ await reader.cancel();
478
+ }
479
+ catch { /* ignore */ }
480
+ throw new Error(`response exceeded ${MAX_MODULE_RESPONSE_BYTES} bytes`);
481
+ }
482
+ chunks.push(value);
483
+ }
484
+ if (total === 0)
485
+ throw new Error("empty response body");
486
+ const buf = new Uint8Array(total);
487
+ let off = 0;
488
+ for (const c of chunks) {
489
+ buf.set(c, off);
490
+ off += c.length;
491
+ }
492
+ return JSON.parse(new TextDecoder().decode(buf));
493
+ }
494
+ /** POST one module's `/invoke` and return its typed `InvokeResponse`. A module failure is DATA, never
495
+ * an exception: a non-200, a malformed body, or an unreachable binding all become `{ ok: false }`,
496
+ * so the core degrades instead of crashing (the contract's whole point). */
497
+ export async function invokeModule(fetcher, request) {
498
+ let res;
499
+ try {
500
+ res = await fetcher.fetch("https://module/invoke", {
501
+ method: "POST",
502
+ headers: { "content-type": "application/json" },
503
+ body: JSON.stringify(request),
504
+ });
505
+ }
506
+ catch (e) {
507
+ return { ok: false, error: `module unreachable: ${e.message}` };
508
+ }
509
+ if (!res.ok)
510
+ return { ok: false, error: `module /invoke -> ${res.status}` };
511
+ let data;
512
+ try {
513
+ data = (await readModuleJson(res));
514
+ }
515
+ catch (e) {
516
+ return { ok: false, error: `module /invoke body rejected: ${e.message}` };
517
+ }
518
+ if (!(data && typeof data === "object" && typeof data.ok === "boolean")) {
519
+ return { ok: false, error: "module returned a malformed InvokeResponse" };
520
+ }
521
+ return data;
522
+ }
523
+ /** True for the async-accepted shape of an InvokeResponse. */
524
+ function isPending(r) {
525
+ return r.ok === true && r.pending === true;
526
+ }
527
+ /** POST a module's `/poll` to check an async job. A failure is DATA, like invoke. */
528
+ export async function pollModule(fetcher, request) {
529
+ let res;
530
+ try {
531
+ res = await fetcher.fetch("https://module/poll", {
532
+ method: "POST",
533
+ headers: { "content-type": "application/json" },
534
+ body: JSON.stringify(request),
535
+ });
536
+ }
537
+ catch (e) {
538
+ return { ok: false, error: `module unreachable: ${e.message}` };
539
+ }
540
+ if (!res.ok)
541
+ return { ok: false, error: `module /poll -> ${res.status}` };
542
+ let data;
543
+ try {
544
+ data = (await readModuleJson(res));
545
+ }
546
+ catch (e) {
547
+ return { ok: false, error: `module /poll body rejected: ${e.message}` };
548
+ }
549
+ if (!(data && typeof data === "object" && typeof data.ok === "boolean")) {
550
+ return { ok: false, error: "module returned a malformed PollResponse" };
551
+ }
552
+ return data;
553
+ }
554
+ /** POST a module's `/cancel` to STOP an in-flight async job, identified by the same poll token `/invoke`
555
+ * returned. A failure is DATA, like invoke/poll: a non-200, a malformed body, or an unreachable binding
556
+ * all become `{ ok: false }`, so the caller degrade-logs the orphan instead of crashing. Only call this
557
+ * on a module whose manifest advertises `cancelable` -- a module without /cancel would 404 here, which
558
+ * this surfaces honestly as `ok: false` (the caller then logs the orphan, never hides it). */
559
+ export async function cancelModule(fetcher, request) {
560
+ try {
561
+ const res = await fetcher.fetch("https://module/cancel", {
562
+ method: "POST",
563
+ headers: { "content-type": "application/json" },
564
+ body: JSON.stringify(request),
565
+ });
566
+ if (!res.ok)
567
+ return { ok: false, error: `module /cancel -> ${res.status}` };
568
+ const data = (await readModuleJson(res));
569
+ if (data && typeof data === "object" && typeof data.ok === "boolean")
570
+ return data;
571
+ return { ok: false, error: "module returned a malformed CancelResponse" };
572
+ }
573
+ catch (e) {
574
+ return { ok: false, error: `module unreachable: ${e.message}` };
575
+ }
576
+ }
577
+ /** Invoke a module and resolve to a terminal result: a synchronous module returns its output; an
578
+ * async module returns `pending` and we poll its `/poll` until done (or the cap). NOTE: the polling
579
+ * runs in the caller's request, so this fits synchronous + SHORT async hooks. A long-running hook
580
+ * (cloud i2v that takes minutes) should be driven OUT of a request -- an orchestrator (Durable
581
+ * Object / cron) calling invokeModule + pollModule across requests -- so no Worker holds a long
582
+ * request open. */
583
+ export async function awaitInvoke(fetcher, request, opts = {}) {
584
+ const r = await invokeModule(fetcher, request);
585
+ if (!r.ok)
586
+ return r;
587
+ if (!isPending(r))
588
+ return { ok: true, output: r.output };
589
+ const pollMs = opts.pollMs ?? 3000;
590
+ const pollMax = opts.pollMax ?? 40;
591
+ for (let i = 0; i < pollMax; i++) {
592
+ await new Promise((res) => setTimeout(res, pollMs));
593
+ const p = await pollModule(fetcher, { poll: r.poll });
594
+ if (!p.ok)
595
+ return p;
596
+ if (!p.pending)
597
+ return { ok: true, output: p.output };
598
+ }
599
+ return { ok: false, error: "module async job did not finish within the poll window" };
600
+ }
601
+ /** Dispatch a `pick_one` hook: resolve the single serving module (honoring an optional `choice`),
602
+ * clamp the user's config against that module's schema, and invoke it. Returns `{ ok: false }` when
603
+ * no module serves the hook or its binding is missing -- the caller decides whether that is fatal. */
604
+ export async function dispatchPickOne(env, modules, hook, input, context, opts = {}) {
605
+ const module = resolvePickOne(modules, hook, opts.choice);
606
+ if (!module)
607
+ return { ok: false, error: `no module serves pick_one hook "${hook}"` };
608
+ const fetcher = fetcherFor(env, module);
609
+ if (!fetcher) {
610
+ return { ok: false, error: `module ${module.name} (${transportLabel(module)}) is not reachable` };
611
+ }
612
+ const config = validateConfig(module.config_schema, opts.config);
613
+ return awaitInvoke(fetcher, { hook, input, config, context });
614
+ }
615
+ /** Dispatch a `chain` hook: fold every serving module in `ui.order`, each consuming the previous
616
+ * module's output as its next input (mapped by `nextInput`, since a hook's output and input shapes
617
+ * differ), clamping each module's config against its own schema. A failed module is skipped
618
+ * (recorded in `errors`), not fatal -- the chain continues from the last good output. */
619
+ export async function dispatchChain(env, modules, hook, seed, context, opts) {
620
+ const applied = [];
621
+ const errors = [];
622
+ const degraded = [];
623
+ let current = seed;
624
+ let last = null;
625
+ for (const module of servingForHook(modules, hook)) {
626
+ const fetcher = fetcherFor(env, module);
627
+ if (!fetcher) {
628
+ errors.push(`${module.name}: ${transportLabel(module)} is not reachable`);
629
+ continue;
630
+ }
631
+ const config = validateConfig(module.config_schema, opts.configFor?.(module.name));
632
+ const r = await awaitInvoke(fetcher, { hook, input: current, config, context });
633
+ if (r.ok) {
634
+ last = r.output;
635
+ current = await opts.nextInput(r.output, seed);
636
+ applied.push(module.name);
637
+ // A module can return ok:true yet report a SOFT-DEGRADE via the `output.degraded` convention (a
638
+ // reason string) -- it passed its input through because it could not do its work. Without this, a
639
+ // degrade is binned in `applied` and only surfaces if the caller happens to inspect the output (the
640
+ // film.finish-ships-uncarded bug). Record + log it centrally so EVERY chain hook gets the signal.
641
+ const deg = r.output?.degraded;
642
+ if (typeof deg === "string" && deg.length > 0) {
643
+ degraded.push(`${module.name}: ${deg}`);
644
+ console.warn(`chain ${hook}: ${module.name} degraded (${deg})`);
645
+ }
646
+ }
647
+ else {
648
+ errors.push(`${module.name}: ${r.error}`);
649
+ }
650
+ }
651
+ return { output: last, applied, errors, degraded };
652
+ }
653
+ //# sourceMappingURL=registry.js.map