diffusers-workflow 0.4.0__py3-none-any.whl
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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw_mcp/diagnose.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Queue a run and work out what happened.
|
|
2
|
+
|
|
3
|
+
Two rules shape this module. A run costs real GPU time on an engine that
|
|
4
|
+
runs one job at a time, so `run_workflow` - and `rerun_job`, which queues
|
|
5
|
+
the same work - refuses until the caller has acknowledged that. And a generation takes minutes, longer than any MCP
|
|
6
|
+
client will hold a tool call open, so submitting returns immediately and
|
|
7
|
+
progress is polled from the event log.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from dw_mcp.client import DwApiError, api_path, coerce_json_object
|
|
14
|
+
|
|
15
|
+
TERMINAL_STATUSES = {"succeeded", "failed", "cancelled"}
|
|
16
|
+
|
|
17
|
+
# How often wait_for_job re-polls /api/jobs/{id} - matches SSE_POLL_SECONDS,
|
|
18
|
+
# the interval the SSE stream itself re-checks a job at (dw/server/app.py).
|
|
19
|
+
WAIT_POLL_SECONDS = 1.0
|
|
20
|
+
|
|
21
|
+
# A generation can run for minutes, far longer than an MCP client holds a
|
|
22
|
+
# tool call open, so wait_for_job's own budget stays well under that no
|
|
23
|
+
# matter what a caller asks for. Some clients hold a tool call open far
|
|
24
|
+
# longer than the 55s this was tuned against (#248), so a deployment that
|
|
25
|
+
# knows its own harness's tool-call budget can raise the cap with
|
|
26
|
+
# DW_MCP_MAX_WAIT_SECONDS - unset, it stays 55.
|
|
27
|
+
MAX_WAIT_SECONDS = float(os.environ.get("DW_MCP_MAX_WAIT_SECONDS", 55))
|
|
28
|
+
|
|
29
|
+
COST_REFUSAL = (
|
|
30
|
+
"Running a workflow occupies the GPU for minutes and the engine runs one "
|
|
31
|
+
"job at a time. Call `validate_workflow` with the arguments you will run "
|
|
32
|
+
"with (free): its `plan` says what will execute - `estimate.minutes` with "
|
|
33
|
+
"its `basis`, and any weights in `downloads_required` this box has to "
|
|
34
|
+
"fetch first. Tell the user that number, get their go-ahead, then call "
|
|
35
|
+
'again with acknowledged_cost bound to the plan: {"fingerprint": '
|
|
36
|
+
'plan.fingerprint, "minutes": plan.estimate.minutes, "downloads": '
|
|
37
|
+
"[each non-null downloads_required repo]} - the server then refuses (409) if the "
|
|
38
|
+
"run's shape changed since. acknowledged_cost=true is for a `plan` that "
|
|
39
|
+
"was null."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _acknowledgement_body(acknowledged_cost):
|
|
44
|
+
"""What an acknowledgement adds to a request body: a bound one is the
|
|
45
|
+
dict itself, verbatim, so the server compares what the agent quoted; a
|
|
46
|
+
bare true is sent as true, so the job records `acknowledged: boolean`
|
|
47
|
+
rather than reading as one that never passed a gate at all (#85). A
|
|
48
|
+
dict without a fingerprint is a mistake caught here, before anything is
|
|
49
|
+
queued."""
|
|
50
|
+
if isinstance(acknowledged_cost, dict):
|
|
51
|
+
if not acknowledged_cost.get("fingerprint"):
|
|
52
|
+
raise DwApiError(
|
|
53
|
+
"A bound acknowledged_cost needs `fingerprint` - the "
|
|
54
|
+
"plan.fingerprint the validate answer carried. Validate again "
|
|
55
|
+
"and pass {fingerprint, minutes, downloads} from its plan."
|
|
56
|
+
)
|
|
57
|
+
# A from_single_file URL sits in downloads_required with repo: null;
|
|
58
|
+
# an agent copying the list verbatim should not earn a 422 for it
|
|
59
|
+
downloads = acknowledged_cost.get("downloads")
|
|
60
|
+
if isinstance(downloads, list):
|
|
61
|
+
acknowledged_cost = {
|
|
62
|
+
**acknowledged_cost,
|
|
63
|
+
"downloads": [repo for repo in downloads if repo],
|
|
64
|
+
}
|
|
65
|
+
return {"acknowledged_cost": acknowledged_cost}
|
|
66
|
+
return {"acknowledged_cost": bool(acknowledged_cost)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_workflow(
|
|
70
|
+
client,
|
|
71
|
+
workflow_path=None,
|
|
72
|
+
inline_workflow=None,
|
|
73
|
+
workflow=None,
|
|
74
|
+
name=None,
|
|
75
|
+
arguments=None,
|
|
76
|
+
acknowledged_cost=False,
|
|
77
|
+
workspace=None,
|
|
78
|
+
wait_seconds=0,
|
|
79
|
+
):
|
|
80
|
+
"""Queue a workflow. `workflow_path` (or `name` - the same thing
|
|
81
|
+
`validate_workflow` calls it) is either a catalog name from
|
|
82
|
+
`list_workflows` or a path to a workflow file on the server;
|
|
83
|
+
`inline_workflow` (or `workflow` - the same thing `validate_workflow`
|
|
84
|
+
calls it) is a full definition. A document just checked with
|
|
85
|
+
`validate_workflow` can be handed straight to this call under either
|
|
86
|
+
spelling. Returns as soon as it is queued - it does not wait for the job
|
|
87
|
+
to finish. Poll `get_job_events` for progress.
|
|
88
|
+
|
|
89
|
+
`wait_seconds` folds the first `wait_for_job` into this call: when it
|
|
90
|
+
is above 0 the queued job is waited on exactly as
|
|
91
|
+
`wait_for_job(job_id, timeout_seconds=wait_seconds)` would - same clamp
|
|
92
|
+
to MAX_WAIT_SECONDS, same `waited_seconds` / `timeout_*` /
|
|
93
|
+
`still_running` fields - and the answer carries the queued-job fields
|
|
94
|
+
plus that wait's slim job. Almost every run is followed by a wait, and
|
|
95
|
+
an unattended agent pays a whole tool turn for it; where the cap covers
|
|
96
|
+
the job's runtime this one call is the run and the wait. The gate is
|
|
97
|
+
untouched: queuing is refused before anything is waited on, and a
|
|
98
|
+
refused queue (409 on a stale plan) returns nothing extra.
|
|
99
|
+
|
|
100
|
+
`acknowledged_cost` is true or, better, the plan it was quoted from:
|
|
101
|
+
{fingerprint, minutes, downloads} from `validate_workflow` - see
|
|
102
|
+
COST_REFUSAL. A bound one the server checks; a 409 means the run's
|
|
103
|
+
shape changed since the quote and the message carries the new plan."""
|
|
104
|
+
if workflow_path is not None and name is not None:
|
|
105
|
+
raise DwApiError(
|
|
106
|
+
"`workflow_path` and `name` are the same thing - provide only one."
|
|
107
|
+
)
|
|
108
|
+
inline_workflow = coerce_json_object(inline_workflow, "inline_workflow")
|
|
109
|
+
workflow = coerce_json_object(workflow, "workflow")
|
|
110
|
+
if inline_workflow is not None and workflow is not None:
|
|
111
|
+
raise DwApiError(
|
|
112
|
+
"`inline_workflow` and `workflow` are the same thing - provide only one."
|
|
113
|
+
)
|
|
114
|
+
path = workflow_path if workflow_path is not None else name
|
|
115
|
+
inline = inline_workflow if inline_workflow is not None else workflow
|
|
116
|
+
if not acknowledged_cost:
|
|
117
|
+
raise DwApiError(COST_REFUSAL)
|
|
118
|
+
if (path is None) == (inline is None):
|
|
119
|
+
raise DwApiError(
|
|
120
|
+
"Provide exactly one of `workflow_path`/`name` (a catalog name "
|
|
121
|
+
"or a path to a workflow on the server) or "
|
|
122
|
+
"`inline_workflow`/`workflow` (a definition to run as-is)."
|
|
123
|
+
)
|
|
124
|
+
payload = {"arguments": arguments or {}}
|
|
125
|
+
payload.update(_acknowledgement_body(acknowledged_cost))
|
|
126
|
+
if path is not None:
|
|
127
|
+
payload["workflow_path"] = path
|
|
128
|
+
else:
|
|
129
|
+
payload["workflow"] = inline
|
|
130
|
+
# base_dir is deliberately absent: it decides where an inline workflow's
|
|
131
|
+
# relative paths resolve, and the MCP surface does not hand that out
|
|
132
|
+
# A named workspace pins this one job rather than the session: a
|
|
133
|
+
# restarted session forgets use_workspace, and a job that resolves
|
|
134
|
+
# output: references in the wrong root fails after it was queued
|
|
135
|
+
params = {"workspace": workspace} if workspace else None
|
|
136
|
+
job = client.post_json("/api/jobs", payload, params=params)
|
|
137
|
+
queued = {
|
|
138
|
+
"job_id": job.get("id"),
|
|
139
|
+
"status": job.get("status"),
|
|
140
|
+
"queue_position": job.get("queue_position"),
|
|
141
|
+
"workspace": job.get("workspace"),
|
|
142
|
+
"next": "Poll get_job_events(job_id) for progress, then get_job(job_id) "
|
|
143
|
+
"for the manifest or the error.",
|
|
144
|
+
}
|
|
145
|
+
if not wait_seconds or float(wait_seconds) <= 0 or queued["job_id"] is None:
|
|
146
|
+
return queued
|
|
147
|
+
# The same loop wait_for_job runs, not a second one: its clamp, its
|
|
148
|
+
# budget fields and its `next` are what a caller already paces against.
|
|
149
|
+
# The wait's status and next overwrite the queued ones, since the job
|
|
150
|
+
# has moved on from "queued" by the time either is read
|
|
151
|
+
waited = wait_for_job(client, queued["job_id"], timeout_seconds=wait_seconds)
|
|
152
|
+
return {**queued, **waited}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_job(client, job_id):
|
|
156
|
+
"""A job's status, arguments, warnings, manifest, error and traceback.
|
|
157
|
+
A running job also carries `progress` - the step, the phase and how long
|
|
158
|
+
it has been in it, with a denoise counter that is null until that loop
|
|
159
|
+
starts. Null under `generating` is the pipeline's silent lead-in, not a
|
|
160
|
+
hang; see wait_for_job. A FAILED job keeps `progress` too, frozen at the
|
|
161
|
+
moment it died - the phase it was in is the fastest way to tell what
|
|
162
|
+
killed it, faster than reading `traceback`."""
|
|
163
|
+
return client.get_json(api_path("api", "jobs", job_id))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_job_workflow(client, job_id):
|
|
167
|
+
"""The workflow a job ran. `realized: true` means every mutable input
|
|
168
|
+
is pinned (arguments, seed, prompts, output:latest); false means the
|
|
169
|
+
job predates run tracking and this is the definition as submitted.
|
|
170
|
+
Pass it to save_workflow to rerun it by name, or edit it and pass it
|
|
171
|
+
to run_workflow as inline_workflow."""
|
|
172
|
+
body = client.get_json(api_path("api", "jobs", job_id, "workflow"))
|
|
173
|
+
return {
|
|
174
|
+
"job_id": job_id,
|
|
175
|
+
"realized": bool(body.get("realized")),
|
|
176
|
+
"workflow": body.get("definition"),
|
|
177
|
+
# Which variable rerun_job(new_seed=True) would draw into, null when
|
|
178
|
+
# the workflow has none - see rerun_job on why that matters
|
|
179
|
+
"seed_variable": body.get("seed_variable"),
|
|
180
|
+
"next": "Pass `workflow` to save_workflow to keep it in the catalog "
|
|
181
|
+
"under a name, or edit it and pass it to run_workflow as "
|
|
182
|
+
"inline_workflow.",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_job_events(client, job_id, after=-1, limit=200, kinds=None):
|
|
187
|
+
"""One page of a job's progress events. `after` is exclusive - pass back
|
|
188
|
+
the previous call's `last_seq` to continue. `kinds` (e.g.
|
|
189
|
+
`["log", "warning"]`) restricts the page to events whose `event` or
|
|
190
|
+
`kind` is one of those values -
|
|
191
|
+
without it, bookkeeping events (`memory`, `step_start`, `phase`, ...)
|
|
192
|
+
dominate the payload; `kinds=["log", "warning"]` is what confirms a
|
|
193
|
+
chain's applied values (resample/mix/normalize gains) cheaply."""
|
|
194
|
+
params = {"after": after, "limit": limit}
|
|
195
|
+
if kinds:
|
|
196
|
+
params["kinds"] = list(kinds)
|
|
197
|
+
return client.get_json(
|
|
198
|
+
api_path("api", "jobs", job_id, "event-log"),
|
|
199
|
+
params=params,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_SLIM_KEYS = (
|
|
204
|
+
"id",
|
|
205
|
+
"workflow_name",
|
|
206
|
+
"status",
|
|
207
|
+
"created_at",
|
|
208
|
+
"started_at",
|
|
209
|
+
"finished_at",
|
|
210
|
+
"workspace",
|
|
211
|
+
"run_id",
|
|
212
|
+
# The run's ordinal - the 'v5' the gallery labels its files with - so
|
|
213
|
+
# the caller can name the run it just waited on without another call
|
|
214
|
+
"run_version",
|
|
215
|
+
"queue_position",
|
|
216
|
+
"warnings",
|
|
217
|
+
"error",
|
|
218
|
+
"event_count",
|
|
219
|
+
# Where a running job has got to: the step, the phase and how long it
|
|
220
|
+
# has been in it, plus the denoise counter when one is running. A
|
|
221
|
+
# single-step generation emits nothing for minutes at a time, so this
|
|
222
|
+
# is what separates a slow job from a hung one on a poll that would
|
|
223
|
+
# otherwise come back byte-identical
|
|
224
|
+
"progress",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def slim_job(job):
|
|
229
|
+
"""A job row without its arguments and traceback - what a poll needs.
|
|
230
|
+
|
|
231
|
+
The arguments of an H3 workflow are thousands of tokens of prompt text,
|
|
232
|
+
repeated on every poll of a long render; get_job serves them once. The
|
|
233
|
+
manifest is kept only once the job is terminal, when it names files.
|
|
234
|
+
"""
|
|
235
|
+
slim = {key: job.get(key) for key in _SLIM_KEYS if key in job}
|
|
236
|
+
if job.get("status") in TERMINAL_STATUSES:
|
|
237
|
+
slim["manifest"] = job.get("manifest")
|
|
238
|
+
return slim
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def wait_for_job(client, job_id, timeout_seconds=20):
|
|
242
|
+
"""Block until a job reaches a terminal status, or `timeout_seconds`
|
|
243
|
+
elapses - a bounded alternative to polling `get_job`/`get_job_events` by
|
|
244
|
+
hand. Does not queue anything, so it does not require
|
|
245
|
+
`acknowledged_cost`; it only reads a job someone already queued.
|
|
246
|
+
|
|
247
|
+
`timeout_seconds` is clamped to [0, MAX_WAIT_SECONDS]: a generation can
|
|
248
|
+
run for minutes, far longer than an MCP client holds a tool call open,
|
|
249
|
+
so this never blocks past a budget kept well under that - 55s by
|
|
250
|
+
default, raised for a deployment whose harness tolerates a longer tool
|
|
251
|
+
call via the `DW_MCP_MAX_WAIT_SECONDS` env var, in which case one call
|
|
252
|
+
can cover a whole short job rather than needing several polls. Every
|
|
253
|
+
reply says what was applied - `timeout_applied_seconds` is the budget
|
|
254
|
+
the call actually ran under, `timeout_requested_seconds` what was asked
|
|
255
|
+
for, and `waited_seconds` how long this call blocked - so a caller
|
|
256
|
+
asking for 600 can tell a capped return from an elapsed one rather
|
|
257
|
+
than inferring it from wall clock. Returns as soon as the job's status
|
|
258
|
+
is succeeded, failed or cancelled. If the timeout elapses first,
|
|
259
|
+
returns the job's last-seen status with `still_running: true` instead
|
|
260
|
+
of hanging - call again to keep waiting. Returns a slim job - status,
|
|
261
|
+
warnings, error, and the manifest once finished - without the
|
|
262
|
+
arguments; get_job has those.
|
|
263
|
+
|
|
264
|
+
A running job carries `progress`: the step it is on, the phase
|
|
265
|
+
(`loading`, `generating`, `decoding`, `saving`) with the model or step
|
|
266
|
+
named in `phase_detail`, `seconds_in_phase`, `seconds_since_event`, and
|
|
267
|
+
`denoise_step`/`denoise_total_steps`, which are null until the denoise
|
|
268
|
+
loop starts. `denoise_total_steps` is the schedule that actually runs,
|
|
269
|
+
which is not always the `num_inference_steps` asked for (#110). Two
|
|
270
|
+
calls with the same phase and a growing
|
|
271
|
+
`seconds_in_phase` but a moving `denoise_step` is a slow run; one where
|
|
272
|
+
`denoise_step` is a number that does not move while
|
|
273
|
+
`seconds_since_event` climbs is a stuck one.
|
|
274
|
+
|
|
275
|
+
`denoise_step: null` under `generating` is neither: it is the lead-in
|
|
276
|
+
the pipeline runs before the loop - encoding the prompt and every
|
|
277
|
+
reference - which emits nothing and is well over a minute on a large
|
|
278
|
+
video model. Its length follows what it has to encode, and a *video*
|
|
279
|
+
reference makes it much longer; the measured figures are the model
|
|
280
|
+
skill's (`minimax-h3` for H3). Silence there is expected, and `get_job_events`
|
|
281
|
+
says which block it is inside while it lasts - one `log` line per
|
|
282
|
+
top-level block of a modular pipeline. `seconds_since_event` only says
|
|
283
|
+
something once `denoise_step` is a number, or in any other phase.
|
|
284
|
+
|
|
285
|
+
Even then it is coarse: where a transformer block cache is configured
|
|
286
|
+
the denoise steps are uneven - several cheap ones, then a full one -
|
|
287
|
+
so a long gap between steps can be a healthy run. Read liveness as
|
|
288
|
+
`denoise_step` having moved between polls minutes apart rather than as
|
|
289
|
+
silence under a fixed threshold."""
|
|
290
|
+
requested = max(0.0, float(timeout_seconds))
|
|
291
|
+
applied = min(requested, float(MAX_WAIT_SECONDS))
|
|
292
|
+
capped = applied < requested
|
|
293
|
+
started = time.monotonic()
|
|
294
|
+
deadline = started + applied
|
|
295
|
+
while True:
|
|
296
|
+
job = client.get_json(api_path("api", "jobs", job_id))
|
|
297
|
+
status = job.get("status")
|
|
298
|
+
budget = {
|
|
299
|
+
"waited_seconds": round(time.monotonic() - started, 1),
|
|
300
|
+
"timeout_requested_seconds": round(requested, 1),
|
|
301
|
+
"timeout_applied_seconds": round(applied, 1),
|
|
302
|
+
"timeout_capped": capped,
|
|
303
|
+
}
|
|
304
|
+
if status in TERMINAL_STATUSES:
|
|
305
|
+
return {
|
|
306
|
+
"job_id": job_id,
|
|
307
|
+
"status": status,
|
|
308
|
+
"still_running": False,
|
|
309
|
+
**budget,
|
|
310
|
+
"job": slim_job(job),
|
|
311
|
+
"next": "get_job(job_id) for the arguments and traceback, "
|
|
312
|
+
"get_job_workflow(job_id) for the realized workflow.",
|
|
313
|
+
}
|
|
314
|
+
remaining = deadline - time.monotonic()
|
|
315
|
+
if remaining <= 0:
|
|
316
|
+
next_step = (
|
|
317
|
+
"Call wait_for_job again, or get_job_events for incremental progress."
|
|
318
|
+
)
|
|
319
|
+
if capped:
|
|
320
|
+
next_step = (
|
|
321
|
+
f"You asked to wait {round(requested, 1)}s but one call "
|
|
322
|
+
f"blocks for at most {MAX_WAIT_SECONDS}s, so this "
|
|
323
|
+
"returned early rather than timing out. The job is still "
|
|
324
|
+
"running: call wait_for_job again (each call covers "
|
|
325
|
+
f"~{MAX_WAIT_SECONDS}s of it), or get_job_events for "
|
|
326
|
+
"incremental progress."
|
|
327
|
+
)
|
|
328
|
+
return {
|
|
329
|
+
"job_id": job_id,
|
|
330
|
+
"status": status,
|
|
331
|
+
"still_running": True,
|
|
332
|
+
**budget,
|
|
333
|
+
"job": slim_job(job),
|
|
334
|
+
"next": next_step,
|
|
335
|
+
}
|
|
336
|
+
time.sleep(min(WAIT_POLL_SECONDS, remaining))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def cancel_job(client, job_id):
|
|
340
|
+
"""Ask a queued or running job to stop."""
|
|
341
|
+
return client.post_json(api_path("api", "jobs", job_id, "cancel"))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def rerun_job(client, job_id, acknowledged_cost=False, new_seed=False):
|
|
345
|
+
"""Queue a fresh job from a previous job's stored spec. This costs the
|
|
346
|
+
same GPU time as `run_workflow` and passes through the same gate - a
|
|
347
|
+
rerun is a run, and the gate would be worth nothing if a job id bought
|
|
348
|
+
a way around it.
|
|
349
|
+
|
|
350
|
+
`new_seed` draws a fresh seed into the workflow's seed variable. Without
|
|
351
|
+
it the arguments repeat exactly, and a seeded workflow's rerun is served
|
|
352
|
+
whole from the step cache - the earlier run's files, republished in a
|
|
353
|
+
fraction of a second, with `reused: true`. Ask for a new seed when the
|
|
354
|
+
point is a different image rather than the same one again.
|
|
355
|
+
|
|
356
|
+
`acknowledged_cost` takes the same bound form as `run_workflow`; a
|
|
357
|
+
fresh seed never changes a fingerprint, so the original plan still
|
|
358
|
+
binds a new-seed rerun."""
|
|
359
|
+
if not acknowledged_cost:
|
|
360
|
+
raise DwApiError(COST_REFUSAL)
|
|
361
|
+
return client.post_json(
|
|
362
|
+
api_path("api", "jobs", job_id, "rerun"),
|
|
363
|
+
{"new_seed": new_seed, **_acknowledgement_body(acknowledged_cost)},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def move_job(client, job_id, direction):
|
|
368
|
+
"""Reorder a queued job: up, down, front, or back."""
|
|
369
|
+
return client.post_json(
|
|
370
|
+
api_path("api", "jobs", job_id, "move"), {"direction": direction}
|
|
371
|
+
)
|
dw_mcp/exports.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Bundling a finished job so it can leave the server.
|
|
2
|
+
|
|
3
|
+
The one thing this module has to keep saying: the directory it makes is on
|
|
4
|
+
the machine running dw.serve, which over a `dw.serve --mcp` endpoint is the
|
|
5
|
+
GPU box and not where the agent is. The zip URL is the way to it from
|
|
6
|
+
anywhere else - but when the server requires a bearer token (#353), that URL
|
|
7
|
+
is for the person to open, not for this agent to fetch on their behalf; see
|
|
8
|
+
`export_job`'s `auth_required` / `open_url`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dw_mcp.client import api_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def export_job(client, job_id, overwrite=False):
|
|
15
|
+
"""Gather one finished job into a directory on the machine running
|
|
16
|
+
dw.serve: workflow.json (realized), manifest.json, job.json, README,
|
|
17
|
+
assets/, inputs/, outputs/. The export copies every output and input
|
|
18
|
+
file rather than linking them, so a video job's export costs its size
|
|
19
|
+
again on the server's disk; `total_bytes` in the result reports what
|
|
20
|
+
was copied. Returns the directory, the zip URL(s), the file list with
|
|
21
|
+
sizes and the total. The three JSON files are in the zip, not repeated
|
|
22
|
+
here. The directory is on the server machine, not this one.
|
|
23
|
+
|
|
24
|
+
`auth_required` says whether the zip needs this server's bearer token
|
|
25
|
+
to open - a token this agent has no way to attach to a browser or hand
|
|
26
|
+
to someone else's tooling. When it is true, `open_url` is for the
|
|
27
|
+
*person* to open, not for this agent to fetch: hand it to them (see
|
|
28
|
+
`next`). When it is false, `open_url` may be fetched directly. It is
|
|
29
|
+
`absolute_zip_url` when the server has one configured (`DW_PUBLIC_URL`
|
|
30
|
+
/ the `public_url` setting), else the relative `zip_url`."""
|
|
31
|
+
body = client.post_json(
|
|
32
|
+
api_path("api", "jobs", job_id, "export"),
|
|
33
|
+
params={"overwrite": "true" if overwrite else "false"},
|
|
34
|
+
)
|
|
35
|
+
directory = body.get("directory")
|
|
36
|
+
zip_url = body.get("zip_url")
|
|
37
|
+
absolute_zip_url = body.get("absolute_zip_url")
|
|
38
|
+
auth_required = bool(body.get("auth_required"))
|
|
39
|
+
if auth_required:
|
|
40
|
+
open_url = absolute_zip_url or zip_url
|
|
41
|
+
next_text = (
|
|
42
|
+
"The directory is on the server, and the zip is behind this "
|
|
43
|
+
"server's bearer token - hand open_url to the person and let "
|
|
44
|
+
"them open it themselves; do not fetch it. "
|
|
45
|
+
+ (
|
|
46
|
+
"It is already absolute."
|
|
47
|
+
if absolute_zip_url
|
|
48
|
+
else "It is relative - tell the person the server's own "
|
|
49
|
+
"address, since none is configured (DW_PUBLIC_URL/public_url)."
|
|
50
|
+
)
|
|
51
|
+
+ " Individual results stay reachable inline via "
|
|
52
|
+
"get_output_image/get_output_audio/get_output_frames without "
|
|
53
|
+
"opening the zip at all. workflow.json, manifest.json and "
|
|
54
|
+
"job.json are inside it - they are not repeated here; "
|
|
55
|
+
"get_job_workflow and get_job serve them individually."
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
open_url = absolute_zip_url or zip_url
|
|
59
|
+
next_text = (
|
|
60
|
+
"The directory is on the server. To give the user the files, "
|
|
61
|
+
"fetch open_url and unpack it into exports/ under the session's "
|
|
62
|
+
"working directory - it is the user's deliverable, not a "
|
|
63
|
+
"temporary file, so not a scratch or temp directory. The archive "
|
|
64
|
+
"already unpacks into one folder named after the job id; do not "
|
|
65
|
+
"create that folder first or the id is doubled in the path. "
|
|
66
|
+
"workflow.json, manifest.json and job.json are inside it - they "
|
|
67
|
+
"are not repeated here; get_job_workflow and get_job serve them "
|
|
68
|
+
"individually."
|
|
69
|
+
)
|
|
70
|
+
result = {
|
|
71
|
+
"job_id": job_id,
|
|
72
|
+
"where": f"{directory} on the machine running the MCP server",
|
|
73
|
+
"directory": directory,
|
|
74
|
+
"zip_url": zip_url,
|
|
75
|
+
"auth_required": auth_required,
|
|
76
|
+
"open_url": open_url,
|
|
77
|
+
"files": body.get("files") or [],
|
|
78
|
+
"total_bytes": body.get("total_bytes"),
|
|
79
|
+
"missing": body.get("missing") or [],
|
|
80
|
+
"next": next_text,
|
|
81
|
+
}
|
|
82
|
+
if absolute_zip_url is not None:
|
|
83
|
+
result["absolute_zip_url"] = absolute_zip_url
|
|
84
|
+
return result
|
dw_mcp/guides.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The prose guides, read from the engine an agent is about to drive.
|
|
2
|
+
|
|
3
|
+
These proxy `GET /api/guides` like every other handler proxies its route.
|
|
4
|
+
They used to read the markdown shipped in this package, on the theory that
|
|
5
|
+
documentation works the same against a remote engine as a local one. It
|
|
6
|
+
does not: an MCP at one version against a `dw.serve` at another confidently
|
|
7
|
+
indexed sections - `Speech Generation`, `templates/` - the server did not
|
|
8
|
+
have, and the guides an agent reads have to describe the engine that will
|
|
9
|
+
run what it authors. The one thing given up is answering `list_guides`
|
|
10
|
+
while the server is down, which is not a real use.
|
|
11
|
+
|
|
12
|
+
The `GUIDES` table, section extraction and the checkout-else-packaged file
|
|
13
|
+
resolution now live in `dw/server/guides.py`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dw_mcp.client import api_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_guides(client):
|
|
20
|
+
"""Every guide the engine serves, with what it covers and the sections
|
|
21
|
+
it holds. The section headings are the routing table."""
|
|
22
|
+
return client.get_json("/api/guides")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_guide(client, name, section=None):
|
|
26
|
+
"""One guide, or one section of it. A section name is matched loosely
|
|
27
|
+
on the server, so a heading copied approximately resolves.
|
|
28
|
+
|
|
29
|
+
Without a section the answer is the guide's index - its opening, its
|
|
30
|
+
first section, and `sections`/`withheld` naming the rest - not the
|
|
31
|
+
whole file: WORKFLOW_GUIDE.md whole is ~19.6k tokens, more in one call
|
|
32
|
+
than the entire tool surface costs to connect (#101). Name the section
|
|
33
|
+
you want."""
|
|
34
|
+
params = {"section": section} if section is not None else None
|
|
35
|
+
return client.get_json(api_path("api", "guides", name), params=params)
|