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/elision.py ADDED
@@ -0,0 +1,306 @@
1
+ """A step nothing references, and which saves nothing, does not run.
2
+
3
+ `templates/minimax/dialogue-short` draws its two characters with Z-Image and
4
+ references those portraits from every shot. An episode can just as well be
5
+ cast from portraits that already exist - a shot entry's subject reference
6
+ takes `from_file: "asset:cast/priya.jpg"` exactly as its voice references do -
7
+ and then the two draw steps still ran and their output was discarded: roughly
8
+ 55 seconds and two model loads on portraits nothing in the run looked at
9
+ (#109, #122). The recurring cast is the headline use of that template, so
10
+ paying for it every episode was the wrong default, and no argument the caller
11
+ could pass avoided it.
12
+
13
+ So: before the first step executes, drop any step whose result no later step
14
+ reads and which writes no file. It is static - it depends only on what the
15
+ realized workflow references, never on a value produced during the run, which
16
+ is what keeps it a different thing from conditional execution (#118 closed the
17
+ step object specifically so an invented `when` is a hard error).
18
+
19
+ Four guardrails, all of them load-bearing:
20
+
21
+ - **A step that saves is kept.** `save` defaults to true, so a step declaring
22
+ a `result` is a deliverable unless it says otherwise - a workflow whose
23
+ whole point is writing three images references nothing.
24
+ - **The last step is kept**, whatever it declares: it is the run's answer.
25
+ - **A release moves rather than disappearing.** `release_pipeline` frees the
26
+ memory the step it sits on took; eliding it silently would leak that for
27
+ the rest of the run. It moves onto the last surviving step before it when
28
+ that step loaded the same pipeline, and is dropped when nothing was loaded
29
+ to release.
30
+ - **Every elision is a warning.** Without one a misspelled reference makes
31
+ the step feeding it vanish and the failure moves from "previous result not
32
+ found" to "the picture is wrong". The static reference check refuses an
33
+ unresolvable literal reference, which contains most of it - the warning is
34
+ what covers the rest. The warning says which of the two happened: a step
35
+ the *written* definition reads through a variable the caller then
36
+ overrode was elided on purpose - it is `music-video`'s supplied singer
37
+ portrait, the saving the feature exists for - and telling that caller
38
+ their reference is probably misspelled puts a false positive on the
39
+ documented path, which is how the warnings channel stops being read
40
+ (#157). A step nothing ever read still gets the diagnosis.
41
+
42
+ Elision is transitive: dropping a step can leave the step it read
43
+ unreferenced in turn, so it runs to a fixed point.
44
+ """
45
+
46
+ import logging
47
+
48
+ from .step_cache import reference_resolves_to, referenced_result_names
49
+
50
+ VARIABLE_PREFIX = "variable:"
51
+
52
+ logger = logging.getLogger("dw")
53
+
54
+
55
+ def _saves(step):
56
+ """Whether the step writes a file.
57
+
58
+ Exactly what `Result.save` asks: a `content_type` to write, and `save`
59
+ not turned off. `save` defaults to true, so declaring a `result` with a
60
+ content type is declaring a deliverable - and a step with no `result` at
61
+ all writes nothing, whatever it generates, which is what makes the
62
+ portrait steps droppable in the first place.
63
+
64
+ A composing step is the one exception: its child workflow saves on its
65
+ own terms, so the parent's `result` block (or its absence) says nothing
66
+ about whether files are written. It is kept.
67
+ """
68
+ if "workflow" in step:
69
+ return True
70
+ result = step.get("result")
71
+ if not isinstance(result, dict):
72
+ return False
73
+ if result.get("content_type") is None:
74
+ return False
75
+ return result.get("save", True) is not False
76
+
77
+
78
+ def _reused_component_names(steps):
79
+ """Every component name a later step asks an earlier one to have shared.
80
+
81
+ Component sharing is keyed on the component's name rather than on the
82
+ step's, so the step that shares is referenced without ever being named.
83
+ """
84
+ names = set()
85
+ for step in steps:
86
+ pipeline = step.get("pipeline")
87
+ if isinstance(pipeline, dict):
88
+ for name in pipeline.get("reused_components") or []:
89
+ names.add(name)
90
+ return names
91
+
92
+
93
+ def _referenced_pipeline_steps(steps):
94
+ """Every step name a later `pipeline_reference` addresses."""
95
+ names = set()
96
+ for step in steps:
97
+ reference = step.get("pipeline_reference")
98
+ if isinstance(reference, dict):
99
+ name = reference.get("reference_name")
100
+ if isinstance(name, str):
101
+ names.add(name)
102
+ return names
103
+
104
+
105
+ def _needed_by(step, later):
106
+ """Whether any of `later` reads this step - by result, by pipeline, or by
107
+ a component it shares."""
108
+ name = step.get("name")
109
+ if not isinstance(name, str):
110
+ return True
111
+
112
+ if any(reference_resolves_to(ref, name) for ref in referenced_result_names(later)):
113
+ return True
114
+ if name in _referenced_pipeline_steps(later):
115
+ return True
116
+
117
+ pipeline = step.get("pipeline")
118
+ shared = set((pipeline or {}).get("shared_components") or [])
119
+ return bool(shared & _reused_component_names(later))
120
+
121
+
122
+ def _carry_release(elided, kept):
123
+ """Move an elided step's release onto the last surviving step before it.
124
+
125
+ `release_pipeline` names the pipeline the elided step itself loaded, so
126
+ it is only meaningful on an earlier step that loaded the same one -
127
+ moving it anywhere else would unload something the workflow did not ask
128
+ to unload. `release_models` frees the process-wide task-model cache, so
129
+ the last step that ran before this one is exactly where it belongs. With
130
+ nothing before it, nothing was loaded and the flag is dropped.
131
+ """
132
+ from .workflow import pipeline_cache_key
133
+
134
+ if not kept:
135
+ return False
136
+ predecessor = kept[-1]
137
+ carried = False
138
+ if elided.get("release_models"):
139
+ predecessor["release_models"] = True
140
+ carried = True
141
+ if not elided.get("release_pipeline"):
142
+ return carried
143
+ elided_pipeline = elided.get("pipeline")
144
+ kept_pipeline = predecessor.get("pipeline")
145
+ if not elided_pipeline or not kept_pipeline:
146
+ return carried
147
+ if pipeline_cache_key(elided_pipeline) == pipeline_cache_key(kept_pipeline):
148
+ predecessor["release_pipeline"] = True
149
+ carried = True
150
+ return carried
151
+
152
+
153
+ def overriding_variables(written, substituted_steps):
154
+ """{step name: variable name} for every step the workflow as *written*
155
+ reads only through a variable whose value no longer names it.
156
+
157
+ `music-video` declares `singer_reference` as
158
+ `{"from_previous_result": "draw_singer"}` and every shot references the
159
+ variable; a caller who passes a portrait instead replaces that value, and
160
+ `draw_singer` becomes unreferenced *because they said so*. Comparing the
161
+ two definitions is what tells that apart from a misspelling, and it costs
162
+ one walk of the variables block (#157).
163
+
164
+ `written` is the definition as the author wrote it - before substitution,
165
+ so the defaults are still there; `substituted_steps` is the expanded step
166
+ list elision is about to judge.
167
+ """
168
+ variables = (written or {}).get("variables")
169
+ if not isinstance(variables, dict):
170
+ return {}
171
+ written_steps = (written or {}).get("steps")
172
+ if not isinstance(written_steps, list):
173
+ return {}
174
+ still_read = referenced_result_names(substituted_steps)
175
+ overridden = {}
176
+ for variable, value in variables.items():
177
+ # A variable no step reads is not how the step was reached, so its
178
+ # disappearance is nobody's decision - the general diagnosis is
179
+ # right for that one
180
+ if not _reads_variable(written_steps, variable):
181
+ continue
182
+ for name in referenced_result_names([value]):
183
+ if any(reference_resolves_to(read, name) for read in still_read):
184
+ continue
185
+ overridden.setdefault(name, variable)
186
+ return overridden
187
+
188
+
189
+ def _reads_variable(tree, name):
190
+ """Whether anything in `tree` references 'variable:<name>'."""
191
+ reference = VARIABLE_PREFIX + name
192
+ if isinstance(tree, str):
193
+ return tree == reference
194
+ if isinstance(tree, dict):
195
+ return any(_reads_variable(value, name) for value in tree.values())
196
+ if isinstance(tree, list):
197
+ return any(_reads_variable(value, name) for value in tree)
198
+ return False
199
+
200
+
201
+ def elide_unreferenced_steps(steps, overridden=None):
202
+ """The steps that will run, and what was dropped.
203
+
204
+ Returns (kept, elided) where `elided` is
205
+ [{'step': name, 'reason': str, 'overridden_by': variable | absent}],
206
+ in the order the steps were written. `overridden` is
207
+ `overriding_variables`' answer: a step named there was dropped because a
208
+ caller replaced the reference that read it, which is a different event
209
+ from a step nothing reads by accident, and is reported as one. `steps` is the expanded, substituted
210
+ list - a `for_each` member is a step like any other by then, and `gather:`
211
+ has already become the `previous_result:` list it stands for.
212
+
213
+ The list is not copied: a kept step is the same object that went in, and
214
+ a release carried onto a survivor is written into it.
215
+ """
216
+ if not isinstance(steps, list) or len(steps) < 2:
217
+ return steps, []
218
+
219
+ kept = list(steps)
220
+ elided = []
221
+ changed = True
222
+ while changed:
223
+ changed = False
224
+ for index, step in enumerate(kept):
225
+ if index == len(kept) - 1:
226
+ # The run's answer, whatever it declares
227
+ continue
228
+ if not isinstance(step, dict) or _saves(step):
229
+ continue
230
+ if _needed_by(step, kept[index + 1 :]):
231
+ continue
232
+ variable = (overridden or {}).get(step.get("name"))
233
+ reason = (
234
+ f"'{variable}' was supplied, so nothing reads its result"
235
+ if variable
236
+ else "nothing after it reads its result and it saves no file"
237
+ )
238
+ if _carry_release(step, kept[:index]):
239
+ reason += "; its release was carried onto the step before it"
240
+ entry = {"step": step.get("name"), "reason": reason}
241
+ if variable:
242
+ entry["overridden_by"] = variable
243
+ elided.append(entry)
244
+ del kept[index]
245
+ changed = True
246
+ break
247
+
248
+ # Written order, not the order the fixed point happened to reach them in
249
+ order = {
250
+ step.get("name"): index
251
+ for index, step in enumerate(steps)
252
+ if isinstance(step, dict)
253
+ }
254
+ elided.sort(key=lambda entry: order.get(entry["step"], 0))
255
+ return kept, elided
256
+
257
+
258
+ def elide_definition(workflow_def, written=None):
259
+ """elide_unreferenced_steps over a whole definition, in place.
260
+
261
+ Returns the elided-step records; the definition's `steps` is replaced
262
+ when anything was dropped, so a caller that wants the untouched list must
263
+ keep its own copy. `written` is the same workflow before substitution,
264
+ which is what distinguishes a step a caller deliberately replaced from
265
+ one nothing reads by accident (#157); without it every elision reads as
266
+ the second.
267
+ """
268
+ steps = workflow_def.get("steps")
269
+ overridden = (
270
+ overriding_variables(written, steps if isinstance(steps, list) else [])
271
+ if written
272
+ else None
273
+ )
274
+ kept, elided = elide_unreferenced_steps(steps, overridden)
275
+ if elided:
276
+ workflow_def["steps"] = kept
277
+ return elided
278
+
279
+
280
+ def warn_elided(elided):
281
+ """Say what did not run, where whoever asked for the run can read it.
282
+
283
+ A warning rather than a log line for the reason every run-time warning is
284
+ one: a consumer over the API or MCP sees the job's `warnings` list and
285
+ nothing else (#82), and "the portrait step was skipped" is precisely the
286
+ thing that explains an otherwise inexplicable result.
287
+ """
288
+ from .events import emit_warning
289
+
290
+ for entry in elided:
291
+ # A step whose reference the caller replaced was elided on purpose -
292
+ # say what was saved, not what might be wrong with it (#157)
293
+ advice = (
294
+ ""
295
+ if entry.get("overridden_by")
296
+ else (
297
+ " If it was meant to, either a later step's reference to it "
298
+ "is misspelled or it needs a 'result' to save."
299
+ )
300
+ )
301
+ emit_warning(
302
+ f"Step '{entry['step']}' did not run: {entry['reason']}.{advice}",
303
+ kind="step_elided",
304
+ step=entry["step"],
305
+ overridden_by=entry.get("overridden_by"),
306
+ )
dw/events.py ADDED
@@ -0,0 +1,275 @@
1
+ """Progress reporting and cooperative cancellation for workflow runs.
2
+
3
+ A RunContext travels ambiently (contextvars) through a run: Workflow.run
4
+ activates it, and Step/Pipeline reach it with get_context() rather than
5
+ threading a parameter through every action signature. Callers that never
6
+ pass a context get a no-op one, so the CLI path pays nothing.
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ import contextvars
13
+
14
+ logger = logging.getLogger("dw")
15
+
16
+ # How long a phase may go without any event before the watchdog speaks up,
17
+ # and (since emitting a stall event itself counts as an event - see
18
+ # RunContext._watchdog_loop) the cadence it repeats on while the stall
19
+ # continues. 30s is well past the 7-10s lead-in a healthy step-callback
20
+ # pipeline shows before its first `pipeline_step` (see
21
+ # docs/proposals/step-callback-lead-in-instrumentation.md) but short enough
22
+ # that a genuine stall is visible long before a human would give up on it.
23
+ # The report goes to the event log only - the job's persisted `warnings`
24
+ # list filters `kind == "phase_stall"` (dw/server/jobs.py), because a stall
25
+ # that resolved is not a warning about the result.
26
+ PHASE_STALL_THRESHOLD_SECONDS = 30.0
27
+ # How often the watchdog thread wakes to check - independent of the
28
+ # threshold above, just fine-grained enough that the reported
29
+ # seconds_since_phase_start doesn't overshoot by much.
30
+ PHASE_STALL_CHECK_INTERVAL_SECONDS = 5.0
31
+
32
+
33
+ class WorkflowCancelled(Exception):
34
+ """Raised inside a run when its RunContext has been cancelled."""
35
+
36
+
37
+ class RunContext:
38
+ """Carries the event sink and the cancellation flag for one workflow run.
39
+
40
+ cancel() may be called from any thread (the worker's command watcher);
41
+ everything else runs on the thread executing the workflow. The
42
+ phase-stall watchdog (_watchdog_loop) is the same story - it reads
43
+ _current_phase/_last_event_at from its own background thread with no
44
+ lock, which is fine for simple attribute reads/writes under the GIL.
45
+ """
46
+
47
+ def __init__(self, on_event=None):
48
+ self._on_event = on_event
49
+ self._cancel = threading.Event()
50
+ # The most recent phase this run reported (see emit_phase/PHASES) -
51
+ # cancel() reads it to tell a client whether the cancel takes effect
52
+ # right away or has to wait out an in-flight, non-interruptible phase
53
+ self._current_phase = None
54
+ # Pipeline cache keys this run resolved - the worker evicts entries a
55
+ # run no longer touches, so an edited workflow drops stale models
56
+ self.touched_pipelines = set()
57
+ # Watchdog state. monotonic, not wall clock, since a stall is
58
+ # measured in elapsed time, not affected by clock adjustments.
59
+ self._last_event_at = time.monotonic()
60
+ self._phase_started_at = time.monotonic()
61
+ # Last *real* event - anything but the watchdog's own phase_stall
62
+ # report - and what kind it was. Tracked separately from
63
+ # _last_event_at (which the stall report itself also bumps, to drive
64
+ # its own repeat cadence) so "how long has it actually been quiet"
65
+ # doesn't reset every time the watchdog speaks.
66
+ self._last_progress_at = self._last_event_at
67
+ self._last_progress_kind = None
68
+ # Reference-counted: a sub-workflow runs inside its parent's
69
+ # RunContext (Workflow.run reuses the ambient one), so the watchdog
70
+ # starts on the outermost run() and stops on the outermost's exit,
71
+ # not on every nested one
72
+ self._run_depth = 0
73
+ self._watchdog_thread = None
74
+ self._watchdog_stop = threading.Event()
75
+
76
+ def emit(self, event_type, counts_as_progress=True, **data):
77
+ """Send one event to the sink. counts_as_progress=False is for a
78
+ status report that says nothing has moved - a download reporting
79
+ zero new bytes (#343): the caller should see it, but the stall
80
+ watchdog must still see the silence, so it bumps neither clock."""
81
+ now = time.monotonic()
82
+ if counts_as_progress:
83
+ self._last_event_at = now
84
+ if not (event_type == "warning" and data.get("kind") == "phase_stall"):
85
+ self._last_progress_at = now
86
+ self._last_progress_kind = event_type
87
+ if self._on_event is None:
88
+ return
89
+ try:
90
+ self._on_event({"event": event_type, **data})
91
+ except Exception as e:
92
+ # A broken sink must not kill a run that is otherwise fine
93
+ logger.warning(f"Progress event sink failed on '{event_type}': {e}")
94
+
95
+ def note_phase(self, phase):
96
+ """Record the run's latest phase - called by emit_phase, read by
97
+ cancel() to judge whether the current phase can be interrupted, and
98
+ by the watchdog to time how long the current phase has run."""
99
+ self._current_phase = phase
100
+ self._phase_started_at = time.monotonic()
101
+
102
+ def enter_run(self):
103
+ """Called around Workflow.run - starts the watchdog on the
104
+ outermost call, a no-op on a nested (sub-workflow) one."""
105
+ self._run_depth += 1
106
+ if self._run_depth == 1:
107
+ self._watchdog_stop.clear()
108
+ self._watchdog_thread = threading.Thread(
109
+ target=self._watchdog_loop, daemon=True, name="dw-phase-stall"
110
+ )
111
+ self._watchdog_thread.start()
112
+
113
+ def exit_run(self):
114
+ """Matching exit for enter_run - stops the watchdog once the
115
+ outermost run() has left, whether it succeeded or raised."""
116
+ self._run_depth -= 1
117
+ if self._run_depth == 0 and self._watchdog_thread is not None:
118
+ self._watchdog_stop.set()
119
+ self._watchdog_thread.join(timeout=PHASE_STALL_CHECK_INTERVAL_SECONDS * 2)
120
+ self._watchdog_thread = None
121
+
122
+ def _watchdog_loop(self):
123
+ """Generic, pipeline-agnostic: this only ever looks at 'how long
124
+ since any event' and 'what phase are we in', never at what a
125
+ particular pipeline does inside a phase (see
126
+ docs/proposals/step-callback-lead-in-instrumentation.md, Option A).
127
+ A stall report is itself an event, so it bumps _last_event_at and
128
+ naturally repeats on PHASE_STALL_THRESHOLD_SECONDS while the silence
129
+ continues and stops the moment a real progress event arrives -
130
+ but it deliberately does not bump _last_progress_at, so the
131
+ seconds_since_last_progress it reports keeps climbing across
132
+ repeats rather than resetting itself every 30s (#357). It is a
133
+ watchdog notice, not progress: a caller polling events should not
134
+ read a run of these, or a climbing event_count made of them, as
135
+ liveness.
136
+ """
137
+ while not self._watchdog_stop.wait(PHASE_STALL_CHECK_INTERVAL_SECONDS):
138
+ phase = self._current_phase
139
+ if phase is None:
140
+ continue
141
+ now = time.monotonic()
142
+ if now - self._last_event_at < PHASE_STALL_THRESHOLD_SECONDS:
143
+ continue
144
+ seconds_since_phase_start = round(now - self._phase_started_at, 1)
145
+ seconds_since_last_progress = round(now - self._last_progress_at, 1)
146
+ last_kind = self._last_progress_kind or "phase start"
147
+ last_offset = round(
148
+ max(0.0, self._last_progress_at - self._phase_started_at), 1
149
+ )
150
+ message = (
151
+ f"no progress event for {seconds_since_last_progress:.1f}s in phase "
152
+ f"'{phase}' (last: {last_kind} at +{last_offset:.1f}s) - informational; "
153
+ "long silent stretches are normal for some models, see the model's skill"
154
+ )
155
+ logger.warning(message)
156
+ self.emit(
157
+ "warning",
158
+ message=message,
159
+ kind="phase_stall",
160
+ phase=phase,
161
+ seconds_since_phase_start=seconds_since_phase_start,
162
+ seconds_since_last_progress=seconds_since_last_progress,
163
+ )
164
+
165
+ def cancel(self):
166
+ self._cancel.set()
167
+ if self._current_phase in NON_INTERRUPTIBLE_PHASES:
168
+ # A model load or a task step has no checkpoint to catch this
169
+ # flag until it finishes - say so, or the client sees the cancel
170
+ # request go silent for however long that takes
171
+ self.emit("cancel_pending", phase=self._current_phase)
172
+
173
+ @property
174
+ def cancelled(self):
175
+ return self._cancel.is_set()
176
+
177
+ def check_cancelled(self):
178
+ if self._cancel.is_set():
179
+ raise WorkflowCancelled("Workflow run was cancelled")
180
+
181
+ def touch_pipeline(self, cache_key):
182
+ self.touched_pipelines.add(cache_key)
183
+
184
+
185
+ _active_context = contextvars.ContextVar("dw_run_context", default=None)
186
+
187
+
188
+ def get_context():
189
+ """The active run's context, or a no-op one outside any run."""
190
+ context = _active_context.get()
191
+ return context if context is not None else RunContext()
192
+
193
+
194
+ def current_context():
195
+ """The active run's context, or None outside any run."""
196
+ return _active_context.get()
197
+
198
+
199
+ def activate_context(context):
200
+ """Make a context the active one; returns a token for deactivate_context."""
201
+ return _active_context.set(context)
202
+
203
+
204
+ def deactivate_context(token):
205
+ _active_context.reset(token)
206
+
207
+
208
+ # The coarse states a run passes through. Small on purpose: a phase says what
209
+ # the run is waiting on, not what any one library is doing internally
210
+ PHASES = ("loading", "cached", "generating", "decoding", "saving", "task")
211
+
212
+ # Phases with no checkpoint of their own: nothing inside a from_pretrained()
213
+ # call or a task handler consults the cancel flag, so a cancel requested
214
+ # during one of these can only take effect once the phase finishes on its own
215
+ NON_INTERRUPTIBLE_PHASES = ("loading", "task")
216
+
217
+
218
+ def select_kinds(events, kinds):
219
+ """The events whose `event` or `kind` is one of `kinds`, in order.
220
+
221
+ Both, because a consumer names what it sees: the bookkeeping events are
222
+ told apart by `event` (`log`, `warning`, `memory`), but a warning's
223
+ own type - `phase_stall`, `audio_clipped` - is its `kind`, and matching
224
+ `event` alone quietly returned an empty page for those. No `kinds`
225
+ means no filter."""
226
+ if not kinds:
227
+ return list(events)
228
+ allowed = set(kinds)
229
+ return [e for e in events if e.get("event") in allowed or e.get("kind") in allowed]
230
+
231
+
232
+ def emit_warning(message, **data):
233
+ """Report something the run's result carries but its status will not.
234
+
235
+ A warning a step discovers at run time - shots being cut together 10 dB
236
+ apart, a video about to be written at a frame rate nothing chose - is
237
+ only useful where whoever asked for the run can read it. The server's
238
+ own log is not that place: a consumer over the API or MCP sees the event
239
+ stream and the job's `warnings` list and nothing else, so a diagnostic
240
+ that only reaches the log does not exist out there (#82).
241
+
242
+ Logged as well as emitted, because the CLI and the REPL have no event
243
+ sink and the log is the whole of their surface.
244
+ """
245
+ logger.warning(message)
246
+ get_context().emit("warning", message=message, **data)
247
+
248
+
249
+ def emit_phase(phase, detail=None):
250
+ """Report a coarse phase change on the active run.
251
+
252
+ A step spends most of its wall clock outside the denoise loop - pulling
253
+ weights, decoding latents, encoding video - and a step counter says
254
+ nothing about any of that. These are the rest of the story. They are rare
255
+ enough (a handful per step) to carry a free-text detail alongside, which
256
+ is what makes 'loading' readable as 'which model'.
257
+ """
258
+ context = get_context()
259
+ context.note_phase(phase)
260
+ context.emit("phase", phase=phase, detail=detail)
261
+
262
+
263
+ def emit_log(message, **data):
264
+ """Narrate one step of a long, otherwise silent stretch of a run.
265
+
266
+ A `log` event rather than a phase: `PHASES` is a closed set a consumer
267
+ switches on, and "which file is being written" is a detail inside one of
268
+ them, not a new state. The modular block lead-in (#95) is the same shape
269
+ at the other end of a step.
270
+
271
+ Logged as well as emitted, because the CLI and the REPL have no event
272
+ sink and the log is the whole of their surface.
273
+ """
274
+ logger.info(message)
275
+ get_context().emit("log", message=message, **data)