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/server/enhancers.py ADDED
@@ -0,0 +1,129 @@
1
+ """Prompt enhancement presets and the inline workflows that run them.
2
+
3
+ The enhancer is not a new execution path - each preset builds a one-step
4
+ inline workflow that the ordinary job queue runs, so progress streaming,
5
+ cancellation, history and the worker's model cache all apply unchanged.
6
+ The step saves its text result, which is how the enhanced prompt comes
7
+ back: as the single file in the job's manifest.
8
+ """
9
+
10
+ import uuid
11
+
12
+ # A generic system prompt for expanding an idea into an image-generation
13
+ # prompt - the counterpart of the H3 preset's Context-IR spec, which lives
14
+ # in the builtin workflow rather than here
15
+ _T2I_SYSTEM_PROMPT = (
16
+ "You expand a user's idea into a single detailed text-to-image prompt. "
17
+ "Describe the subject, setting, lighting, mood, composition and style "
18
+ "concretely, in one flowing paragraph of comma-separated phrases. "
19
+ "Reply with the prompt text only - no preamble, no quotes, no headings."
20
+ )
21
+
22
+ # Each preset names what the UI shows (label, placeholder, curated models)
23
+ # and what the builder needs (which workflow or task runs the enhancement).
24
+ # 'intended_models' are the stored-prompt intended_model values the preset
25
+ # is preselected for
26
+ PRESETS = {
27
+ "h3": {
28
+ "label": "MiniMax-H3 Context-IR",
29
+ "workflow": "builtin:h3_context_ir.json",
30
+ "default_model": "Qwen/Qwen3-4B-Instruct-2507",
31
+ "models": [
32
+ "Qwen/Qwen3-4B-Instruct-2507",
33
+ "Qwen/Qwen2.5-7B-Instruct",
34
+ "Qwen/Qwen2.5-1.5B-Instruct",
35
+ ],
36
+ "intended_models": ["minimax-h3"],
37
+ "placeholder": (
38
+ "Task: T2VA. Duration: 5.17 seconds. "
39
+ "Idea: a red fox trotting through a snowy pine forest at dawn."
40
+ ),
41
+ },
42
+ "t2i": {
43
+ "label": "Text-to-image augmenter",
44
+ "task": "text_generation",
45
+ "system_prompt": _T2I_SYSTEM_PROMPT,
46
+ "default_model": "Qwen/Qwen2.5-1.5B-Instruct",
47
+ "models": [
48
+ "Qwen/Qwen2.5-1.5B-Instruct",
49
+ "Qwen/Qwen3-4B-Instruct-2507",
50
+ ],
51
+ "intended_models": ["z-image", "flux"],
52
+ "placeholder": "a cat portrait in a sunlit window",
53
+ },
54
+ }
55
+
56
+
57
+ def preset_descriptions():
58
+ """The presets as the UI consumes them - everything but how they run."""
59
+ return [
60
+ {
61
+ "key": key,
62
+ "label": preset["label"],
63
+ "default_model": preset["default_model"],
64
+ "models": preset["models"],
65
+ "intended_models": preset["intended_models"],
66
+ "placeholder": preset["placeholder"],
67
+ }
68
+ for key, preset in PRESETS.items()
69
+ ]
70
+
71
+
72
+ def build_enhance_workflow(preset_key, idea, model_name=None, device=None):
73
+ """An inline workflow that enhances one idea with one preset.
74
+
75
+ The workflow id is unique per call: output file names derive from the id,
76
+ so two enhancements never overwrite each other's text in the output
77
+ directory.
78
+
79
+ Args:
80
+ preset_key: Key into PRESETS
81
+ idea: The idea text to expand
82
+ model_name: LLM repo id; the preset's default when omitted
83
+ device: Device override for the language model; the workflow or
84
+ task default (cpu) when omitted
85
+
86
+ Returns:
87
+ The inline workflow definition as a dict
88
+
89
+ Raises:
90
+ ValueError: If the preset is unknown or the idea is empty
91
+ """
92
+ preset = PRESETS.get(preset_key)
93
+ if preset is None:
94
+ raise ValueError(
95
+ f"Unknown enhancer preset '{preset_key}' - one of {sorted(PRESETS)}"
96
+ )
97
+ if not idea or not idea.strip():
98
+ raise ValueError("Provide an idea to enhance")
99
+
100
+ model = model_name or preset["default_model"]
101
+
102
+ if "workflow" in preset:
103
+ arguments = {"prompt": idea, "model_name": model}
104
+ if device:
105
+ arguments["device"] = device
106
+ action = {"workflow": {"path": preset["workflow"], "arguments": arguments}}
107
+ else:
108
+ arguments = {
109
+ "prompt": idea,
110
+ "model_name": model,
111
+ "system_prompt": preset["system_prompt"],
112
+ "max_new_tokens": 400,
113
+ }
114
+ if device:
115
+ arguments["device"] = device
116
+ action = {"task": {"command": preset["task"], "arguments": arguments}}
117
+
118
+ return {
119
+ "id": f"enhance_{uuid.uuid4().hex[:8]}",
120
+ "steps": [
121
+ {
122
+ "name": "enhance",
123
+ **action,
124
+ # The save is the return channel: the builtin's own steps stay
125
+ # save:false, so the manifest holds exactly this text file
126
+ "result": {"content_type": "text/plain", "save": True},
127
+ }
128
+ ],
129
+ }
dw/server/exports.py ADDED
@@ -0,0 +1,480 @@
1
+ """Gathering one finished job into a directory that stands on its own.
2
+
3
+ The record of a run is split across four places on one machine: the job row,
4
+ the run's manifest, the workflow that ran, and the media on either side of it.
5
+ This module puts them in one tree - text at the top, media in folders, no
6
+ absolute paths inside the files - so the run can be committed, moved or handed
7
+ to someone else.
8
+
9
+ exports/<job id>/
10
+ README.md what this is, how it was made, how to run it again
11
+ workflow.json the realized workflow, when the run wrote one
12
+ manifest.json the run's manifest, or a synthesized stand-in
13
+ job.json the job row: status, times, arguments, warnings, error
14
+ assets/ every asset: the workflow names, under its own name
15
+ inputs/ every file an output: reference named, by run
16
+ outputs/ every file the manifest lists
17
+
18
+ Copies are copies, never hard links: exports/ is what a user moves or deletes,
19
+ and a hard link would make deleting it look like deleting the original.
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import re
26
+ import shutil
27
+ from dataclasses import dataclass, field
28
+
29
+ from ..assets import ASSET_PREFIX
30
+ from ..realize import strings_with_prefix
31
+ from ..runs import (
32
+ MANIFEST_FILE_NAME,
33
+ OUTPUT_PREFIX,
34
+ is_output_reference,
35
+ resolve_output_reference,
36
+ )
37
+ from ..security import (
38
+ PathTraversalError,
39
+ SecurityError,
40
+ validate_asset_reference,
41
+ validate_output_path,
42
+ validate_path,
43
+ )
44
+ from ..workspace import EXPORTS_SUBDIR
45
+ from .jobs import TERMINAL_STATES
46
+
47
+ # A job id is one path segment of the manager's making - hex today, but any
48
+ # name without a separator or a leading dot is accepted so history stays
49
+ # readable if the shape ever changes
50
+ JOB_ID_SEGMENT = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
51
+
52
+ logger = logging.getLogger("dw")
53
+
54
+ WORKFLOW_FILE_NAME = "workflow.json"
55
+ JOB_FILE_NAME = "job.json"
56
+ README_FILE_NAME = "README.md"
57
+
58
+ # What job.json holds, in this order, whatever the job was read from. A live
59
+ # job's detail and a historical one's differ in shape - history carries the
60
+ # submitted spec and a 'historical' flag, a live job a traceback and an event
61
+ # count - and an export is a record, so it takes one fixed key set: a
62
+ # traceback is a developer's artifact of one process, an event count
63
+ # describes a log this tree does not hold, and the spec is what
64
+ # workflow.json already is. 'realized' is added beside them
65
+ JOB_RECORD_KEYS = (
66
+ "id",
67
+ "workflow",
68
+ "workflow_name",
69
+ "status",
70
+ "created_at",
71
+ "started_at",
72
+ "finished_at",
73
+ "workspace",
74
+ "arguments",
75
+ "warnings",
76
+ "manifest",
77
+ "error",
78
+ "run_id",
79
+ "run_dir",
80
+ "run_version",
81
+ )
82
+
83
+ README_TEMPLATE = """# {workflow_name} - job {job_id}
84
+
85
+ {realized_sentence}
86
+
87
+ ## The run
88
+
89
+ | | |
90
+ | --- | --- |
91
+ | Job | `{job_id}` |
92
+ | Workflow | `{workflow_name}` |
93
+ | Catalog entry | {catalog_name} |
94
+ | Run | {run} |
95
+ | Status | {status} |
96
+ | Started | {started_at} |
97
+ | Finished | {finished_at} |
98
+ | Device | {device} |
99
+ | Engine | dw {dw_version} |
100
+ | Seed | `{seed}` |
101
+
102
+ Arguments as submitted:
103
+
104
+ ```json
105
+ {arguments}
106
+ ```
107
+
108
+ ## Stored prompts
109
+
110
+ {prompts}
111
+
112
+ ## Sub-workflows
113
+
114
+ {sub_workflows}
115
+
116
+ ## Inputs
117
+
118
+ {inputs}
119
+
120
+ ## Running it again
121
+
122
+ From a checkout of diffusers-workflow, with this directory as the working
123
+ directory:
124
+
125
+ ```bash
126
+ python -m dw.run workflow.json
127
+ ```
128
+
129
+ Over MCP, pass the contents of `workflow.json` to `run_workflow` as
130
+ `inline_workflow`. Either way the `asset:` and `output:` names in it resolve
131
+ against the server's libraries, not against this directory - the copies under
132
+ `assets/` and `inputs/` are the record of what those names meant, not a
133
+ substitute for them.
134
+
135
+ ## Missing
136
+
137
+ {missing}
138
+
139
+ ## A note on committing this
140
+
141
+ `assets/`, `inputs/` and `outputs/` hold generated and source media, which is
142
+ usually large and always binary. If this tree goes into Git, put those three
143
+ directories in Git LFS; the four files at the top are text and belong in Git
144
+ proper.
145
+ """
146
+
147
+
148
+ @dataclass
149
+ class ExportSummary:
150
+ """What an export produced, computed from what actually landed."""
151
+
152
+ job_id: str
153
+ directory: str
154
+ files: list = field(default_factory=list)
155
+ total_bytes: int = 0
156
+ missing: list = field(default_factory=list)
157
+
158
+ def as_dict(self):
159
+ return {
160
+ "job_id": self.job_id,
161
+ "directory": self.directory,
162
+ "files": self.files,
163
+ "total_bytes": self.total_bytes,
164
+ "missing": self.missing,
165
+ }
166
+
167
+
168
+ def export_directory(workspace_root, job_id):
169
+ """Where one job's export lives, confined to the workspace root.
170
+
171
+ The job id is caller input - it arrives in a URL - so it is joined and
172
+ then validated rather than trusted to be the hex string the manager
173
+ generates.
174
+ """
175
+ if not workspace_root:
176
+ raise ValueError("This server has no workspace root to export into")
177
+ # One plain segment, before the join: validate_path accepts a path equal
178
+ # to its root, so '.' or '' would otherwise name the exports root itself
179
+ # and the zip route would archive every export in the workspace
180
+ if not JOB_ID_SEGMENT.fullmatch(job_id or ""):
181
+ raise PathTraversalError(f"Not a job id: {job_id!r}")
182
+ root = validate_output_path(os.path.join(workspace_root, EXPORTS_SUBDIR), None)
183
+ return validate_path(os.path.join(root, job_id), root)
184
+
185
+
186
+ def export_job(manager, job_id, workspace_root, asset_roots, overwrite=False):
187
+ """Gather one finished job into '<workspace_root>/exports/<job id>/'.
188
+
189
+ Args:
190
+ manager: The JobManager holding the job (live or historical).
191
+ job_id: The job to export.
192
+ workspace_root: The workspace the export lands in.
193
+ asset_roots: The workspace's asset search path, in order - the same
194
+ order 'asset:' resolves in, so what is copied is what the run
195
+ loaded.
196
+ overwrite: Replace an existing export rather than refusing.
197
+
198
+ Returns:
199
+ An ExportSummary.
200
+
201
+ Raises:
202
+ ValueError: Unknown job, or a job that has not finished.
203
+ FileExistsError: The export exists and overwrite is False.
204
+ """
205
+ job = manager.get(job_id)
206
+ if job is None:
207
+ raise ValueError(f"Unknown job {job_id}")
208
+ detail = job if isinstance(job, dict) else manager.describe(job)
209
+ status = detail.get("status")
210
+ if status not in TERMINAL_STATES:
211
+ raise ValueError(
212
+ f"Job {job_id} is {status} - only a finished job can be exported"
213
+ )
214
+
215
+ spec = job.get("spec", {}) if isinstance(job, dict) else job.spec
216
+ run_dir = job.get("run_dir") if isinstance(job, dict) else job.run_dir
217
+ output_root = validate_output_path(
218
+ spec.get("output_dir") or manager.output_dir, None
219
+ )
220
+
221
+ target = export_directory(workspace_root, job_id)
222
+ if os.path.exists(target):
223
+ if not overwrite:
224
+ raise FileExistsError(
225
+ f"An export of job {job_id} already exists - pass overwrite to "
226
+ f"replace it"
227
+ )
228
+ shutil.rmtree(target)
229
+ os.makedirs(target)
230
+
231
+ summary = ExportSummary(job_id=job_id, directory=target)
232
+
233
+ realized = manager.realized(job_id)
234
+ workflow = realized if realized is not None else (manager.definition(job_id) or {})
235
+ _write_json(summary, target, WORKFLOW_FILE_NAME, workflow)
236
+
237
+ manifest = _run_manifest(output_root, run_dir)
238
+ if manifest is None:
239
+ # No run directory, or it no longer holds a manifest: the job row's
240
+ # own per-step file list is what is left, and it says so
241
+ manifest = {
242
+ "synthesized": True,
243
+ "run_id": None,
244
+ "status": status,
245
+ "steps": detail.get("manifest") or [],
246
+ }
247
+ _write_json(summary, target, MANIFEST_FILE_NAME, manifest)
248
+
249
+ record = {key: detail.get(key) for key in JOB_RECORD_KEYS}
250
+ record["realized"] = realized is not None
251
+ _write_json(summary, target, JOB_FILE_NAME, record)
252
+
253
+ _copy_assets(summary, workflow, target, asset_roots)
254
+ _copy_inputs(summary, workflow, target, output_root)
255
+ _copy_outputs(summary, manifest, target, output_root, run_dir)
256
+
257
+ readme = _readme(job_id, detail, manifest, workflow, summary, realized is not None)
258
+ _write_text(summary, target, README_FILE_NAME, readme)
259
+ return summary
260
+
261
+
262
+ # -------------------------------------------------------------- the pieces
263
+
264
+
265
+ def _run_manifest(output_root, run_dir):
266
+ """The manifest the run left, or None when there is no reading it."""
267
+ if not run_dir:
268
+ return None
269
+ try:
270
+ path = validate_path(
271
+ os.path.join(output_root, run_dir, MANIFEST_FILE_NAME), output_root
272
+ )
273
+ with open(path, "r") as file:
274
+ return json.load(file)
275
+ except (SecurityError, OSError, ValueError) as e:
276
+ logger.debug(f"No run manifest to export from {run_dir}: {e}")
277
+ return None
278
+
279
+
280
+ def _copy_assets(summary, workflow, target, asset_roots):
281
+ """Every 'asset:' the workflow names, under its own name in assets/."""
282
+ for reference in strings_with_prefix(workflow, ASSET_PREFIX):
283
+ try:
284
+ name = validate_asset_reference(
285
+ reference.removeprefix(ASSET_PREFIX).strip()
286
+ )
287
+ except SecurityError:
288
+ summary.missing.append(reference)
289
+ continue
290
+ source = None
291
+ for root in asset_roots:
292
+ try:
293
+ candidate = validate_path(os.path.join(root, name), root)
294
+ except SecurityError:
295
+ continue
296
+ if os.path.isfile(candidate):
297
+ source = candidate
298
+ break
299
+ if source is None:
300
+ summary.missing.append(reference)
301
+ continue
302
+ _copy(
303
+ summary, source, target, os.path.join("assets", *name.split("/")), reference
304
+ )
305
+
306
+
307
+ def _copy_inputs(summary, workflow, target, output_root):
308
+ """Every 'output:' the workflow names, kept under the run it came from.
309
+
310
+ The reference itself is not rewritten - the realized workflow is the
311
+ immutable record of the run - so the directory name is the reference's
312
+ own name, and the README says where each one came from.
313
+ """
314
+ for reference in strings_with_prefix(workflow, OUTPUT_PREFIX):
315
+ if not is_output_reference(reference):
316
+ continue
317
+ name = reference.removeprefix(OUTPUT_PREFIX).strip()
318
+ try:
319
+ source = resolve_output_reference(reference, output_root)
320
+ except (SecurityError, OSError, ValueError):
321
+ summary.missing.append(reference)
322
+ continue
323
+ _copy(
324
+ summary, source, target, os.path.join("inputs", *name.split("/")), reference
325
+ )
326
+
327
+
328
+ def _copy_outputs(summary, manifest, target, output_root, run_dir):
329
+ """Every file the manifest lists, under outputs/.
330
+
331
+ A manifest entry names a file relative to the run directory when the run
332
+ wrote it, and absolutely when a step-cache hit republished an earlier
333
+ run's file. Both land here: the first under its own relative name, the
334
+ second under its path relative to the output root, which keeps the
335
+ identity and run id that say where it really came from.
336
+ """
337
+ run_root = os.path.join(output_root, run_dir) if run_dir else output_root
338
+ # One file can be listed by two steps (a chain's output is the next
339
+ # step's input); it is one file in the export, copied and counted once
340
+ copied = set()
341
+ for entry in manifest.get("steps") or []:
342
+ if not isinstance(entry, dict):
343
+ continue
344
+ for recorded in entry.get("files") or []:
345
+ source = (
346
+ recorded
347
+ if os.path.isabs(recorded)
348
+ else os.path.join(run_root, recorded)
349
+ )
350
+ try:
351
+ source = validate_path(source, output_root)
352
+ except SecurityError:
353
+ summary.missing.append(recorded)
354
+ continue
355
+ if not os.path.isfile(source):
356
+ summary.missing.append(recorded)
357
+ continue
358
+ if source in copied:
359
+ continue
360
+ copied.add(source)
361
+ try:
362
+ relative = os.path.relpath(source, run_root)
363
+ except ValueError: # different drive on Windows
364
+ relative = os.path.basename(source)
365
+ if relative == os.pardir or relative.startswith(os.pardir + os.sep):
366
+ relative = os.path.relpath(source, output_root)
367
+ _copy(
368
+ summary,
369
+ source,
370
+ target,
371
+ os.path.join("outputs", *relative.split(os.sep)),
372
+ recorded,
373
+ )
374
+
375
+
376
+ def _readme(job_id, detail, manifest, workflow, summary, realized):
377
+ workflow_block = manifest.get("workflow") or {}
378
+ prompts = workflow_block.get("prompts") or []
379
+ sub_workflows = workflow_block.get("sub_workflows") or {}
380
+ inputs = [
381
+ entry["path"] for entry in summary.files if entry["path"].startswith("inputs/")
382
+ ]
383
+ return README_TEMPLATE.format(
384
+ job_id=job_id,
385
+ workflow_name=detail.get("workflow") or "unknown",
386
+ catalog_name=(
387
+ f"`{detail['workflow_name']}`"
388
+ if detail.get("workflow_name")
389
+ else "none - an inline definition"
390
+ ),
391
+ realized_sentence=(
392
+ "`workflow.json` is the *realized* workflow: every mutable input "
393
+ "is pinned, so it reproduces this run whatever changes afterwards."
394
+ if realized
395
+ else "`workflow.json` is the definition as submitted - this job "
396
+ "predates run tracking, so its arguments and prompts are not "
397
+ "pinned into it."
398
+ ),
399
+ run=(
400
+ f"`{detail['run_id']}`"
401
+ + (
402
+ f" - version {manifest['version']}"
403
+ if isinstance(manifest.get("version"), int)
404
+ else ""
405
+ )
406
+ if detail.get("run_id")
407
+ else "not recorded"
408
+ ),
409
+ status=detail.get("status"),
410
+ started_at=detail.get("started_at"),
411
+ finished_at=detail.get("finished_at"),
412
+ device=manifest.get("device", "unknown"),
413
+ dw_version=manifest.get("dw_version", "unknown"),
414
+ seed=manifest.get("seed", workflow.get("seed", "not recorded")),
415
+ arguments=json.dumps(detail.get("arguments") or {}, indent=2),
416
+ prompts=_bullets(f"`{name}` - inlined into `workflow.json`" for name in prompts)
417
+ or "None: this workflow named no stored prompt.",
418
+ sub_workflows=_bullets(
419
+ f"`{path}` - sha256 `{digest}`" if digest else f"`{path}` - unreadable"
420
+ for path, digest in sorted(sub_workflows.items())
421
+ )
422
+ or "None: this workflow composed no other workflow by path.",
423
+ inputs=_bullets(
424
+ f"`{path}` - copied from the run named in its own path" for path in inputs
425
+ )
426
+ or "None: this workflow named no file from an earlier run.",
427
+ missing=_bullets(f"`{name}`" for name in summary.missing)
428
+ or "Nothing: every file this run referenced was found and copied.",
429
+ )
430
+
431
+
432
+ def _bullets(lines):
433
+ return "\n".join(f"- {line}" for line in lines)
434
+
435
+
436
+ # ------------------------------------------------------------------- files
437
+
438
+
439
+ def _copy(summary, source, target, relative, name):
440
+ """Copy one file into the export and record it.
441
+
442
+ `name` is how a failure is reported - the reference or the manifest's own
443
+ entry, never the absolute path, because `missing` is read out of the
444
+ README on another machine where that path means nothing.
445
+ """
446
+ destination = os.path.join(target, relative)
447
+ try:
448
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
449
+ shutil.copyfile(source, destination)
450
+ except OSError as e:
451
+ logger.warning(f"Could not copy {source} into the export: {e}")
452
+ summary.missing.append(name)
453
+ return
454
+ _record(summary, destination, target)
455
+
456
+
457
+ def _write_json(summary, target, name, payload):
458
+ _write_text(summary, target, name, json.dumps(payload, indent=2, default=str))
459
+
460
+
461
+ def _write_text(summary, target, name, text):
462
+ path = os.path.join(target, name)
463
+ try:
464
+ with open(path, "w", encoding="utf-8") as file:
465
+ file.write(text)
466
+ except OSError as e:
467
+ logger.warning(f"Could not write {path}: {e}")
468
+ return
469
+ _record(summary, path, target)
470
+
471
+
472
+ def _record(summary, path, target):
473
+ try:
474
+ size = os.path.getsize(path)
475
+ except OSError:
476
+ return
477
+ summary.files.append(
478
+ {"path": os.path.relpath(path, target).replace(os.sep, "/"), "bytes": size}
479
+ )
480
+ summary.total_bytes += size