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/server.py ADDED
@@ -0,0 +1,1343 @@
1
+ """Assemble the MCP tool surface over a DwClient.
2
+
3
+ The only module that imports the MCP SDK. Every tool body is a one-line
4
+ call into a handler, so the handlers stay testable without a session and
5
+ this file stays a description of the surface rather than logic.
6
+ """
7
+
8
+ import functools
9
+ import inspect
10
+ from typing import Literal, Optional
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+ from mcp.server.mcpserver.exceptions import ToolError
14
+ from mcp.types import AudioContent, ImageContent, TextContent, ToolAnnotations
15
+
16
+ from dw_mcp import (
17
+ assets,
18
+ authoring,
19
+ catalog,
20
+ diagnose,
21
+ exports,
22
+ guides,
23
+ media,
24
+ models,
25
+ prompts,
26
+ workspaces,
27
+ )
28
+ from dw_mcp.client import DwApiError
29
+
30
+ READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
31
+ WRITES = ToolAnnotations(read_only_hint=False, open_world_hint=False)
32
+ OVERWRITES = ToolAnnotations(
33
+ read_only_hint=False,
34
+ destructive_hint=True,
35
+ idempotent_hint=True,
36
+ open_world_hint=False,
37
+ )
38
+ DELETES = ToolAnnotations(
39
+ read_only_hint=False,
40
+ destructive_hint=True,
41
+ idempotent_hint=True,
42
+ open_world_hint=False,
43
+ )
44
+
45
+
46
+ def _anticipated(fn):
47
+ """Let a DwApiError's message reach the model.
48
+
49
+ A DwApiError is a failure the handlers saw coming and wrote a message
50
+ for. Anything but a ToolError escaping a tool is treated by the SDK as a
51
+ crash: the message is replaced with "Error executing tool <name>" and a
52
+ traceback is logged. Re-raising as ToolError keeps the text.
53
+ """
54
+
55
+ @functools.wraps(fn)
56
+ def wrapper(*args, **kwargs):
57
+ try:
58
+ return fn(*args, **kwargs)
59
+ except DwApiError as e:
60
+ raise ToolError(str(e)) from e
61
+
62
+ return wrapper
63
+
64
+
65
+ def build_server(client):
66
+ """An MCP server whose tools all run against `client`."""
67
+ server = MCPServer(
68
+ "diffusers-workflow",
69
+ instructions=(
70
+ "Generate images, video and audio on a real GPU: author, run "
71
+ "and diagnose diffusers-workflow jobs against a running "
72
+ "dw.serve. A workflow is a JSON document of named steps, each a "
73
+ "diffusers pipeline or a utility task; the engine runs one job "
74
+ "at a time.\n"
75
+ "\n"
76
+ "Start from `list_workflows(shape=...)` and run what the catalog "
77
+ "already holds, with `arguments` overriding its variables. "
78
+ "Shapes: image, image-set, image-edit, shot, sequence, audio, "
79
+ "text, utility. Traits: has-audio, chained, image-conditioned, "
80
+ "identity-referenced, needs-input-media, composes-workflows. An "
81
+ "open-ended request names a subject, not a shape - decide the "
82
+ "deliverable's shape first. `list_guides` indexes the docs by "
83
+ "section and `list_tasks` is what a shape is composed from; "
84
+ "author new JSON only when neither the catalog nor a "
85
+ "composition covers the request.\n"
86
+ "\n"
87
+ "`get_server_info` reports the accelerator, directories and this "
88
+ "session's workspace; a CUDA-only choice is unavailable on an "
89
+ "mps or cpu server.\n"
90
+ "\n"
91
+ 'The loop: `get_guide("workflows", section="Authoring a '
92
+ 'workflow from an agent")` before writing or repairing JSON -> '
93
+ "`validate_workflow` (free; repeat until clean) -> quote its "
94
+ "`plan.estimate` and get the user's go-ahead -> `run_workflow` "
95
+ "-> `wait_for_job` -> `get_job` -> `get_output_image`, "
96
+ "`get_output_frames`, `get_output_audio` to look at and listen "
97
+ "to the result and judge it against the request. Tools that "
98
+ "spend GPU time or disk, or delete for good, refuse until "
99
+ "`acknowledged_cost` is set. A workflow you wrote has no "
100
+ "measured cost: quote the `models/` entry that loads the same "
101
+ "pipeline (`list_workflows(include_models=true)`) times the "
102
+ "number of images.\n"
103
+ "\n"
104
+ "Arguments carry references rather than literals: `variable:`, "
105
+ "`previous_result:`, `prompt:` (the stored prompt library), "
106
+ "`asset:` (input media on the server - `upload_asset`, "
107
+ "`keep_output`) and `output:` (an earlier run's file). The "
108
+ "guide's References section defines each; prefer them to a "
109
+ "local path, which means nothing to the server. "
110
+ "`use_workspace` picks the workspace this session works in."
111
+ ),
112
+ )
113
+
114
+ def tool(fn, annotations):
115
+ # The SDK ships fn.__doc__ verbatim. Python 3.13+ strips a docstring's
116
+ # common indentation at compile time, 3.12 does not - so without this
117
+ # every continuation line reaches the agent with eight leading spaces,
118
+ # about 1_100 tokens of resident surface on the interpreter most
119
+ # servers run. cleandoc makes the description the same on both.
120
+ server.add_tool(
121
+ _anticipated(fn),
122
+ name=fn.__name__,
123
+ description=inspect.cleandoc(fn.__doc__ or ""),
124
+ annotations=annotations,
125
+ )
126
+
127
+ # ------------------------------------------------------------- catalog
128
+
129
+ def list_workflows(
130
+ shape: Optional[str] = None,
131
+ traits: Optional[str] = None,
132
+ configures: Optional[str] = None,
133
+ include_models: bool = False,
134
+ ) -> dict:
135
+ """List the workflows stored on the server, compactly. Decide the
136
+ deliverable's shape first and pass it: one of image, image-set,
137
+ image-edit, shot, sequence, audio, text, utility. `traits` narrows
138
+ further (comma-separated, all must match): has-audio, chained,
139
+ image-conditioned, identity-referenced, needs-input-media,
140
+ composes-workflows. Each entry carries a one-line `summary`, its
141
+ `shape` and `traits` (what it needs supplied), `cost` (curated:
142
+ measured once by a maintainer on the devices named, never derived
143
+ from this server's history - null means nobody wrote one down, not
144
+ that the run is cheap. It is also the mark of an
145
+ entry that has been run through on a real device: one without a
146
+ `cost` has only been authored, and its first run is the one that
147
+ finds what the description could not verify. `observed_minutes` and
148
+ `observed_runs`, when present, are this box's *own* finished runs
149
+ of that workflow - the cold median, model load included, and how
150
+ many runs are behind it. Prefer it when quoting a price for this
151
+ machine, fall back to `cost`, and say "unknown" only when neither
152
+ is there; say which one you used - a maintainer's card and this
153
+ box's average are different claims), output kinds and variable names. `lists`, present for a
154
+ list-driven workflow, names per list variable the fields an entry
155
+ takes, the steps run over it and the default's length; there
156
+ `cost[].per_entry`, when present, is the measured cost of one
157
+ entry (`{variable, minutes, entries}`), so a run over a
158
+ different-length list can be priced from it. `constraints`, present
159
+ for a workflow that bounds a variable, is the rule each bounded one
160
+ has to satisfy, terse - pass an
161
+ `arguments` value outside it and `validate_workflow` refuses it for
162
+ free, instead of the run failing after the weights are loaded.
163
+ Templates only by default; `configures=<template>` lists the checkpoint configs
164
+ tuned for one, `include_models=true` lists them all. `get_workflow`
165
+ has the full description and definition."""
166
+ return catalog.list_workflows(
167
+ client,
168
+ shape=shape,
169
+ traits=[t.strip() for t in (traits or "").split(",") if t.strip()],
170
+ configures=configures,
171
+ include_models=include_models,
172
+ )
173
+
174
+ def get_workflow(name: str, variables_only: bool = False) -> dict:
175
+ """Get one stored workflow's full JSON definition, by a name from
176
+ `list_workflows`. Read one before editing it, and to learn the
177
+ idioms this installation actually uses. Pass
178
+ `variables_only=true` when the question is only what a variable
179
+ defaults to - it answers with the variables and their values and
180
+ nothing else, which is a fraction of the definition; long defaults
181
+ come back cut to 200 characters with the cut ones named in
182
+ `truncated`, reaching into a list default too - a shot's prompt
183
+ is named `shots[0].prompt`. `observed`, when this box has run the
184
+ workflow, is the whole derived-cost block: `cold_minutes` /
185
+ `cold_runs` (model load included) and `warm_minutes` /
186
+ `warm_runs` (model already resident), the `drivers` the figure is
187
+ for, `since`, and `unclassified_runs` when a run's event tail was
188
+ trimmed too far to tell which it was. Step-cache-only runs are
189
+ excluded. A variable the workflow bounds is
190
+ reported under `constraints` beside its default - the range and the
191
+ step it has to land on - so a frame count is read rather than
192
+ guessed at."""
193
+ return catalog.get_workflow(client, name, variables_only=variables_only)
194
+
195
+ def get_schema(section: str | None = None) -> dict:
196
+ """Get the JSON schema every workflow definition must satisfy - the
197
+ authority on a workflow's structure: steps, pipelines, tasks,
198
+ results, variables. Read it before authoring one from scratch, and
199
+ note that schema validation runs before variable substitution, so a
200
+ variable's default has to be the type its use expects (25, not
201
+ "25").
202
+
203
+ Ask for the part you need: `section` takes `steps`, `pipelines`,
204
+ `tasks`, `result`, `variables` or `configuration` and answers that
205
+ fragment - a tenth the size - with `elsewhere` naming the section
206
+ that holds each definition it still references. The whole schema is
207
+ the no-argument call."""
208
+ return catalog.get_schema(client, section=section)
209
+
210
+ def list_pipelines() -> dict:
211
+ """List every diffusers pipeline class this installation provides.
212
+ These are the names a step's `component_type` can take - and the
213
+ list is this installation's, so a pipeline from a newer diffusers
214
+ will not be here until it is updated."""
215
+ return catalog.list_pipelines(client)
216
+
217
+ def get_pipeline_signature(name: str) -> dict:
218
+ """Get a pipeline's real call arguments. Check this before proposing
219
+ pipeline arguments - a plausible-looking argument that the pipeline
220
+ does not accept is the most common workflow bug."""
221
+ return catalog.get_pipeline_signature(client, name)
222
+
223
+ def list_classes(
224
+ kind: Literal["pipelines", "models", "schedulers", "quantization"],
225
+ ) -> dict:
226
+ """List class names of one kind: pipelines, models, schedulers, or
227
+ quantization. These are the names a workflow's `component_type`,
228
+ `scheduler_type` or `config_type` can take."""
229
+ return catalog.list_classes(client, kind)
230
+
231
+ def get_class(name: str, target: Literal["init", "call", "load"] = "init") -> dict:
232
+ """Get a class's argument schema, from whichever entry point a
233
+ workflow reaches it by: `init` reads the constructor (quantization
234
+ configs, schedulers, models built from named arguments), `call`
235
+ reads __call__ (a pipeline's `arguments`), `load` reads
236
+ from_pretrained plus the curated loading knobs, which is what a
237
+ component's `from_pretrained_arguments` can carry."""
238
+ return catalog.get_class(client, name, target=target)
239
+
240
+ def list_tasks() -> dict:
241
+ """List every task command a workflow's task step can name - the
242
+ non-pipeline work: upscaling, face restoration, ControlNet
243
+ preprocessors, captioning, frame interpolation, video and audio
244
+ handling. Check here before assuming something needs a pipeline."""
245
+ return catalog.list_tasks(client)
246
+
247
+ def get_task(command: str) -> dict:
248
+ """Get a task command's argument schema, read from its real
249
+ implementation signature. The counterpart of
250
+ `get_pipeline_signature` for a task step."""
251
+ return catalog.get_task(client, command)
252
+
253
+ def list_models() -> dict:
254
+ """List what the Hugging Face model cache holds, largest first."""
255
+ return catalog.list_models(client)
256
+
257
+ def get_memory() -> dict:
258
+ """Get the worker's VRAM and RAM statistics. Check this first when a
259
+ job fails with an out-of-memory error.
260
+
261
+ `gpu_*` is the card, `host_memory_*` the machine:
262
+ `host_memory_rss_mb` is what the worker process holds and
263
+ `host_memory_peak_rss_mb` the most it has ever held, beside the
264
+ machine's `host_memory_total_mb` / `host_memory_available_mb`. Read
265
+ both - a workflow that offloads (`offload: "sequential"`,
266
+ `group_offload`) keeps its weights in host memory by design, so the
267
+ card can sit near-empty through a generation and VRAM alone will not
268
+ show what a run is holding or failing to release. A host field is
269
+ absent, rather than null, on a platform that cannot measure it.
270
+
271
+ `host_pinned_reserved_mb` / `host_pinned_allocated_mb`, when
272
+ present, are torch's pinned-host cache and are part of
273
+ `host_memory_rss_mb` - see the `acceleration` guide, section
274
+ "Reading Memory While Offloading", for what that means for a worker
275
+ that has released every model and still holds gigabytes.
276
+
277
+ `live: true` means `info` was measured now and is the worker's own
278
+ memory - only these readings are comparable with each other.
279
+ `live: false` means it was not: `info: null` (with `stale: false`)
280
+ means nothing has been measured because nothing is resident, and a
281
+ populated `info` is a cached earlier reading - `reason` says why
282
+ (`job_running`, `worker_stopped`, `worker_busy`, `worker_unreachable`)
283
+ and `age_seconds` how old it is. A cached reading is not this
284
+ moment's: one taken while a job is loading a model understates what
285
+ is resident by however much has loaded since, so ask again when the
286
+ server is idle rather than comparing it against a live figure.
287
+
288
+ `info.step_cache` is the step cache's own accounting: `entries`,
289
+ `retained_bytes` against `max_retained_bytes`."""
290
+ return catalog.get_memory(client)
291
+
292
+ def clear_memory() -> dict:
293
+ """Drop every loaded pipeline and the step cache, freeing VRAM/RAM
294
+ immediately instead of waiting for the next job to evict one model
295
+ for another. Also drops the step cache, so a seeded workflow that
296
+ would otherwise reuse cached results regenerates on its next run.
297
+
298
+ Refused with a 409 while a job is running or queued - the queue is
299
+ FIFO, so wait for it to finish and retry rather than expecting this
300
+ call to block until it does. On an idle server with no model process
301
+ resident there is nothing loaded to clear, so it succeeds with a null
302
+ `info` rather than failing."""
303
+ return catalog.clear_memory(client)
304
+
305
+ def get_health() -> dict:
306
+ """Check that the server is alive, and see what answered: its
307
+ version and accelerator, whether a model process is currently
308
+ resident, the job running now and how many are queued.
309
+
310
+ `worker_alive: false` on an otherwise healthy server (`status: ok`)
311
+ is the normal idle state, not a fault - the worker is an on-demand
312
+ subprocess that has not started yet because no job has run since
313
+ the server started or the last memory clear, and it starts with the
314
+ next job."""
315
+ return catalog.get_health(client)
316
+
317
+ def get_server_info() -> dict:
318
+ """Get what this installation can do and where it keeps things: the
319
+ accelerator a run will use (`device` - cuda, mps or cpu), the dw
320
+ version, and the workflow, output and prompt directories. Check the
321
+ device before authoring: a CUDA-only choice - bitsandbytes
322
+ quantization, torch.compile, flash attention - is not available on
323
+ an mps or cpu server, and `directories` is what a path passed to
324
+ run_workflow or download_output is relative to. If this session
325
+ works in a named workspace, `directories` are scoped to that
326
+ workspace. `trust_workflows` reports the posture a submitted
327
+ workflow is read under: false - the default - means the file is
328
+ untrusted input, so an out-of-ecosystem import, remote code, and a
329
+ media location outside the workspace's roots are all refused."""
330
+ return workspaces.server_info(client)
331
+
332
+ def list_jobs(
333
+ limit: int = 20, status: str | None = None, workspace: str | None = None
334
+ ) -> dict:
335
+ """List queued, running and recent jobs, newest first, with their
336
+ status and queue position. The ids here are what `get_job`,
337
+ `wait_for_job`, `get_job_events`, `cancel_job`, `rerun_job` and
338
+ `move_job` take - including jobs from before this session, so a run
339
+ someone started in the browser can be picked up here.
340
+
341
+ `limit` is the newest N (20 by default); `total` reports how many
342
+ matched, so a truncated answer says so rather than looking
343
+ complete. `status` narrows to one state or a comma-separated set of
344
+ them - queued, running, succeeded, failed, cancelled. `workspace`
345
+ lists one workspace's jobs; without it, a named workspace lists its
346
+ own and the default workspace lists every job the server holds,
347
+ whichever workspace ran it. Each job carries `acknowledged` - `none`,
348
+ `boolean` or `bound` - which form of cost acknowledgement queued it."""
349
+ return catalog.list_jobs(
350
+ client, limit=limit, status=status, workspace=workspace
351
+ )
352
+
353
+ def list_gallery(
354
+ limit: int = 50,
355
+ subfolder: str | None = None,
356
+ only_orphans: bool = False,
357
+ workspace: str | None = None,
358
+ folder: str | None = None,
359
+ version: int | None = None,
360
+ media: bool = False,
361
+ ) -> dict:
362
+ """List generated output files, newest first. A name is
363
+ <workflow>/<run id>/<file>, where <file> may itself sit in a
364
+ subfolder the step chose (`final/episode.mp4`) - the form
365
+ `get_output_image`, `get_output_text`, `download_output`,
366
+ `keep_output` and `delete_output` all take, and the form an
367
+ "output:" reference in a later workflow is built from. Each entry
368
+ carries `folder` (the workflow) and `subfolder` (the part of the run:
369
+ by convention `final` is the deliverable and `intermediate` the
370
+ scratch work, '' when the step chose none); `subfolder=` filters on
371
+ the latter, so `subfolder="final"` is "what did these runs
372
+ deliver". Each entry's `url` is already scoped to its workspace;
373
+ use it as given rather than composing one from the name.
374
+
375
+ Entries also carry `run_id` and `version`, the run's stable ordinal
376
+ (the web UI shows `v5`) - quote the version to a person. `folder=`
377
+ plus `version=` lists that run; "output:<folder>/v5/<file>" names
378
+ it. Other tools take `name`.
379
+
380
+ `only_orphans=True` inverts the call: instead of files, it returns
381
+ run directories holding nothing but their own bookkeeping
382
+ (manifest.json, workflow.json, job.json) as `runs`, each
383
+ `{name, mtime}` - a run whose output was deleted before
384
+ `delete_output` could remove it by name, or one that failed before
385
+ writing anything. `subfolder` does not apply in this mode. `name` is
386
+ exactly what `delete_output` accepts, so clearing the backlog is
387
+ list, then delete each name. A run that wrote any file at
388
+ all - a text-shape prompt, a utility's side output - is not listed;
389
+ this call only lists, so deciding whether a listed entry is actually
390
+ junk before calling `delete_output` on it is still yours to make.
391
+
392
+ `workspace` names the workspace for this one call without
393
+ switching the session to it - the same pin `run_workflow`
394
+ takes, so a job run into another workspace is reachable from
395
+ here without leaving this one.
396
+
397
+ `media=True` adds `duration_seconds` to audio/video entries - two
398
+ takes sharing a basename are told apart by length, not size or
399
+ mtime."""
400
+ return catalog.list_gallery(
401
+ client,
402
+ limit=limit,
403
+ subfolder=subfolder,
404
+ only_orphans=only_orphans,
405
+ workspace=workspace,
406
+ folder=folder,
407
+ version=version,
408
+ media=media,
409
+ )
410
+
411
+ def get_gallery_metadata(
412
+ name: str, envelope: bool = False, workspace: str | None = None
413
+ ) -> dict:
414
+ """Get the metadata embedded in a generated file: the exact
415
+ workflow, arguments and seed that produced it - the definition,
416
+ not a summary, so a result can be reproduced or a failed run's
417
+ definition edited and re-run. Only an image embeds it; for audio
418
+ and video `metadata` is null and `next` names
419
+ `get_job_workflow(job_id)` when known, else a kept asset has no
420
+ provenance. `media` itself carries duration, sample rate,
421
+ channels, fps, size and level - the checks an agent that cannot
422
+ listen makes on a deliverable; `media.shots` places a joined
423
+ video's shots.
424
+ `envelope=true` adds that level second by second
425
+ (`media.envelope.rms_dbfs` / `peak_dbfs`), which says *where* in a
426
+ track something is: a shot's last frame, a seam's hole, where a
427
+ score goes quiet. Leave it off unless it's about position - a long
428
+ track is a long list.
429
+
430
+ `media.peak_dbfs` is what the job's `audio_no_headroom` (-0.5 dBFS,
431
+ pre-encode) and `audio_clipped` (0.0 dBFS, post-encode) warnings
432
+ read - see `normalize_audio` under "Video Processing" in the tasks
433
+ guide. A mux emits only the second.
434
+
435
+ `name` may be an "asset:" reference instead of a gallery name, and
436
+ then it describes that input asset - how many frames a shot is,
437
+ whether two shots share an fps, whether a score reaches the length
438
+ of the cut it will lie under. Check before running: frame counts
439
+ and rates are arguments the caller supplies, and a wrong one is a
440
+ failed job or, worse, silence padded onto the end of a track.
441
+
442
+ `workspace` pins this call to another workspace."""
443
+ return catalog.get_gallery_metadata(
444
+ client, name, envelope=envelope, workspace=workspace
445
+ )
446
+
447
+ def list_guides() -> dict:
448
+ """List the documentation the engine serves: each guide's
449
+ name, what it covers, and its section headings. Read this when a
450
+ request is open-ended enough that no catalog entry obviously
451
+ answers it - a request names a subject ("a lego movie trailer"),
452
+ while the catalog and these guides are written in shapes
453
+ (multi-shot video, cuts, a consistent cast, narration over
454
+ B-roll), and the section headings are where the two get matched
455
+ up. Cheaper than guessing: reading a section costs a fraction of
456
+ one wrong run."""
457
+ return guides.list_guides(client)
458
+
459
+ def get_guide(name: str, section: str | None = None) -> dict:
460
+ """Get one guide from `list_guides`, or one section of it. Name the
461
+ section - a guide runs to thousands of lines, and the headings in
462
+ the listing are there so the right part can be asked for by name. A
463
+ section name is matched loosely, so a heading copied approximately
464
+ still resolves. Called without one, the answer is the guide's index
465
+ (its opening and first section, with `sections` and `withheld`
466
+ naming the rest), not the whole file."""
467
+ return guides.get_guide(client, name, section=section)
468
+
469
+ for fn in (
470
+ list_guides,
471
+ get_guide,
472
+ list_workflows,
473
+ get_workflow,
474
+ get_schema,
475
+ list_pipelines,
476
+ get_pipeline_signature,
477
+ list_classes,
478
+ get_class,
479
+ list_tasks,
480
+ get_task,
481
+ list_models,
482
+ get_memory,
483
+ get_health,
484
+ get_server_info,
485
+ list_jobs,
486
+ list_gallery,
487
+ get_gallery_metadata,
488
+ ):
489
+ tool(fn, READ_ONLY)
490
+ tool(clear_memory, WRITES)
491
+
492
+ # --------------------------------------------------------------- media
493
+
494
+ def get_output_image(
495
+ name: str,
496
+ max_dimension: int = 768,
497
+ workspace: str | None = None,
498
+ crop: list[int] | None = None,
499
+ ) -> list[ImageContent | TextContent]:
500
+ """Look at a generated image, named as `list_gallery` or a job's
501
+ manifest reports it. Use this to judge output quality - a run that
502
+ succeeded can still have made the wrong picture. Downscaled to
503
+ `max_dimension` on its longest side; the second part reports the
504
+ before/after size, so a downscale is never silent.
505
+ `crop` is `[x, y, width, height]` in the original's pixels,
506
+ cut before the downscale.
507
+
508
+ `workspace` pins this call to another workspace."""
509
+ result = media.get_output_image(
510
+ client, name, max_dimension=max_dimension, workspace=workspace, crop=crop
511
+ )
512
+ image = ImageContent(
513
+ type="image", data=result["data"], mime_type=result["mime_type"]
514
+ )
515
+ telemetry = TextContent(
516
+ type="text",
517
+ text=(
518
+ f"name: {result['name']}\n"
519
+ f"original_size: {result['original_size']}\n"
520
+ + (f"crop: {result['crop']}\n" if result["crop"] else "")
521
+ + f"returned_size: {result['returned_size']}\n"
522
+ f"bytes: {result['bytes']}"
523
+ ),
524
+ )
525
+ return [image, telemetry]
526
+
527
+ def get_output_audio(
528
+ name: str,
529
+ start: float | None = None,
530
+ duration: float | None = None,
531
+ workspace: str | None = None,
532
+ ) -> list[AudioContent | TextContent]:
533
+ """Listen to a generated soundtrack, named as `list_gallery` or a
534
+ job's manifest reports it - an audio output, or a video's muxed
535
+ track: own encoding when served whole, WAV when extracted or
536
+ excerpted. No downscale exists for audio - a whole clip too
537
+ large is refused; ask for a part with `start`/`duration` in
538
+ seconds, per `get_gallery_metadata`'s envelope. The text part
539
+ says what was cut. To *see* a video, `get_output_frames`. A
540
+ text-only client confirms the *words* an output speaks by
541
+ transcribing it instead: WORKFLOW_GUIDE's "The loop", step 6, in
542
+ `get_guide("workflows", section="Authoring a workflow from an
543
+ agent")`.
544
+
545
+ `workspace` pins this call to another workspace."""
546
+ result = media.get_output_audio(
547
+ client, name, start=start, duration=duration, workspace=workspace
548
+ )
549
+ audio = AudioContent(
550
+ type="audio", data=result["data"], mime_type=result["mime_type"]
551
+ )
552
+ lines = [f"name: {result['name']}", f"bytes: {result['bytes']}"]
553
+ if result["duration_seconds"] is not None:
554
+ lines.append(f"duration_seconds: {result['duration_seconds']}")
555
+ if result["excerpt"]:
556
+ e = result["excerpt"]
557
+ lines.append(f"excerpt: {e['duration']}s from {e['start']}s of {e['of']}s")
558
+ telemetry = TextContent(type="text", text="\n".join(lines))
559
+ return [audio, telemetry]
560
+
561
+ def get_output_frames(
562
+ name: str,
563
+ at: list[str | float] | None = None,
564
+ seams: bool | list[int] | None = None,
565
+ count: int | None = None,
566
+ boundaries: list[int] | None = None,
567
+ names: list[str] | None = None,
568
+ max_dimension: int = 512,
569
+ hear: float | None = None,
570
+ workspace: str | None = None,
571
+ crop: list[int] | None = None,
572
+ ) -> list[ImageContent | AudioContent | TextContent]:
573
+ """See a generated video as frames - no video content type exists
574
+ over MCP. One selector: `count` (contact sheet), `at` (seconds or
575
+ "frame:N"), or `seams` (true, or seam numbers from 1) for each
576
+ join's frame pair, at a joined output's `media.shots`; else
577
+ `boundaries` (each later shot's first frame) and `names`. Over budget, tiles shrink together.
578
+ `hear=N` adds N seconds of soundtrack around each `at`.
579
+ `crop` is `[x, y, width, height]` in the video's own source
580
+ pixels, cut from every frame before any downscale, like
581
+ `get_output_image`'s.
582
+
583
+ `workspace` pins this call to another workspace."""
584
+ result = media.get_output_frames(
585
+ client,
586
+ name,
587
+ at=at,
588
+ seams=seams,
589
+ count=count,
590
+ boundaries=boundaries,
591
+ names=names,
592
+ max_dimension=max_dimension,
593
+ hear=hear,
594
+ workspace=workspace,
595
+ crop=crop,
596
+ )
597
+ parts = []
598
+ for tile in result["tiles"]:
599
+ parts.append(
600
+ ImageContent(
601
+ type="image", data=tile["data"], mime_type=tile["mime_type"]
602
+ )
603
+ )
604
+ if "audio" in tile:
605
+ parts.append(
606
+ AudioContent(
607
+ type="audio",
608
+ data=tile["audio"]["data"],
609
+ mime_type=tile["audio"]["mime_type"],
610
+ )
611
+ )
612
+ lines = [
613
+ f"name: {result['name']}",
614
+ f"frame_count: {result['frame_count']} fps: {result['fps']}",
615
+ ]
616
+ if result.get("crop"):
617
+ lines.append(f"crop: {result['crop']}")
618
+ fps = result["fps"]
619
+ for tile in result["tiles"]:
620
+ if tile.get("frames"):
621
+ # a contact sheet: every cell, so each one can be located
622
+ cells = ", ".join(
623
+ f"{frame} ({frame / fps:.2f}s)" if fps else str(frame)
624
+ for frame in tile["frames"]
625
+ )
626
+ where = f"frames: {cells}"
627
+ else:
628
+ where = f"frame {tile['frame']} @ {tile['seconds']:.2f}s"
629
+ if tile.get("difference") is not None:
630
+ where += f" difference: {tile['difference']}"
631
+ if tile.get("audio_error"):
632
+ where += f" hear: {tile['audio_error']}"
633
+ lines.append(
634
+ f"- {tile['label']} {where} [{tile['width']}x{tile['height']}]"
635
+ )
636
+ if result["downscaled_to"]:
637
+ lines.append(
638
+ f"downscaled_to: {result['downscaled_to']} (every tile, to fit the inline budget)"
639
+ )
640
+ if result.get("hear"):
641
+ lines.append(f"hear: {result['hear']}s around each moment")
642
+ if result.get("audio_truncated"):
643
+ lines.append(
644
+ "audio_truncated: some tiles' audio was skipped to stay within "
645
+ "the response size budget"
646
+ )
647
+ parts.append(TextContent(type="text", text="\n".join(lines)))
648
+ return parts
649
+
650
+ def get_output_text(
651
+ name: str, max_characters: int = 20000, workspace: str | None = None
652
+ ) -> dict:
653
+ """Read a text output - a prompt enhancement, or any step whose
654
+ result is text/plain or JSON. Truncated to `max_characters`, and
655
+ the reply says how long the file really was.
656
+
657
+ `workspace` names the workspace for this one call without
658
+ switching the session to it - the same pin `run_workflow`
659
+ takes, so a job run into another workspace is reachable from
660
+ here without leaving this one."""
661
+ return media.get_output_text(
662
+ client, name, max_characters=max_characters, workspace=workspace
663
+ )
664
+
665
+ def assess_output(
666
+ name: str,
667
+ probe: str | None = None,
668
+ detail: bool = False,
669
+ workspace: str | None = None,
670
+ ) -> dict:
671
+ """Measure a finished cut - seams, shot levels, sync - on the
672
+ server, without queueing. Findings are places to look, not
673
+ verdicts: check each with get_output_frames/get_output_audio.
674
+ `probe` (analyze_shots, analyze_seams, analyze_sync_drift) returns
675
+ one probe's full body; `detail` adds every probe's. Takes `asset:`."""
676
+ return media.assess_output(
677
+ client, name, probe=probe, detail=detail, workspace=workspace
678
+ )
679
+
680
+ def delete_output(
681
+ name: str | None = None,
682
+ workspace: str | None = None,
683
+ job_id: str | None = None,
684
+ ) -> dict:
685
+ """Permanently remove one generated file from the output directory.
686
+ Not recoverable (rerun the job to get it back), and any "output:"
687
+ reference to it stops resolving; prefer `keep_output` if it is
688
+ worth keeping. When it was the last media file of its run, the run
689
+ directory goes with it, sidecars included. `name` may also be a run
690
+ directory ("<workflow>/<run id>", the first two parts of a gallery
691
+ name), which removes the whole run - the only handle on a run that
692
+ failed before writing any media - or give `job_id` instead: the run
693
+ that job wrote is removed whole, and the reply adds `job_id` and
694
+ the resolved `run_dir`. Exactly one of the two; a job with no run
695
+ directory, or unknown, is an error.
696
+
697
+ `workspace` pins this call to another workspace without switching
698
+ the session; a `job_id` delete with no `workspace` goes to
699
+ the workspace the job ran in."""
700
+ return media.delete_output(client, name, workspace=workspace, job_id=job_id)
701
+
702
+ def download_output(
703
+ name: str,
704
+ destination: str | None = None,
705
+ overwrite: bool = False,
706
+ workspace: str | None = None,
707
+ ) -> dict:
708
+ """Save one output file to disk on the
709
+ machine running the MCP server - for the stdio `dw-mcp` that is
710
+ your own machine; for a
711
+ `dw.serve --mcp` endpoint it is the GPU box, and this tool is not
712
+ the way to get a file to where you are (use get_output_image /
713
+ get_output_text for inline content, or the `url` that
714
+ `list_gallery` reports for each entry, which already carries the
715
+ workspace selector - do not build an /outputs URL by hand). This is
716
+ also not how a generated file becomes an input for a later
717
+ workflow: use `keep_output`, which links it inside the workspace
718
+ under an "asset:" name, rather than writing into the server's asset
719
+ directory behind the API's back. Unlike the inline tools, this works
720
+ for any file type, streams the body straight to disk rather than
721
+ buffering it, and returns no content to the conversation - only
722
+ where it was saved. `destination` may be a
723
+ full path or a directory; a '..' path segment in it is refused. An
724
+ existing file at the resolved path is left alone unless
725
+ `overwrite=True`. On the stdio `dw-mcp`, omitting `destination`
726
+ saves into the current working directory under the output's own
727
+ name. On a `dw.serve --mcp` endpoint the save happens on the server, and destination is required there -
728
+ an omitted one is refused rather than dropped loose in the
729
+ workspace root, where nothing can find or delete it later; use
730
+ the `url` list_gallery reports, get_output_image/get_output_audio/
731
+ get_output_frames for inline content, or keep_output to make it a
732
+ named asset instead.
733
+
734
+ `workspace` names the workspace for this one call without
735
+ switching the session to it - the same pin `run_workflow`
736
+ takes, so a job run into another workspace is reachable from
737
+ here without leaving this one."""
738
+ return media.download_output(
739
+ client,
740
+ name,
741
+ destination=destination,
742
+ overwrite=overwrite,
743
+ workspace=workspace,
744
+ )
745
+
746
+ tool(get_output_image, READ_ONLY)
747
+ tool(get_output_audio, READ_ONLY)
748
+ tool(get_output_frames, READ_ONLY)
749
+ tool(get_output_text, READ_ONLY)
750
+ tool(assess_output, READ_ONLY)
751
+ tool(download_output, OVERWRITES)
752
+ tool(delete_output, DELETES)
753
+
754
+ # ---------------------------------------------------------------- assets
755
+
756
+ def list_assets(detail: bool = False) -> dict:
757
+ """List the input media on the server, each with the "asset:"
758
+ reference a workflow argument carries. Look here before asking for
759
+ a file: what a workflow needs may already be there. Entries carry
760
+ name, reference, kind, size and origin only - for one asset's
761
+ duration, frame count, fps, sample rate or channels, pass its
762
+ reference to `get_gallery_metadata`, which reads inputs as well as
763
+ outputs. Pass detail=true for each entry's folder, mtime and url
764
+ too, needed before naming a shared library's writable/read-only
765
+ roots or opening the file's preview URL."""
766
+ return assets.list_assets(client, detail=detail)
767
+
768
+ def upload_asset(
769
+ file_path: str | None = None,
770
+ content: str | None = None,
771
+ asset_name: str | None = None,
772
+ shared: bool = False,
773
+ ) -> dict:
774
+ """Put an image, video or audio file into the server's asset
775
+ library and get back the "asset:" reference to use in a workflow.
776
+ Pass exactly one of `file_path` or `content`.
777
+
778
+ `file_path` is read from the machine this MCP server runs on and
779
+ pushed to the engine, so it is how an input reaches a dw.serve
780
+ running somewhere else. When this MCP surface is served by
781
+ dw.serve itself, "this machine" is the engine's own box, so
782
+ `file_path` is confined to the directories it works in - a file
783
+ that exists only on your own machine cannot be named this way.
784
+
785
+ `content` is for exactly that case: the file's bytes, base64-encoded,
786
+ sent inline in the call rather than read off any disk. Use it for a
787
+ voice sample or small image that lives only on the machine you are
788
+ running on, against a remote `dw.serve --mcp` endpoint with no
789
+ filesystem in common with you. Capped at 4MB, well under
790
+ `file_path`'s 200MB, because these bytes ride in the call itself.
791
+ `asset_name` is required with `content`, since there is no file to
792
+ take a name or extension from.
793
+
794
+ Accepts the usual image, video and audio extensions. Reference the
795
+ result rather than a path: a path on this machine means nothing to
796
+ the server. Pass `asset_name` to store it under a readable name
797
+ ("cast/priya-voice.wav", folders allowed) - without one (when using
798
+ `file_path`) the stored name is random, and a set of related inputs
799
+ cannot be told apart in the workflows that carry them. Pass
800
+ `shared=true` to put it in the library every workspace shares
801
+ rather than this session's own - where a recurring cast belongs,
802
+ since a workspace's own assets are invisible from the next
803
+ workspace."""
804
+ return assets.upload_asset(
805
+ client,
806
+ file_path=file_path,
807
+ content=content,
808
+ asset_name=asset_name,
809
+ shared=shared,
810
+ )
811
+
812
+ def keep_output(
813
+ name: str,
814
+ asset_name: str | None = None,
815
+ overwrite: bool = False,
816
+ shared: bool = False,
817
+ workspace: str | None = None,
818
+ ) -> dict:
819
+ """Keep a generated file as an input asset under a stable "asset:"
820
+ name, so later workflows can rely on it - a run's own name moves
821
+ ("latest") or breaks when outputs are pruned. This is the step
822
+ between a render you liked and the next stage that conditions on
823
+ it. `name` is a gallery name; `asset_name` defaults to the file's
824
+ own. The copy happens on the server, inside the workspace: nothing
825
+ is downloaded or re-uploaded. Pass `shared=true` to keep it in the
826
+ library every workspace shares instead - where something a later
827
+ piece in its own workspace has to reach belongs.
828
+
829
+ `workspace` names the workspace for this one call without
830
+ switching the session to it - the same pin `run_workflow`
831
+ takes, so a job run into another workspace is reachable from
832
+ here without leaving this one."""
833
+ return assets.keep_output(
834
+ client,
835
+ name,
836
+ asset_name=asset_name,
837
+ overwrite=overwrite,
838
+ shared=shared,
839
+ workspace=workspace,
840
+ )
841
+
842
+ def delete_asset(name: str) -> dict:
843
+ """Permanently remove one file from the asset library, by the name
844
+ `list_assets` reports (without the "asset:" prefix). Not
845
+ recoverable, and any workflow still carrying that reference stops
846
+ loading. Deletes from whichever library holds it - this
847
+ workspace's own before the shared one, the order an "asset:"
848
+ reference resolves in; one from a read-only examples library is
849
+ refused."""
850
+ return assets.delete_asset(client, name)
851
+
852
+ tool(list_assets, READ_ONLY)
853
+ tool(upload_asset, WRITES)
854
+ tool(keep_output, WRITES)
855
+ tool(delete_asset, DELETES)
856
+
857
+ # ------------------------------------------------------------ workspaces
858
+
859
+ def list_workspaces(detail: bool = False) -> dict:
860
+ """List the server's workspaces and say which one this session is
861
+ working in. Each has its own workflows, assets and outputs; the
862
+ stored prompt library is shared by all of them, and so is the
863
+ shared asset library that `upload_asset(shared=true)` and
864
+ `keep_output(shared=true)` write into - which is how a recurring
865
+ cast stays reachable from the workspace the next piece is made
866
+ in. Entries carry name, default and usage (files/bytes) only; pass
867
+ detail=true for each entry's full folder paths (workflows, assets,
868
+ outputs, prompts, common_assets)."""
869
+ return workspaces.list_workspaces(client, detail=detail)
870
+
871
+ def use_workspace(name: str) -> dict:
872
+ """Work in a different workspace for the rest of this session - every
873
+ later call reads and writes there. Use this to keep your work out of
874
+ another agent's namespace, rather than sharing the default one."""
875
+ return workspaces.use_workspace(client, name)
876
+
877
+ def create_workspace(name: str, use: bool = False) -> dict:
878
+ """Create a workspace on the server. It gets its own workflows,
879
+ assets and outputs and shares the one prompt library. The name is a
880
+ single path segment and cannot be one of the reserved folder names
881
+ (workflows, prompts, assets, outputs, exports, common). Pass use=true to switch this
882
+ session to it as well; otherwise the session stays where it was and
883
+ the result says so."""
884
+ return workspaces.create_workspace(client, name, use=use)
885
+
886
+ def delete_workspace(name: str, acknowledged_cost: bool = False) -> dict:
887
+ """Permanently delete a workspace and every workflow, asset and
888
+ generated file in it. Refuses without acknowledged_cost=True, and
889
+ reports what it would remove instead."""
890
+ return workspaces.delete_workspace(
891
+ client, name, acknowledged_cost=acknowledged_cost
892
+ )
893
+
894
+ tool(list_workspaces, READ_ONLY)
895
+ tool(use_workspace, WRITES)
896
+ tool(create_workspace, WRITES)
897
+ tool(delete_workspace, DELETES)
898
+
899
+ # ----------------------------------------------------------- authoring
900
+
901
+ def validate_workflow(
902
+ workflow: dict | str | None = None,
903
+ name: str | None = None,
904
+ inline_workflow: dict | str | None = None,
905
+ workflow_path: str | None = None,
906
+ workspace: str | None = None,
907
+ arguments: dict | None = None,
908
+ ) -> dict:
909
+ """Check a workflow against the schema and against real pipeline
910
+ signatures. Free and instant - run it before run_workflow.
911
+ Give exactly one of `workflow` or `name` (a stored workflow as
912
+ `list_workflows` reports it); `run_workflow`'s `inline_workflow`
913
+ and `workflow_path` spellings are accepted here too. `workflow` may
914
+ be a JSON-encoded string. Every error comes back at once, each with
915
+ its JSON path. `workspace` pins this one call to another workspace
916
+ without switching the session.
917
+
918
+ A valid answer carries `plan`: what will execute for these
919
+ arguments - `estimate.minutes` and its `basis` (`observed`,
920
+ `per_entry`, `catalog`, `derived`, `other_device` or `unknown` -
921
+ how to quote each is WORKFLOW_GUIDE's "The loop", step 4), each
922
+ `downloads_required` entry as its own cost line, and
923
+ `steps`/`list_entries` for how many members the list produced.
924
+ `plan` is null when it could not be built; the verdict stands.
925
+
926
+ Pass the same `arguments` you will pass to `run_workflow` and they
927
+ are checked too: an undeclared name, a value that will not coerce,
928
+ and an `asset:`/`prompt:`/`output:` reference naming nothing this
929
+ workspace can reach, each at `arguments.<name>`.
930
+ `checked_arguments` says whether your values or only the stored
931
+ defaults were checked. A value outside a bound the workflow
932
+ declares is an error here rather than a failed run; one the workflow rounds up
933
+ comes back as a warning naming what it becomes.
934
+
935
+ Also checked: an unwritable `result.subfolder` or `file_base_name`,
936
+ after `for_each` expansion; and each sub-workflow step - an
937
+ unreachable `workflow.path`, the composed workflow in turn, a
938
+ composition cycle, and (as a warning) an argument passed down that
939
+ it declares no variable for."""
940
+ return authoring.validate_workflow(
941
+ client,
942
+ workflow=workflow,
943
+ name=name,
944
+ inline_workflow=inline_workflow,
945
+ workflow_path=workflow_path,
946
+ workspace=workspace,
947
+ arguments=arguments,
948
+ )
949
+
950
+ def save_workflow(
951
+ name: str,
952
+ workflow: dict | str | None = None,
953
+ patch: dict | str | None = None,
954
+ ) -> dict:
955
+ """Save a workflow to the server's writable workflow directory,
956
+ overwriting any existing workflow of that name there. Validate it
957
+ first. A name that currently resolves to a read-only source (an
958
+ examples directory) is not overwritten - the copy lands in the
959
+ writable directory and shadows it from then on, which is how an
960
+ example gets adapted without being damaged. `name` may include
961
+ folders.
962
+
963
+ Give exactly one of `workflow` (the full document) or `patch` for a
964
+ small, targeted edit: a JSON Merge Patch (RFC 7396) merged onto the
965
+ currently stored definition, so bumping one argument means sending
966
+ just that argument rather than the whole document -
967
+ `{"variables": {"num_images_per_prompt": 4}}` rather than the whole
968
+ workflow. A patch key set to `null` deletes that key from the
969
+ stored document. A list is replaced whole, never merged - a merge
970
+ patch has no notion of list position, so changing one `shots` entry
971
+ still means sending the whole `shots` list. Either may also be
972
+ given as a JSON-encoded string, which is parsed before saving; a
973
+ string that fails to parse is reported as invalid JSON rather than
974
+ as a type mismatch.
975
+
976
+ A workflow stored for reuse should mark each saving step's
977
+ `result.subfolder` - `final` for the step whose output the user will
978
+ be shown, `intermediate` for the rest - so a later consumer can tell
979
+ the deliverable from the scratch files without knowing the workflow."""
980
+ return authoring.save_workflow(client, name, workflow=workflow, patch=patch)
981
+
982
+ def delete_workflow(name: str) -> dict:
983
+ """Permanently delete a stored workflow from this workspace. A
984
+ workflow from a read-only examples directory is refused rather than
985
+ deleted - `list_workflows` reports which those are as
986
+ `writable: false`."""
987
+ return authoring.delete_workflow(client, name)
988
+
989
+ tool(validate_workflow, READ_ONLY)
990
+ tool(save_workflow, OVERWRITES)
991
+ tool(delete_workflow, DELETES)
992
+
993
+ # ------------------------------------------------------------- prompts
994
+
995
+ def list_prompts(
996
+ tag: Optional[str] = None,
997
+ intended_model: Optional[str] = None,
998
+ include_text: bool = False,
999
+ ) -> dict:
1000
+ """List the stored prompts - the worked examples a workflow reaches
1001
+ by writing "prompt:name" or "prompt:folder/name". Each entry carries
1002
+ its `description`, `intended_model`, `tags` and the size of its text;
1003
+ `get_prompt` returns the text itself. This is where the caption a
1004
+ model was trained on is already written out, so read the exemplar
1005
+ for the family you are about to run rather than inventing the
1006
+ format: `intended_model` narrows to one family, as the listing
1007
+ reports it, and `tag` to one label. `include_text=true` returns every body, which for the whole
1008
+ library is more than a client will accept - filter first."""
1009
+ return prompts.list_prompts(
1010
+ client,
1011
+ tag=tag,
1012
+ intended_model=intended_model,
1013
+ include_text=include_text,
1014
+ )
1015
+
1016
+ def get_prompt(name: str) -> dict:
1017
+ """Get one stored prompt's full definition - its text, description,
1018
+ intended model and tags - by a name from `list_prompts`. The prompt
1019
+ library is shared by every workspace on the server."""
1020
+ return prompts.get_prompt(client, name)
1021
+
1022
+ def get_prompt_schema() -> dict:
1023
+ """Get the JSON schema every stored prompt must satisfy. Check this
1024
+ before writing one, as you would get_schema before a workflow."""
1025
+ return prompts.get_prompt_schema(client)
1026
+
1027
+ def save_prompt(name: str, prompt: dict | str) -> dict:
1028
+ """Save a prompt to the library, overwriting any prompt of that
1029
+ name. Its `text` may not itself begin with a reference prefix
1030
+ (variable:, previous_result:, constant:, asset:, output:, prompt:)
1031
+ - the server refuses that to prevent a reference resolving twice.
1032
+ The library is shared by every workspace on this server. `prompt`
1033
+ may also be a JSON-encoded string; a parse failure is reported as
1034
+ invalid JSON, not a type mismatch."""
1035
+ return prompts.save_prompt(client, name, prompt)
1036
+
1037
+ def delete_prompt(name: str) -> dict:
1038
+ """Permanently delete a stored prompt. A workflow that still
1039
+ references it by "prompt:name" will fail to load."""
1040
+ return prompts.delete_prompt(client, name)
1041
+
1042
+ def list_enhancers() -> dict:
1043
+ """List the enhancer presets `enhance_prompt` accepts - one per
1044
+ target model family. Call this before enhance_prompt rather than
1045
+ guessing a preset name."""
1046
+ return prompts.list_enhancers(client)
1047
+
1048
+ def enhance_prompt(
1049
+ idea: str,
1050
+ preset: str = "h3",
1051
+ model_name: str | None = None,
1052
+ device: str | None = None,
1053
+ acknowledged_cost: bool = False,
1054
+ ) -> dict:
1055
+ """Expand a short idea into a full prompt with a language model.
1056
+ This costs time on the engine: it queues a real job, and the engine
1057
+ runs one at a time, so a generation waiting behind it is delayed.
1058
+ Tell the user what will be enhanced and get their go-ahead, then
1059
+ pass acknowledged_cost=true. Returns as soon as the job is queued;
1060
+ the enhanced text is the text file in its finished manifest."""
1061
+ return prompts.enhance_prompt(
1062
+ client,
1063
+ idea,
1064
+ preset=preset,
1065
+ model_name=model_name,
1066
+ device=device,
1067
+ acknowledged_cost=acknowledged_cost,
1068
+ )
1069
+
1070
+ for fn in (list_prompts, get_prompt, get_prompt_schema, list_enhancers):
1071
+ tool(fn, READ_ONLY)
1072
+ tool(save_prompt, OVERWRITES)
1073
+ tool(delete_prompt, DELETES)
1074
+ tool(enhance_prompt, WRITES)
1075
+
1076
+ # ------------------------------------------------------------ diagnose
1077
+
1078
+ def run_workflow(
1079
+ workflow_path: str | None = None,
1080
+ inline_workflow: dict | str | None = None,
1081
+ workflow: dict | str | None = None,
1082
+ name: str | None = None,
1083
+ arguments: dict | None = None,
1084
+ acknowledged_cost: bool | dict = False,
1085
+ workspace: str | None = None,
1086
+ wait_seconds: int = 0,
1087
+ ) -> dict:
1088
+ """Queue a workflow for generation. This costs GPU time: a run
1089
+ occupies the machine for minutes and the engine runs one job at a
1090
+ time. Tell the user what will run and get their go-ahead, then pass
1091
+ acknowledged_cost as below. Returns as soon as the job is queued;
1092
+ follow it with `wait_for_job`, then `get_job` for the manifest - or
1093
+ fold that first wait in with `wait_seconds` above 0, which waits on
1094
+ the job exactly as `wait_for_job(job_id,
1095
+ timeout_seconds=wait_seconds)` would ({cap}s cap per call) and adds
1096
+ its fields to the result (`still_running`, `waited_seconds`,
1097
+ `timeout_*`, the slim `job`). If the cap covers the job's
1098
+ runtime one call is enough; on `still_running: true` call
1099
+ `wait_for_job` again. Give exactly one of `workflow_path` - a
1100
+ catalog name from `list_workflows`, with or without .json, or a
1101
+ path on the server - or `inline_workflow`, a full definition
1102
+ nothing stored covers; `validate_workflow` calls these `name` and
1103
+ `workflow`, and both tools accept both spellings.
1104
+ `inline_workflow`/`workflow` may also be a JSON-encoded string; a
1105
+ parse failure is reported as invalid JSON, not a type mismatch.
1106
+ `arguments` overrides the workflow's variables by name. `workspace` pins this
1107
+ call to another workspace without switching the session (where its
1108
+ `output:`/`asset:` references live).
1109
+
1110
+ Bind the acknowledgement to what you quoted: pass
1111
+ {"fingerprint": plan.fingerprint, "minutes": plan.estimate.minutes,
1112
+ "downloads": [...the non-null repos in plan.downloads_required]} from
1113
+ the validate plan; the server refuses with 409, naming the new
1114
+ plan, if the run's shape changed since. Bare true is for a plan
1115
+ that was null."""
1116
+ return diagnose.run_workflow(
1117
+ client,
1118
+ workflow_path=workflow_path,
1119
+ inline_workflow=inline_workflow,
1120
+ workflow=workflow,
1121
+ name=name,
1122
+ arguments=arguments,
1123
+ acknowledged_cost=acknowledged_cost,
1124
+ workspace=workspace,
1125
+ wait_seconds=wait_seconds,
1126
+ )
1127
+
1128
+ # The cap is a number a caller paces against, so the description
1129
+ # states it (as wait_for_job's does, below). replace rather than
1130
+ # format: the docstring spells out a literal {fingerprint, ...} dict.
1131
+ if run_workflow.__doc__: # absent under python -OO
1132
+ run_workflow.__doc__ = run_workflow.__doc__.replace(
1133
+ "{cap}", str(diagnose.MAX_WAIT_SECONDS)
1134
+ )
1135
+
1136
+ def get_job(job_id: str) -> dict:
1137
+ """Get a job's status, argument warnings, output manifest, error and
1138
+ traceback. The manifest names each step's files the way
1139
+ `get_output_image`, `download_output` and `keep_output` take them,
1140
+ and each entry's `subfolder` says what kind of output the step
1141
+ declared - by convention `final` is the deliverable, `intermediate`
1142
+ the scratch work, and '' a step that said nothing. A step served
1143
+ from the step cache is marked `reused` and reports the earlier run's
1144
+ files. When a job failed, the error and traceback here are what to
1145
+ read before changing anything. `acknowledged` says which form of
1146
+ cost acknowledgement queued the job (`none`, `boolean`, `bound`) and
1147
+ `acknowledged_cost` is the bound `{fingerprint, minutes, downloads}`
1148
+ when there was one."""
1149
+ return diagnose.get_job(client, job_id)
1150
+
1151
+ def get_job_workflow(job_id: str) -> dict:
1152
+ """Get the workflow a job actually ran. When `realized` is true every
1153
+ mutable input is pinned - the caller's arguments folded into the
1154
+ variables, the seed the run used, stored prompt text inlined, and any
1155
+ `output:.../latest/...` rewritten to the run it resolved to - so the
1156
+ definition reproduces that run however the library changes. When it is
1157
+ false the job predates run tracking and this is the definition as
1158
+ submitted. After a long inline run worth keeping, this then
1159
+ `save_workflow` is how it gets a name."""
1160
+ return diagnose.get_job_workflow(client, job_id)
1161
+
1162
+ def get_job_events(
1163
+ job_id: str,
1164
+ after: int = -1,
1165
+ limit: int = 200,
1166
+ kinds: list[str] | None = None,
1167
+ ) -> dict:
1168
+ """Get a page of a job's progress events - phase transitions, denoise
1169
+ steps, memory readings and log lines. `after` is exclusive: pass back
1170
+ the previous call's `last_seq` to continue. Each event's `at` is
1171
+ seconds since the job started, so where a step's time went is the
1172
+ difference between two events. For 'is it still moving?' the
1173
+ `progress` block on get_job/wait_for_job is cheaper than a page of
1174
+ events. `kinds` (e.g. `["log", "warning"]`, or a warning's `kind`)
1175
+ filters the page - `memory` events otherwise dominate it.
1176
+
1177
+ A `kind: "phase_stall"` entry is a watchdog notice, not progress - it
1178
+ fires every ~30s a phase goes quiet, not evidence of a hang by
1179
+ itself. It carries `seconds_since_last_progress` and
1180
+ `seconds_since_phase_start`; some models are silent for minutes
1181
+ normally - check the model's guide before treating one as a fault."""
1182
+ return diagnose.get_job_events(
1183
+ client, job_id, after=after, limit=limit, kinds=kinds
1184
+ )
1185
+
1186
+ def wait_for_job(job_id: str, timeout_seconds: int = 20) -> dict:
1187
+ """Block until a job finishes, instead of polling get_job or
1188
+ get_job_events by hand: returns as soon as its status is succeeded,
1189
+ failed or cancelled, or with still_running: true when
1190
+ timeout_seconds elapses first, so you can call again. Queues
1191
+ nothing, so no acknowledged_cost.
1192
+
1193
+ One call blocks for at most {cap} seconds, whatever timeout_seconds
1194
+ asks for - this deployment's cap, set for the tool-call budget the
1195
+ client holds open; a larger value is clamped, not honoured, so
1196
+ budget one call per {cap}s of the job, and one call is enough when
1197
+ {cap} covers its runtime. Every reply says which happened:
1198
+ waited_seconds, timeout_requested_seconds, timeout_applied_seconds
1199
+ and timeout_capped.
1200
+
1201
+ Returns a slim job - status, warnings, error, the manifest once
1202
+ finished - without the arguments (get_job has those). A running job
1203
+ also carries `progress`: step, phase, and
1204
+ `denoise_step`/`denoise_total_steps`, null until the denoise loop
1205
+ starts. Tell a slow run from a stuck one by whether
1206
+ `denoise_step` has moved since a poll minutes ago, not by silence:
1207
+ a video reference's lead-in can run many minutes emitting nothing,
1208
+ and denoise gaps are uneven under a transformer block cache - both
1209
+ normal. If you're also reading get_job_events, a `phase_stall`
1210
+ entry there is the same silence being narrated, not a fault or a
1211
+ sign of progress - it repeats every ~30s the phase stays quiet, so
1212
+ neither seeing one nor watching its event_count climb tells you
1213
+ anything `denoise_step` doesn't already say better. Full diagnosis,
1214
+ and why `denoise_total_steps` can read one less than asked, in
1215
+ WORKFLOW_GUIDE's "The loop", step 5."""
1216
+ return diagnose.wait_for_job(client, job_id, timeout_seconds=timeout_seconds)
1217
+
1218
+ # The cap is a number a caller paces against, so the description states
1219
+ # it rather than saying "well under a generation's runtime".
1220
+ if wait_for_job.__doc__: # absent under python -OO
1221
+ wait_for_job.__doc__ = wait_for_job.__doc__.format(
1222
+ cap=diagnose.MAX_WAIT_SECONDS
1223
+ )
1224
+
1225
+ def cancel_job(job_id: str) -> dict:
1226
+ """Ask a queued or running job to stop. Cooperative: a running job
1227
+ stops at the next step or denoise-step boundary, not instantly.
1228
+ Deliberately not gated - it ends a cost rather than starting one."""
1229
+ return diagnose.cancel_job(client, job_id)
1230
+
1231
+ def rerun_job(
1232
+ job_id: str, acknowledged_cost: bool | dict = False, new_seed: bool = False
1233
+ ) -> dict:
1234
+ """Queue a fresh job from a previous job's stored specification. This
1235
+ costs GPU time: a rerun is a run - it occupies the machine for
1236
+ minutes and the engine runs one job at a time. Tell the user what
1237
+ will run and get their go-ahead, then pass acknowledged_cost.
1238
+
1239
+ Pass new_seed=true for a different image: a workflow that pins its
1240
+ seed reruns to the same pixels, and the step cache serves that whole
1241
+ run from the earlier one's files (marked `reused`) in a fraction of a
1242
+ second rather than generating anything.
1243
+
1244
+ `acknowledged_cost` takes the same bound form as run_workflow; a
1245
+ fresh seed never changes the fingerprint, so the original plan still
1246
+ binds a new_seed rerun."""
1247
+ return diagnose.rerun_job(
1248
+ client,
1249
+ job_id,
1250
+ acknowledged_cost=acknowledged_cost,
1251
+ new_seed=new_seed,
1252
+ )
1253
+
1254
+ def move_job(
1255
+ job_id: str, direction: Literal["up", "down", "front", "back"]
1256
+ ) -> dict:
1257
+ """Reorder a queued job. Only a job still waiting can move; the one
1258
+ already running cannot."""
1259
+ return diagnose.move_job(client, job_id, direction)
1260
+
1261
+ def export_job(job_id: str, overwrite: bool = False) -> dict:
1262
+ """Gather one finished job into a directory on the server: the
1263
+ realized workflow, the run's manifest, the job row, a README, and
1264
+ copies of every asset it used, every earlier run's file it read and
1265
+ every file it made. The export copies every output and input file
1266
+ rather than linking them, so a video job's export costs its size
1267
+ again on the server's disk; `total_bytes` in the result reports
1268
+ what was copied. Returns the directory, a zip URL, the file list
1269
+ with sizes and the total. The three JSON files are in the zip, not
1270
+ repeated here - get_job_workflow and get_job serve them individually.
1271
+ THE DIRECTORY IS ON THE MACHINE RUNNING THE SERVER, not on yours.
1272
+
1273
+ `auth_required` says whether opening the zip needs this server's
1274
+ bearer token, a token you cannot attach to someone else's browser
1275
+ or tooling. When it is false, fetch open_url yourself and unpack
1276
+ it into exports/ under the session's working directory - it is
1277
+ the user's deliverable, not a temp file; the archive already
1278
+ unpacks into one folder named after the job id, so do not create that folder first.
1279
+ When it is true, do NOT fetch it: hand open_url to the person and let them open it
1280
+ (`next` says whether it is already absolute or needs the server's
1281
+ address told to them). Individual results stay reachable inline
1282
+ via get_output_image/get_output_audio/get_output_frames either
1283
+ way. Refuses a job that is still running; refuses an existing
1284
+ export unless overwrite=true."""
1285
+ return exports.export_job(client, job_id, overwrite=overwrite)
1286
+
1287
+ tool(get_job, READ_ONLY)
1288
+ tool(get_job_workflow, READ_ONLY)
1289
+ tool(get_job_events, READ_ONLY)
1290
+ tool(wait_for_job, READ_ONLY)
1291
+ for fn in (run_workflow, cancel_job, rerun_job, move_job, export_job):
1292
+ tool(fn, WRITES)
1293
+
1294
+ # -------------------------------------------------------------- models
1295
+
1296
+ def download_model(repo_id: str, acknowledged_cost: bool = False) -> dict:
1297
+ """Fetch a model repo into the Hugging Face cache. This costs disk
1298
+ and bandwidth: a model repo is commonly tens of gigabytes. Check
1299
+ list_models first - it may already be cached. Tell the user what you
1300
+ are about to fetch and get their go-ahead, then pass
1301
+ acknowledged_cost=true. Returns as soon as the download starts; poll
1302
+ list_downloads for progress."""
1303
+ return models.download_model(
1304
+ client, repo_id, acknowledged_cost=acknowledged_cost
1305
+ )
1306
+
1307
+ def list_downloads() -> dict:
1308
+ """List model downloads the server is running or recently ran."""
1309
+ return models.list_downloads(client)
1310
+
1311
+ def cancel_download(download_id: str) -> dict:
1312
+ """Ask a running model download to stop. Partial files stay in the
1313
+ cache and resume if it is retried."""
1314
+ return models.cancel_download(client, download_id)
1315
+
1316
+ def delete_model(repo: str, acknowledged_cost: bool = False) -> dict:
1317
+ """Delete every cached revision of one model repo. This is not
1318
+ recoverable: getting the model back means downloading it again. Tell
1319
+ the user which repo and how much it frees, get their go-ahead, then
1320
+ pass acknowledged_cost=true. Refused while a job or download is
1321
+ active."""
1322
+ return models.delete_model(client, repo, acknowledged_cost=acknowledged_cost)
1323
+
1324
+ def get_diffusers_state() -> dict:
1325
+ """Get the installed diffusers version and any update in flight."""
1326
+ return models.get_diffusers_state(client)
1327
+
1328
+ def update_diffusers(acknowledged_cost: bool = False) -> dict:
1329
+ """Upgrade diffusers to GitHub HEAD. This can break the install: it
1330
+ installs an untagged development build that workflows running today
1331
+ may not survive, and this tool cannot undo it. Report the current
1332
+ version, explain why the update is worth it, get the user's
1333
+ go-ahead, then pass acknowledged_cost=true. Refused while a job is
1334
+ running or queued."""
1335
+ return models.update_diffusers(client, acknowledged_cost=acknowledged_cost)
1336
+
1337
+ tool(list_downloads, READ_ONLY)
1338
+ tool(get_diffusers_state, READ_ONLY)
1339
+ for fn in (download_model, cancel_download, update_diffusers):
1340
+ tool(fn, WRITES)
1341
+ tool(delete_model, DELETES)
1342
+
1343
+ return server