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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """MCP server for diffusers-workflow.
2
+
3
+ A stdio MCP server that is an HTTP client of a running `dw.serve`. It owns
4
+ no job state and no GPU worker - every tool is a call against the REST API
5
+ that the web UI already uses.
6
+ """
dw_mcp/__main__.py ADDED
@@ -0,0 +1,133 @@
1
+ """`python -m dw_mcp` / `dw-mcp`: serve the tool surface over stdio."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ import httpx
7
+
8
+ from dw_mcp.client import (
9
+ DwApiError,
10
+ DwClient,
11
+ is_loopback_url,
12
+ resolve_base_url,
13
+ resolve_token,
14
+ )
15
+ from dw_mcp.server import build_server
16
+
17
+
18
+ def _refuse(message):
19
+ print(f"dw-mcp: {message}", file=sys.stderr)
20
+ return 2
21
+
22
+
23
+ def main(argv=None, transport=None):
24
+ parser = argparse.ArgumentParser(
25
+ prog="dw-mcp",
26
+ description="MCP server for diffusers-workflow. Requires a running "
27
+ "dw.serve - start one with `dw-serve` first.",
28
+ )
29
+ parser.add_argument(
30
+ "--url",
31
+ default=None,
32
+ help="Base URL of the running dw.serve "
33
+ "(default: $DW_MCP_URL, else http://127.0.0.1:8765)",
34
+ )
35
+ parser.add_argument(
36
+ "--token",
37
+ default=None,
38
+ help="Bearer token the dw.serve was started with, if any "
39
+ "(default: $DW_API_TOKEN). Required when --url is not loopback.",
40
+ )
41
+ parser.add_argument(
42
+ "--workspace",
43
+ default=None,
44
+ help="Which of the server's workspaces to work in (default: "
45
+ "$DW_MCP_WORKSPACE, else the server's default). A name on the "
46
+ "server, not a directory here - list_workspaces shows them",
47
+ )
48
+ parser.add_argument(
49
+ "--timeout",
50
+ type=float,
51
+ default=30.0,
52
+ help="Seconds to wait on any one API request (default: 30)",
53
+ )
54
+ parser.add_argument(
55
+ "--no-probe",
56
+ action="store_true",
57
+ default=False,
58
+ help="Skip the startup GET /api/health that confirms the server is "
59
+ "reachable and the token is accepted",
60
+ )
61
+ args = parser.parse_args(argv)
62
+
63
+ base_url = resolve_base_url(args.url)
64
+ token = resolve_token(args.token)
65
+
66
+ # A remote dw.serve with no token would let anyone on that network run
67
+ # workflows as this user; refusing here is the client-side half of the
68
+ # server's own non-loopback-without-token warning.
69
+ if not is_loopback_url(base_url) and not token:
70
+ return _refuse(
71
+ f"{base_url} is not a loopback address and no token is set. "
72
+ "A dw.serve reachable from other machines must be started with "
73
+ "--token / DW_API_TOKEN, and the same token passed here with "
74
+ "--token or DW_API_TOKEN."
75
+ )
76
+
77
+ client = DwClient(
78
+ base_url=base_url,
79
+ timeout=args.timeout,
80
+ token=token,
81
+ transport=transport,
82
+ workspace=args.workspace,
83
+ )
84
+ try:
85
+ if not args.no_probe:
86
+ # A remote URL or token that is wrong is a misconfiguration the
87
+ # user must fix before anything works, so it is fatal. On
88
+ # loopback it usually means "dw.serve is not up yet", which a
89
+ # tool call reports for itself with the same message - warn and
90
+ # serve anyway rather than making the agent restart us.
91
+ code = _probe(client, base_url, required=not is_loopback_url(base_url))
92
+ if code:
93
+ return code
94
+ build_server(client).run(transport="stdio")
95
+ finally:
96
+ client.close()
97
+ return 0
98
+
99
+
100
+ def _probe(client, base_url, required=True):
101
+ """One GET /api/health so a wrong URL or token fails here, once, with a
102
+ message - not on every tool call as an unexplained 401.
103
+
104
+ Returns 2 when the probe failed and `required`; 0 otherwise. A failed
105
+ probe always prints, so a loopback server that is merely not up yet
106
+ still says so.
107
+ """
108
+ try:
109
+ health = client.get_json("/api/health")
110
+ except (DwApiError, httpx.HTTPError) as e:
111
+ text = str(e)
112
+ if "401" in text or "token" in text.lower():
113
+ code = _refuse(
114
+ f"dw.serve at {base_url} requires a bearer token and rejected "
115
+ f"the one given (or none was given): {text}"
116
+ )
117
+ else:
118
+ code = _refuse(f"could not reach dw.serve at {base_url}: {text}")
119
+ return code if required else 0
120
+ print(
121
+ "dw-mcp: connected to {host} (dw {version}, {device}) at {url}".format(
122
+ host=health.get("hostname", "?"),
123
+ version=health.get("version", "?"),
124
+ device=health.get("device", "?"),
125
+ url=base_url,
126
+ ),
127
+ file=sys.stderr,
128
+ )
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
dw_mcp/assets.py ADDED
@@ -0,0 +1,336 @@
1
+ """The asset library: the input media a workflow reaches by an 'asset:'
2
+ reference, and the one way to get a local file into it.
3
+
4
+ An agent authoring remotely can name assets that already exist on the box,
5
+ but until it can put bytes there it can only write workflows it has no way
6
+ to supply inputs for. `upload_asset` is that path: the file's bytes go up as
7
+ the request body - the same route the browser's file picker uses - and what
8
+ comes back is the reference, not a path, because the reference is what a
9
+ workflow carries and the path means nothing on the machine the agent is on.
10
+ """
11
+
12
+ import base64
13
+ import os
14
+
15
+ from dw_mcp.client import DwApiError, api_path
16
+
17
+ # Twin of the server's own limit (dw/server/app.py). Checked here as well so
18
+ # a 200MB file fails before it is read and pushed, not after
19
+ MAX_UPLOAD_BYTES = 200 * 1024 * 1024
20
+
21
+ # `content` bytes arrive base64-encoded in the tool call itself rather than
22
+ # read off a disk, so they compete with everything else in the call for the
23
+ # caller's context budget - a few MB is enough for a voice sample or a small
24
+ # image, and nowhere near enough to use this as a bulk-transfer path (#203)
25
+ MAX_INLINE_UPLOAD_BYTES = 4 * 1024 * 1024
26
+
27
+ # What the library holds, and what the upload route accepts. Duplicated from
28
+ # dw/security.py rather than imported: importing anything under dw/ pulls in
29
+ # torch, which this pure HTTP client must not do
30
+ ALLOWED_UPLOAD_EXTENSIONS = frozenset(
31
+ {
32
+ ".jpg",
33
+ ".jpeg",
34
+ ".png",
35
+ ".gif",
36
+ ".bmp",
37
+ ".webp",
38
+ ".mp4",
39
+ ".avi",
40
+ ".mkv",
41
+ ".mov",
42
+ ".webm",
43
+ ".wav",
44
+ ".mp3",
45
+ ".flac",
46
+ ".ogg",
47
+ }
48
+ )
49
+
50
+
51
+ def _remote_roots(client):
52
+ """The directories a remote read is confined to, or None when local.
53
+
54
+ The mirror of media.py's `_remote_root` (#113), for the other direction.
55
+ Only the mounted MCP surface is remote: there `upload_asset` runs inside
56
+ dw.serve, so `file_path` names a file on the operator's box rather than
57
+ on the calling agent's machine, and an unconfined read is an arbitrary
58
+ file read plus a path-existence oracle (#138). A stdio `dw-mcp` returns
59
+ None and keeps reading whatever the user can, because there "local file"
60
+ is genuinely their own.
61
+ """
62
+ if not getattr(client, "mounted", False):
63
+ return None
64
+
65
+ directories = (client.get_json("/api/server").get("directories")) or {}
66
+ roots = []
67
+ for key in ("workspace", "workflows", "assets", "outputs", "prompts"):
68
+ value = directories.get(key)
69
+ if not value:
70
+ continue
71
+ resolved = os.path.normpath(
72
+ os.path.realpath(os.path.abspath(os.path.expanduser(str(value))))
73
+ )
74
+ if resolved not in roots:
75
+ roots.append(resolved)
76
+ if not roots:
77
+ raise DwApiError(
78
+ "This server cannot say which directories it works in, so it "
79
+ "will not read a file off its own disk for you. Upload the "
80
+ "bytes through the web UI's file picker, pass them inline to "
81
+ "upload_asset as content=, or keep a generated file with "
82
+ "keep_output."
83
+ )
84
+ return roots
85
+
86
+
87
+ def _confine_source(path, roots, named):
88
+ """Refuse a source outside `roots`, before anything looks at the file.
89
+
90
+ Ordered ahead of the existence and extension checks on purpose: a
91
+ refusal that depends on whether the file is there turns the tool into a
92
+ path-existence oracle for the whole box, which is the condition this
93
+ closes as much as the read itself (#138). Containment is on the resolved
94
+ real path, so a symlink cannot carry the read out.
95
+ """
96
+ probe = path
97
+ while not os.path.exists(probe) and os.path.dirname(probe) != probe:
98
+ probe = os.path.dirname(probe)
99
+ resolved = os.path.normpath(
100
+ os.path.join(os.path.realpath(probe), os.path.relpath(path, probe))
101
+ )
102
+ if any(resolved == root or resolved.startswith(root + os.sep) for root in roots):
103
+ return
104
+ raise DwApiError(
105
+ f"Refusing to read {named} - this MCP endpoint is served by "
106
+ f"dw.serve, so the file would be read off the server, where a "
107
+ f"source is confined to the directories it works in "
108
+ f"({', '.join(roots)}). A file that is already there is reachable "
109
+ f"as an 'asset:' reference; to put a new one there when it exists "
110
+ f"only on your own machine, call upload_asset with content= (its "
111
+ f"bytes, base64-encoded) instead of file_path, upload it through "
112
+ f"the web UI's file picker, or promote a generated file with "
113
+ f"keep_output."
114
+ )
115
+
116
+
117
+ # What a compact asset entry keeps: enough to name and choose one, nothing
118
+ # that only opening or previewing it needs
119
+ ASSET_SUMMARY_FIELDS = ("name", "reference", "kind", "size", "origin")
120
+
121
+
122
+ def _summarised_asset_entries(entries):
123
+ return [
124
+ {key: entry.get(key) for key in ASSET_SUMMARY_FIELDS if key in entry}
125
+ for entry in entries
126
+ if isinstance(entry, dict)
127
+ ]
128
+
129
+
130
+ def list_assets(client, detail=False):
131
+ """The input media on the server, each with the 'asset:' reference a
132
+ workflow argument carries.
133
+
134
+ Spans the whole search path: each entry's 'origin' says whether it is
135
+ this workspace's own ('workspace'), the library every workspace shares
136
+ ('common'), or one a read-only examples tree brought with it.
137
+ 'libraries' names the roots searched, in order, with 'writable' saying
138
+ which one an upload or delete can reach. 'shadowed' lists names a
139
+ nearer library hides - present in a farther root but not resolvable by
140
+ 'asset:', since the nearer file wins; a shadowed entry carries no 'url'
141
+ because that URL would serve the file that shadows it.
142
+
143
+ Compact by default - each `assets`/`shadowed` entry cut to
144
+ `name`/`reference`/`kind`/`size`/`origin` - because a library that has
145
+ accumulated months of episode and regression assets answers in the tens
146
+ of KB otherwise for a question that is usually "what's available and
147
+ under what name" (#249). Pass `detail=True` for each entry's `folder`,
148
+ `mtime` and `url` too.
149
+ """
150
+ result = client.get_json("/api/assets")
151
+ if detail:
152
+ return result
153
+ assets = result.get("assets")
154
+ shadowed = result.get("shadowed")
155
+ if not isinstance(assets, list):
156
+ return result
157
+ return {
158
+ **result,
159
+ "assets": _summarised_asset_entries(assets),
160
+ "shadowed": _summarised_asset_entries(shadowed)
161
+ if isinstance(shadowed, list)
162
+ else shadowed,
163
+ "note": (
164
+ "Compact: each entry is name/reference/kind/size/origin only. "
165
+ "Call list_assets(detail=True) for folder, mtime and url too."
166
+ ),
167
+ }
168
+
169
+
170
+ def delete_asset(client, name):
171
+ """Remove one file from the asset library.
172
+
173
+ Deletes from whichever library holds it - the workspace's own before
174
+ the shared one, the order 'asset:' resolves in. An asset from a
175
+ read-only examples library answers 403.
176
+ """
177
+ return client.delete_json(api_path("api", "assets", name))
178
+
179
+
180
+ def keep_output(
181
+ client, name, asset_name=None, overwrite=False, shared=False, workspace=None
182
+ ):
183
+ """Keep a generated file as an input asset, under a stable name.
184
+
185
+ A run's files are named by the run that made them, which is the wrong
186
+ thing to build on: 'latest' moves and a pinned run id breaks when
187
+ outputs are pruned. Keeping one gives it an 'asset:' name that stays
188
+ put, so a later workflow can rely on it.
189
+
190
+ The copy happens on the server, inside the workspace - downloading a
191
+ render here only to upload it back would move the bytes twice for
192
+ nothing.
193
+
194
+ `shared` keeps it in the library every workspace under the server's
195
+ root shares instead, which is where something a later episode in its
196
+ own workspace has to reach belongs.
197
+ """
198
+ return client.post_json(
199
+ "/api/assets/keep",
200
+ {
201
+ "name": name,
202
+ "asset_name": asset_name,
203
+ "overwrite": overwrite,
204
+ "shared": shared,
205
+ },
206
+ workspace=workspace,
207
+ )
208
+
209
+
210
+ def upload_asset(client, file_path=None, content=None, asset_name=None, shared=False):
211
+ """Put an image, video or audio file into the server's asset library and
212
+ get back the reference a workflow can use.
213
+
214
+ Takes exactly one of two sources:
215
+
216
+ `file_path` is read from the machine this MCP server runs on, which is
217
+ not necessarily the machine dw.serve runs on - that is the point of the
218
+ tool. Over a `dw.serve --mcp` endpoint that machine *is* the server, so
219
+ there `file_path` is confined to the directories the server works in,
220
+ and the refusal comes before the file is looked for so it cannot be used
221
+ to probe which paths exist (#138). A stdio `dw-mcp` is unconfined,
222
+ because there the file really is the caller's own.
223
+
224
+ `content` is the file's bytes, base64-encoded, for the case `file_path`
225
+ cannot reach: a headless agent driving a mounted `dw.serve --mcp`
226
+ endpoint has no filesystem in common with that server, so a voice
227
+ sample or small image that exists only on the agent's own machine has
228
+ no path either side can name. No path is confined or read for this
229
+ path - the bytes just go straight to the upload route - so it does not
230
+ reopen #138. It is capped well under `file_path`'s limit
231
+ (MAX_INLINE_UPLOAD_BYTES, a few MB) because these bytes ride in the tool
232
+ call itself rather than being streamed off disk, and `asset_name` is
233
+ required so the upload has a name and extension to be validated against.
234
+
235
+ `asset_name` is the name it is stored under - 'cast/priya-voice.wav'
236
+ rather than the random one an upload gets by default. A recurring cast
237
+ referenced as 'asset:uploads/084eaecc....wav' in every workflow cannot
238
+ be told apart without opening each file, which is the whole reason to
239
+ name one (2026-09-11). With `file_path`, the extension comes from the
240
+ uploaded file when the name has none; with `content`, `asset_name` must
241
+ carry its own extension since there is no file to take one from.
242
+
243
+ `shared` puts it in the library every workspace shares rather than in
244
+ the session's own, which is what a recurring cast needs: assets are
245
+ per workspace, so a cast uploaded while making episode one was
246
+ invisible from the workspace episode four was made in.
247
+ """
248
+ if (file_path is None) == (content is None):
249
+ raise DwApiError("Pass exactly one of file_path or content.")
250
+
251
+ if content is not None:
252
+ return _upload_inline(client, content, asset_name=asset_name, shared=shared)
253
+
254
+ path = os.path.abspath(os.path.expanduser(str(file_path)))
255
+ roots = _remote_roots(client)
256
+ if roots is not None:
257
+ _confine_source(path, roots, file_path)
258
+ if not os.path.isfile(path):
259
+ raise DwApiError(f"No such file: {file_path}")
260
+
261
+ extension = os.path.splitext(path)[1].lower()
262
+ if extension not in ALLOWED_UPLOAD_EXTENSIONS:
263
+ raise DwApiError(
264
+ f"{os.path.basename(path)} is not a kind the asset library takes "
265
+ f"({', '.join(sorted(ALLOWED_UPLOAD_EXTENSIONS))})."
266
+ )
267
+
268
+ size = os.path.getsize(path)
269
+ if size > MAX_UPLOAD_BYTES:
270
+ raise DwApiError(
271
+ f"{os.path.basename(path)} is {size} bytes, over the "
272
+ f"{MAX_UPLOAD_BYTES} byte upload limit."
273
+ )
274
+
275
+ try:
276
+ with open(path, "rb") as handle:
277
+ body = handle.read()
278
+ except OSError as e:
279
+ raise DwApiError(f"Could not read {file_path}: {e}")
280
+
281
+ params = {"filename": os.path.basename(path)}
282
+ if asset_name:
283
+ params["asset_name"] = asset_name
284
+ if shared:
285
+ params["shared"] = "true"
286
+ result = client.post_bytes("/api/uploads", body, params=params)
287
+ # 'path' from a server with no asset library is an absolute path on that
288
+ # machine; from one with a library it is already the reference. Report
289
+ # whichever it gave, named for what it is
290
+ return {
291
+ "reference": result.get("path"),
292
+ "url": result.get("url"),
293
+ "uploaded": os.path.basename(path),
294
+ "size": size,
295
+ }
296
+
297
+
298
+ def _upload_inline(client, content, asset_name=None, shared=False):
299
+ """The `content=` path of upload_asset - bytes with no path behind
300
+ them, so nothing here is confined or read off any disk (#203)."""
301
+ if not asset_name:
302
+ raise DwApiError(
303
+ "content requires asset_name, so the upload has a name and an "
304
+ "extension to validate - there is no file to take either from."
305
+ )
306
+
307
+ extension = os.path.splitext(asset_name)[1].lower()
308
+ if extension not in ALLOWED_UPLOAD_EXTENSIONS:
309
+ raise DwApiError(
310
+ f"{asset_name} is not a kind the asset library takes "
311
+ f"({', '.join(sorted(ALLOWED_UPLOAD_EXTENSIONS))})."
312
+ )
313
+
314
+ try:
315
+ body = base64.b64decode(content, validate=True)
316
+ except Exception as e:
317
+ raise DwApiError(f"content could not be decoded as base64: {e}")
318
+
319
+ if len(body) > MAX_INLINE_UPLOAD_BYTES:
320
+ raise DwApiError(
321
+ f"content is {len(body)} bytes, over the "
322
+ f"{MAX_INLINE_UPLOAD_BYTES} byte limit for an inline upload. A "
323
+ f"file this large should be reached by file_path instead, from "
324
+ f"a machine that has it on disk."
325
+ )
326
+
327
+ params = {"filename": asset_name, "asset_name": asset_name}
328
+ if shared:
329
+ params["shared"] = "true"
330
+ result = client.post_bytes("/api/uploads", body, params=params)
331
+ return {
332
+ "reference": result.get("path"),
333
+ "url": result.get("url"),
334
+ "uploaded": asset_name,
335
+ "size": len(body),
336
+ }
dw_mcp/authoring.py ADDED
@@ -0,0 +1,114 @@
1
+ """Writing workflows. Validation is free and comes first; saving overwrites,
2
+ so it is annotated destructive at the tool layer.
3
+
4
+ Path confinement is the server's job (dw/security.py already refuses
5
+ traversal and anything outside the workflow directory). Nothing here
6
+ re-implements it - a second, subtly different check is how the two drift.
7
+ """
8
+
9
+ from dw_mcp.client import DwApiError, api_path, coerce_json_object
10
+
11
+
12
+ def validate_workflow(
13
+ client,
14
+ workflow=None,
15
+ name=None,
16
+ inline_workflow=None,
17
+ workflow_path=None,
18
+ workspace=None,
19
+ arguments=None,
20
+ ):
21
+ """Schema- and signature-check a workflow without queuing anything. This
22
+ is free (no GPU work) and should be called before any run or save. Give
23
+ either an inline definition (`workflow`, or `inline_workflow` - the same
24
+ thing `run_workflow` calls it) or the name of a stored one (`name`, or
25
+ `workflow_path` - the same thing `run_workflow` calls it), as
26
+ `list_workflows` reports it. A validated document can be handed straight
27
+ to `run_workflow` under either spelling.
28
+
29
+ `arguments` is the same dict `run_workflow` takes, checked against the
30
+ variables the workflow declares and against this workspace's libraries.
31
+ Without it the answer is about the stored definition and its stock
32
+ defaults - which is everything except the part the caller wrote
33
+ (2026-09-11).
34
+
35
+ `plan.cached_steps` is evaluated against the pinned workspace's own
36
+ `outputs/` - a run sitting in a different workspace, however identical
37
+ its arguments and seed, does not count as a hit. Pin `workspace` to the
38
+ one an earlier run actually used if you want to see it credited."""
39
+ workflow = coerce_json_object(workflow, "workflow")
40
+ inline_workflow = coerce_json_object(inline_workflow, "inline_workflow")
41
+ if workflow is not None and inline_workflow is not None:
42
+ raise DwApiError(
43
+ "`workflow` and `inline_workflow` are the same thing - provide only one."
44
+ )
45
+ if name is not None and workflow_path is not None:
46
+ raise DwApiError(
47
+ "`name` and `workflow_path` are the same thing - provide only one."
48
+ )
49
+ inline = workflow if workflow is not None else inline_workflow
50
+ stored = name if name is not None else workflow_path
51
+ if (inline is None) == (stored is None):
52
+ raise DwApiError(
53
+ "Provide exactly one of `workflow`/`inline_workflow` (an inline "
54
+ "definition) or `name`/`workflow_path` (a stored workflow)."
55
+ )
56
+ params = {"workspace": workspace} if workspace else None
57
+ payload = {"workflow_path": stored} if inline is None else {"workflow": inline}
58
+ if arguments is not None:
59
+ # An explicit {} still means "check a run with no values supplied" -
60
+ # distinct from omitting arguments entirely, which means "check the
61
+ # document" (#364). A falsy-but-not-None check here would drop that
62
+ # distinction before it ever reaches the server.
63
+ payload["arguments"] = arguments
64
+ # The server resolves a name against its own workflow directory, so
65
+ # validation sees the same base directory a run would
66
+ return client.post_json("/api/validate", payload, params=params)
67
+
68
+
69
+ def save_workflow(client, name, workflow=None, patch=None):
70
+ """Write a workflow into the server's writable workflow directory,
71
+ overwriting any file already under that name there. A name that resolves
72
+ to one of the server's read-only sources (an examples directory) is not
73
+ overwritten: the copy lands in the writable directory and shadows it from
74
+ then on. The server validates before writing.
75
+
76
+ Give exactly one of `workflow` (the full document to write) or `patch`
77
+ (a JSON Merge Patch, RFC 7396, applied to the currently stored
78
+ definition): a dict whose keys overwrite the stored ones, recursively
79
+ for nested dicts, so bumping one argument means sending just that
80
+ argument rather than the whole document. A key set to `null` deletes it.
81
+ A list replaces the stored list whole - a merge patch has no notion of
82
+ list position, so changing one entry of a `for_each` list still means
83
+ sending that whole list."""
84
+ if (workflow is None) == (patch is None):
85
+ raise DwApiError(
86
+ "Provide exactly one of `workflow` (a full replacement) or "
87
+ "`patch` (a JSON merge patch onto the stored version)."
88
+ )
89
+ workflow = coerce_json_object(workflow, "workflow")
90
+ patch = coerce_json_object(patch, "patch")
91
+ if patch is not None:
92
+ current = client.get_json(api_path("api", "workflows", name))
93
+ workflow = _merge_patch(current, patch)
94
+ return client.put_json(api_path("api", "workflows", name), {"workflow": workflow})
95
+
96
+
97
+ def _merge_patch(target, patch):
98
+ """RFC 7396 JSON Merge Patch: each dict key in `patch` merges
99
+ recursively into `target`; any other value replaces `target` outright;
100
+ `None` deletes the key from the result."""
101
+ if not isinstance(patch, dict):
102
+ return patch
103
+ result = dict(target) if isinstance(target, dict) else {}
104
+ for key, value in patch.items():
105
+ if value is None:
106
+ result.pop(key, None)
107
+ else:
108
+ result[key] = _merge_patch(result.get(key), value)
109
+ return result
110
+
111
+
112
+ def delete_workflow(client, name):
113
+ """Remove a workflow from the server's workflow directory."""
114
+ return client.delete_json(api_path("api", "workflows", name))