@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.
- package/LICENSE +661 -0
- package/README.md +87 -0
- package/dist/audio-routing.d.ts +2 -0
- package/dist/audio-routing.d.ts.map +1 -0
- package/dist/audio-routing.js +4 -0
- package/dist/audio-routing.js.map +1 -0
- package/dist/audio-stage.d.ts +4 -0
- package/dist/audio-stage.d.ts.map +1 -0
- package/dist/audio-stage.js +25 -0
- package/dist/audio-stage.js.map +1 -0
- package/dist/beat-analyze.d.ts +22 -0
- package/dist/beat-analyze.d.ts.map +1 -0
- package/dist/beat-analyze.js +111 -0
- package/dist/beat-analyze.js.map +1 -0
- package/dist/beat-sync-types.d.ts +33 -0
- package/dist/beat-sync-types.d.ts.map +1 -0
- package/dist/beat-sync-types.js +4 -0
- package/dist/beat-sync-types.js.map +1 -0
- package/dist/bundle-assembler.d.ts +54 -0
- package/dist/bundle-assembler.d.ts.map +1 -0
- package/dist/bundle-assembler.js +376 -0
- package/dist/bundle-assembler.js.map +1 -0
- package/dist/bundle-durations.d.ts +5 -0
- package/dist/bundle-durations.d.ts.map +1 -0
- package/dist/bundle-durations.js +68 -0
- package/dist/bundle-durations.js.map +1 -0
- package/dist/bundle-storyboard.d.ts +11 -0
- package/dist/bundle-storyboard.d.ts.map +1 -0
- package/dist/bundle-storyboard.js +82 -0
- package/dist/bundle-storyboard.js.map +1 -0
- package/dist/captions.d.ts +25 -0
- package/dist/captions.d.ts.map +1 -0
- package/dist/captions.js +56 -0
- package/dist/captions.js.map +1 -0
- package/dist/cast-db.d.ts +61 -0
- package/dist/cast-db.d.ts.map +1 -0
- package/dist/cast-db.js +324 -0
- package/dist/cast-db.js.map +1 -0
- package/dist/cast-lora-train.d.ts +18 -0
- package/dist/cast-lora-train.d.ts.map +1 -0
- package/dist/cast-lora-train.js +215 -0
- package/dist/cast-lora-train.js.map +1 -0
- package/dist/cast-loras.d.ts +21 -0
- package/dist/cast-loras.d.ts.map +1 -0
- package/dist/cast-loras.js +69 -0
- package/dist/cast-loras.js.map +1 -0
- package/dist/clip-content-validate.d.ts +43 -0
- package/dist/clip-content-validate.d.ts.map +1 -0
- package/dist/clip-content-validate.js +115 -0
- package/dist/clip-content-validate.js.map +1 -0
- package/dist/clip-job-model.d.ts +42 -0
- package/dist/clip-job-model.d.ts.map +1 -0
- package/dist/clip-job-model.js +8 -0
- package/dist/clip-job-model.js.map +1 -0
- package/dist/clip-validate.d.ts +33 -0
- package/dist/clip-validate.d.ts.map +1 -0
- package/dist/clip-validate.js +247 -0
- package/dist/clip-validate.js.map +1 -0
- package/dist/d1-retry.d.ts +20 -0
- package/dist/d1-retry.d.ts.map +1 -0
- package/dist/d1-retry.js +67 -0
- package/dist/d1-retry.js.map +1 -0
- package/dist/db-env.d.ts +6 -0
- package/dist/db-env.d.ts.map +1 -0
- package/dist/db-env.js +2 -0
- package/dist/db-env.js.map +1 -0
- package/dist/dialogue-lines.d.ts +23 -0
- package/dist/dialogue-lines.d.ts.map +1 -0
- package/dist/dialogue-lines.js +84 -0
- package/dist/dialogue-lines.js.map +1 -0
- package/dist/film-advance-lease.d.ts +9 -0
- package/dist/film-advance-lease.d.ts.map +1 -0
- package/dist/film-advance-lease.js +19 -0
- package/dist/film-advance-lease.js.map +1 -0
- package/dist/film-model.d.ts +415 -0
- package/dist/film-model.d.ts.map +1 -0
- package/dist/film-model.js +541 -0
- package/dist/film-model.js.map +1 -0
- package/dist/film-orchestrator.d.ts +216 -0
- package/dist/film-orchestrator.d.ts.map +1 -0
- package/dist/film-orchestrator.js +2204 -0
- package/dist/film-orchestrator.js.map +1 -0
- package/dist/film-render-bridge.d.ts +26 -0
- package/dist/film-render-bridge.d.ts.map +1 -0
- package/dist/film-render-bridge.js +176 -0
- package/dist/film-render-bridge.js.map +1 -0
- package/dist/finish-hash.d.ts +10 -0
- package/dist/finish-hash.d.ts.map +1 -0
- package/dist/finish-hash.js +60 -0
- package/dist/finish-hash.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/key-safety.d.ts +5 -0
- package/dist/key-safety.d.ts.map +1 -0
- package/dist/key-safety.js +23 -0
- package/dist/key-safety.js.map +1 -0
- package/dist/lora-bundle.d.ts +19 -0
- package/dist/lora-bundle.d.ts.map +1 -0
- package/dist/lora-bundle.js +75 -0
- package/dist/lora-bundle.js.map +1 -0
- package/dist/lora-keys.d.ts +2 -0
- package/dist/lora-keys.d.ts.map +1 -0
- package/dist/lora-keys.js +4 -0
- package/dist/lora-keys.js.map +1 -0
- package/dist/modules/conformance.d.ts +58 -0
- package/dist/modules/conformance.d.ts.map +1 -0
- package/dist/modules/conformance.js +369 -0
- package/dist/modules/conformance.js.map +1 -0
- package/dist/modules/manifest-validate.d.ts +5 -0
- package/dist/modules/manifest-validate.d.ts.map +1 -0
- package/dist/modules/manifest-validate.js +60 -0
- package/dist/modules/manifest-validate.js.map +1 -0
- package/dist/modules/registry.d.ts +184 -0
- package/dist/modules/registry.d.ts.map +1 -0
- package/dist/modules/registry.js +653 -0
- package/dist/modules/registry.js.map +1 -0
- package/dist/modules/render-pipeline.d.ts +38 -0
- package/dist/modules/render-pipeline.d.ts.map +1 -0
- package/dist/modules/render-pipeline.js +40 -0
- package/dist/modules/render-pipeline.js.map +1 -0
- package/dist/modules/types.d.ts +544 -0
- package/dist/modules/types.d.ts.map +1 -0
- package/dist/modules/types.js +57 -0
- package/dist/modules/types.js.map +1 -0
- package/dist/operator-config.d.ts +20 -0
- package/dist/operator-config.d.ts.map +1 -0
- package/dist/operator-config.js +85 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/output-extract.d.ts +8 -0
- package/dist/output-extract.d.ts.map +1 -0
- package/dist/output-extract.js +129 -0
- package/dist/output-extract.js.map +1 -0
- package/dist/planner-prompt.d.ts +12 -0
- package/dist/planner-prompt.d.ts.map +1 -0
- package/dist/planner-prompt.js +337 -0
- package/dist/planner-prompt.js.map +1 -0
- package/dist/planner-yaml.d.ts +15 -0
- package/dist/planner-yaml.d.ts.map +1 -0
- package/dist/planner-yaml.js +157 -0
- package/dist/planner-yaml.js.map +1 -0
- package/dist/platform/fetcher.d.ts +3 -0
- package/dist/platform/fetcher.d.ts.map +1 -0
- package/dist/platform/fetcher.js +7 -0
- package/dist/platform/fetcher.js.map +1 -0
- package/dist/platform/index.d.ts +6 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +5 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/object-store-r2.d.ts +26 -0
- package/dist/platform/object-store-r2.d.ts.map +1 -0
- package/dist/platform/object-store-r2.js +74 -0
- package/dist/platform/object-store-r2.js.map +1 -0
- package/dist/platform/orchestrator-context.d.ts +20 -0
- package/dist/platform/orchestrator-context.d.ts.map +1 -0
- package/dist/platform/orchestrator-context.js +28 -0
- package/dist/platform/orchestrator-context.js.map +1 -0
- package/dist/platform/r2-types.d.ts +41 -0
- package/dist/platform/r2-types.d.ts.map +1 -0
- package/dist/platform/r2-types.js +3 -0
- package/dist/platform/r2-types.js.map +1 -0
- package/dist/platform/types.d.ts +90 -0
- package/dist/platform/types.d.ts.map +1 -0
- package/dist/platform/types.js +23 -0
- package/dist/platform/types.js.map +1 -0
- package/dist/preflight.d.ts +57 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +243 -0
- package/dist/preflight.js.map +1 -0
- package/dist/presign.d.ts +5 -0
- package/dist/presign.d.ts.map +1 -0
- package/dist/presign.js +24 -0
- package/dist/presign.js.map +1 -0
- package/dist/public-id.d.ts +8 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +25 -0
- package/dist/public-id.js.map +1 -0
- package/dist/render-adopt.d.ts +3 -0
- package/dist/render-adopt.d.ts.map +1 -0
- package/dist/render-adopt.js +92 -0
- package/dist/render-adopt.js.map +1 -0
- package/dist/render-log.d.ts +25 -0
- package/dist/render-log.d.ts.map +1 -0
- package/dist/render-log.js +139 -0
- package/dist/render-log.js.map +1 -0
- package/dist/render-module-config.d.ts +28 -0
- package/dist/render-module-config.d.ts.map +1 -0
- package/dist/render-module-config.js +124 -0
- package/dist/render-module-config.js.map +1 -0
- package/dist/render-mux.d.ts +18 -0
- package/dist/render-mux.d.ts.map +1 -0
- package/dist/render-mux.js +69 -0
- package/dist/render-mux.js.map +1 -0
- package/dist/render-orchestrator.d.ts +71 -0
- package/dist/render-orchestrator.d.ts.map +1 -0
- package/dist/render-orchestrator.js +376 -0
- package/dist/render-orchestrator.js.map +1 -0
- package/dist/render-sweep.d.ts +5 -0
- package/dist/render-sweep.d.ts.map +1 -0
- package/dist/render-sweep.js +62 -0
- package/dist/render-sweep.js.map +1 -0
- package/dist/renders-db.d.ts +129 -0
- package/dist/renders-db.d.ts.map +1 -0
- package/dist/renders-db.js +752 -0
- package/dist/renders-db.js.map +1 -0
- package/dist/runpod-submit.d.ts +151 -0
- package/dist/runpod-submit.d.ts.map +1 -0
- package/dist/runpod-submit.js +469 -0
- package/dist/runpod-submit.js.map +1 -0
- package/dist/runpod-types.d.ts +2 -0
- package/dist/runpod-types.d.ts.map +1 -0
- package/dist/runpod-types.js +4 -0
- package/dist/runpod-types.js.map +1 -0
- package/dist/scatter-notify.d.ts +4 -0
- package/dist/scatter-notify.d.ts.map +1 -0
- package/dist/scatter-notify.js +43 -0
- package/dist/scatter-notify.js.map +1 -0
- package/dist/scatter-orchestrator-types.d.ts +56 -0
- package/dist/scatter-orchestrator-types.d.ts.map +1 -0
- package/dist/scatter-orchestrator-types.js +3 -0
- package/dist/scatter-orchestrator-types.js.map +1 -0
- package/dist/scatter-orchestrator.d.ts +70 -0
- package/dist/scatter-orchestrator.d.ts.map +1 -0
- package/dist/scatter-orchestrator.js +614 -0
- package/dist/scatter-orchestrator.js.map +1 -0
- package/dist/scatter.d.ts +35 -0
- package/dist/scatter.d.ts.map +1 -0
- package/dist/scatter.js +171 -0
- package/dist/scatter.js.map +1 -0
- package/dist/secret-store.d.ts +5 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +19 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/shot-durations-parse.d.ts +3 -0
- package/dist/shot-durations-parse.d.ts.map +1 -0
- package/dist/shot-durations-parse.js +43 -0
- package/dist/shot-durations-parse.js.map +1 -0
- package/dist/srt.d.ts +11 -0
- package/dist/srt.d.ts.map +1 -0
- package/dist/srt.js +54 -0
- package/dist/srt.js.map +1 -0
- package/dist/storyboard-ids.d.ts +2 -0
- package/dist/storyboard-ids.d.ts.map +1 -0
- package/dist/storyboard-ids.js +12 -0
- package/dist/storyboard-ids.js.map +1 -0
- package/dist/storyboard-projects-db.d.ts +31 -0
- package/dist/storyboard-projects-db.d.ts.map +1 -0
- package/dist/storyboard-projects-db.js +153 -0
- package/dist/storyboard-projects-db.js.map +1 -0
- package/dist/storyboard-validate.d.ts +72 -0
- package/dist/storyboard-validate.d.ts.map +1 -0
- package/dist/storyboard-validate.js +595 -0
- package/dist/storyboard-validate.js.map +1 -0
- package/dist/structured-events.d.ts +6 -0
- package/dist/structured-events.d.ts.map +1 -0
- package/dist/structured-events.js +15 -0
- package/dist/structured-events.js.map +1 -0
- package/dist/tar.d.ts +9 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +151 -0
- package/dist/tar.js.map +1 -0
- package/dist/voices.d.ts +12 -0
- package/dist/voices.d.ts.map +1 -0
- package/dist/voices.js +37 -0
- package/dist/voices.js.map +1 -0
- 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
|