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.
Files changed (260) hide show
  1. diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
  2. diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
  3. diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
  5. diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
  7. dw/__init__.py +440 -0
  8. dw/adapter_compatibility.py +226 -0
  9. dw/arguments.py +1231 -0
  10. dw/assessment_rules.py +159 -0
  11. dw/assets.py +130 -0
  12. dw/cache_blocks.json +16 -0
  13. dw/cache_blocks.py +146 -0
  14. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  15. dw/content_types.py +150 -0
  16. dw/dissolve_frame_errors.py +121 -0
  17. dw/docs/ACCELERATION.md +352 -0
  18. dw/docs/AGENT_LOOP.md +95 -0
  19. dw/docs/DEPENDENCIES.md +91 -0
  20. dw/docs/IP_ADAPTER.md +109 -0
  21. dw/docs/LORAS.md +131 -0
  22. dw/docs/MCP.md +517 -0
  23. dw/docs/PROMPT_WEIGHTING.md +78 -0
  24. dw/docs/QUANTIZATION.md +230 -0
  25. dw/docs/RECIPES_24GB.md +201 -0
  26. dw/docs/RELEASING.md +195 -0
  27. dw/docs/REMOTE.md +140 -0
  28. dw/docs/REPL_COMMANDS.md +121 -0
  29. dw/docs/REPL_WORKER_GUIDE.md +51 -0
  30. dw/docs/SECURITY.md +272 -0
  31. dw/docs/SECURITY_QUICKREF.md +112 -0
  32. dw/docs/SERVER.md +679 -0
  33. dw/docs/TASKS.md +1741 -0
  34. dw/docs/TESTING.md +71 -0
  35. dw/docs/WORKFLOW_GUIDE.md +2038 -0
  36. dw/docs/WORKSPACES.md +316 -0
  37. dw/download_watch.py +335 -0
  38. dw/elision.py +306 -0
  39. dw/events.py +275 -0
  40. dw/for_each.py +409 -0
  41. dw/host_memory.py +258 -0
  42. dw/host_memory_projection.py +230 -0
  43. dw/hub_cache.py +432 -0
  44. dw/introspection.py +1228 -0
  45. dw/kernel_availability.py +208 -0
  46. dw/locations.py +599 -0
  47. dw/log_setup.py +45 -0
  48. dw/loudness.py +82 -0
  49. dw/media_audio.py +217 -0
  50. dw/media_frames.py +367 -0
  51. dw/media_info.py +297 -0
  52. dw/pipeline_processors/chain.py +821 -0
  53. dw/pipeline_processors/config_objects.py +237 -0
  54. dw/pipeline_processors/pipeline.py +2297 -0
  55. dw/pipeline_processors/remote.py +46 -0
  56. dw/plan.py +920 -0
  57. dw/previous_results.py +411 -0
  58. dw/probe_paths.py +59 -0
  59. dw/prompt_schema.json +48 -0
  60. dw/prompt_weighting.py +378 -0
  61. dw/prompts.py +159 -0
  62. dw/realize.py +250 -0
  63. dw/reference_limits.py +215 -0
  64. dw/reference_names.py +125 -0
  65. dw/repl.py +338 -0
  66. dw/repl_commands.py +836 -0
  67. dw/repl_worker.py +159 -0
  68. dw/result.py +1720 -0
  69. dw/result_fps.py +82 -0
  70. dw/run.py +162 -0
  71. dw/runs.py +768 -0
  72. dw/scalar_result_validation.py +97 -0
  73. dw/schema.py +283 -0
  74. dw/security.py +1038 -0
  75. dw/select_validation.py +115 -0
  76. dw/serve.py +277 -0
  77. dw/server/__init__.py +2 -0
  78. dw/server/app.py +4586 -0
  79. dw/server/assess.py +132 -0
  80. dw/server/catalog_shape.py +487 -0
  81. dw/server/enhancers.py +129 -0
  82. dw/server/exports.py +480 -0
  83. dw/server/guides.py +257 -0
  84. dw/server/jobs.py +1561 -0
  85. dw/server/mcp_mount.py +95 -0
  86. dw/server/netinfo.py +124 -0
  87. dw/server/observed_cost.py +379 -0
  88. dw/server/sysinfo.py +71 -0
  89. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  90. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  91. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  92. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  93. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  94. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  95. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  96. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  97. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  98. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  99. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  100. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  101. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  102. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  103. dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
  104. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  105. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  106. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  107. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  108. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  109. dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
  110. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  111. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  112. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  113. dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
  114. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  115. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  116. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  117. dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
  118. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  119. dw/server/ui/assets/html-PhsdjHSr.js +1 -0
  120. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  121. dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
  122. dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
  123. dw/server/ui/assets/index-DgrYhQd9.js +43 -0
  124. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  125. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  126. dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
  127. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  128. dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
  129. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  130. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  131. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  132. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  133. dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
  134. dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
  135. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  136. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  137. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  138. dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
  139. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  140. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  141. dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
  142. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  143. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  144. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  145. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  146. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  147. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  148. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  149. dw/server/ui/assets/php-nr791fC2.js +1 -0
  150. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  151. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  152. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  153. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  154. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  155. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  156. dw/server/ui/assets/python-Bcn70HdC.js +1 -0
  157. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  158. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  159. dw/server/ui/assets/razor-D1HmNnby.js +1 -0
  160. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  161. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  162. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  163. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  164. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  165. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  166. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  167. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  168. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  169. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  170. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  171. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  172. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  173. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  174. dw/server/ui/assets/st-DbInun42.js +1 -0
  175. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  176. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  177. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  178. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  179. dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
  180. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  181. dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
  182. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  183. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  184. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  185. dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
  186. dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
  187. dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
  188. dw/server/ui/index.html +17 -0
  189. dw/server/updater.py +192 -0
  190. dw/settings.py +98 -0
  191. dw/shot_span_preflight.py +116 -0
  192. dw/shots.py +359 -0
  193. dw/slice_preflight.py +148 -0
  194. dw/step.py +187 -0
  195. dw/step_cache.py +442 -0
  196. dw/subfolders.py +107 -0
  197. dw/task_domains.py +307 -0
  198. dw/tasks/assess.py +826 -0
  199. dw/tasks/audio_transcription.py +88 -0
  200. dw/tasks/audio_utils.py +1862 -0
  201. dw/tasks/background_remover.py +43 -0
  202. dw/tasks/borders.py +113 -0
  203. dw/tasks/compose_text.py +74 -0
  204. dw/tasks/concat_videos.py +300 -0
  205. dw/tasks/depth_estimator.py +54 -0
  206. dw/tasks/diffusion_upscale.py +109 -0
  207. dw/tasks/dissolve_videos.py +342 -0
  208. dw/tasks/format_messages.py +24 -0
  209. dw/tasks/gather.py +173 -0
  210. dw/tasks/grade.py +97 -0
  211. dw/tasks/image_to_text.py +43 -0
  212. dw/tasks/image_utils.py +764 -0
  213. dw/tasks/interpolate_frames.py +252 -0
  214. dw/tasks/judge.py +68 -0
  215. dw/tasks/model_cache.py +55 -0
  216. dw/tasks/pair_audio.py +268 -0
  217. dw/tasks/qr_code.py +19 -0
  218. dw/tasks/restore_faces.py +175 -0
  219. dw/tasks/rife_model.py +192 -0
  220. dw/tasks/segment.py +121 -0
  221. dw/tasks/select.py +111 -0
  222. dw/tasks/speech_generation.py +228 -0
  223. dw/tasks/stabilize.py +129 -0
  224. dw/tasks/task.py +920 -0
  225. dw/tasks/tensor_image.py +57 -0
  226. dw/tasks/text_generation.py +169 -0
  227. dw/tasks/text_sections.py +80 -0
  228. dw/tasks/upscale.py +203 -0
  229. dw/tasks/video_utils.py +624 -0
  230. dw/tasks/zoe_depth.py +71 -0
  231. dw/teacache.py +381 -0
  232. dw/teacache_models.json +99 -0
  233. dw/test.py +29 -0
  234. dw/type_helpers.py +231 -0
  235. dw/validate.py +68 -0
  236. dw/variable_constraints.py +444 -0
  237. dw/variables.py +443 -0
  238. dw/video_extensions.py +141 -0
  239. dw/vram_estimate.py +116 -0
  240. dw/worker.py +764 -0
  241. dw/workflow.py +2007 -0
  242. dw/workflow_schema.json +1346 -0
  243. dw/workflow_sources.py +383 -0
  244. dw/workflows/h3_context_ir.json +57 -0
  245. dw/workflows/test.json +31 -0
  246. dw/workspace.py +730 -0
  247. dw_mcp/__init__.py +6 -0
  248. dw_mcp/__main__.py +133 -0
  249. dw_mcp/assets.py +336 -0
  250. dw_mcp/authoring.py +114 -0
  251. dw_mcp/catalog.py +360 -0
  252. dw_mcp/client.py +486 -0
  253. dw_mcp/diagnose.py +371 -0
  254. dw_mcp/exports.py +84 -0
  255. dw_mcp/guides.py +35 -0
  256. dw_mcp/media.py +638 -0
  257. dw_mcp/models.py +97 -0
  258. dw_mcp/prompts.py +104 -0
  259. dw_mcp/server.py +1343 -0
  260. 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)