taskmaestro 0.2.0__tar.gz → 0.3.0__tar.gz

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 (72) hide show
  1. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/.github/workflows/ci.yml +15 -3
  2. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/.github/workflows/publish.yml +11 -1
  3. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/PKG-INFO +311 -7
  4. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/README.md +309 -6
  5. taskmaestro-0.3.0/docs/agent-quickstart.md +102 -0
  6. taskmaestro-0.3.0/examples/agent_quickstart/input.yaml +2 -0
  7. taskmaestro-0.3.0/examples/agent_quickstart/tasks.py +19 -0
  8. taskmaestro-0.3.0/examples/agent_quickstart/workflow.yaml +9 -0
  9. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/image_processing/input.yaml +3 -1
  10. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/image_processing/pipeline.py +20 -9
  11. taskmaestro-0.3.0/examples/release_pipeline/input.yaml +27 -0
  12. taskmaestro-0.3.0/examples/release_pipeline/pipeline.py +379 -0
  13. taskmaestro-0.3.0/examples/release_pipeline/workflow.yaml +44 -0
  14. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/text_analysis/pipeline.py +8 -1
  15. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/text_analysis/workflow.yaml +0 -1
  16. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/pyproject.toml +6 -2
  17. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/__init__.py +14 -2
  18. taskmaestro-0.3.0/taskmaestro/__main__.py +8 -0
  19. taskmaestro-0.3.0/taskmaestro/cli.py +484 -0
  20. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/context.py +20 -0
  21. taskmaestro-0.3.0/taskmaestro/dependencies.py +131 -0
  22. taskmaestro-0.3.0/taskmaestro/exceptions.py +82 -0
  23. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/hooks/base.py +23 -0
  24. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/hooks/logging.py +13 -0
  25. taskmaestro-0.3.0/taskmaestro/hooks/persistence.py +44 -0
  26. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/hooks/timing.py +20 -0
  27. taskmaestro-0.3.0/taskmaestro/job.py +185 -0
  28. taskmaestro-0.3.0/taskmaestro/mapping.py +37 -0
  29. taskmaestro-0.3.0/taskmaestro/runner.py +550 -0
  30. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/visualization.py +84 -23
  31. taskmaestro-0.3.0/taskmaestro/workflow.py +948 -0
  32. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/workflow_task.py +11 -15
  33. taskmaestro-0.3.0/taskmaestro/yaml_config.py +626 -0
  34. taskmaestro-0.3.0/tests/test_agent_quickstart.py +39 -0
  35. taskmaestro-0.3.0/tests/test_cli.py +837 -0
  36. taskmaestro-0.3.0/tests/test_collections.py +593 -0
  37. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_discovery.py +1 -1
  38. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_exceptions.py +12 -0
  39. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_hooks.py +74 -0
  40. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_job.py +47 -0
  41. taskmaestro-0.3.0/tests/test_mapping.py +914 -0
  42. taskmaestro-0.3.0/tests/test_runner.py +963 -0
  43. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_visualization.py +52 -3
  44. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_workflow.py +417 -0
  45. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_workflow_task.py +51 -0
  46. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_yaml_config.py +735 -48
  47. taskmaestro-0.2.0/taskmaestro/exceptions.py +0 -41
  48. taskmaestro-0.2.0/taskmaestro/hooks/persistence.py +0 -24
  49. taskmaestro-0.2.0/taskmaestro/job.py +0 -116
  50. taskmaestro-0.2.0/taskmaestro/runner.py +0 -201
  51. taskmaestro-0.2.0/taskmaestro/workflow.py +0 -500
  52. taskmaestro-0.2.0/taskmaestro/yaml_config.py +0 -463
  53. taskmaestro-0.2.0/tests/test_runner.py +0 -432
  54. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/.gitignore +0 -0
  55. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/LICENSE +0 -0
  56. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/image_processing/inner_workflow.yaml +0 -0
  57. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/image_processing/workflow.yaml +0 -0
  58. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/resinsight/input.yaml +0 -0
  59. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/resinsight/pipeline.py +0 -0
  60. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/resinsight/workflow.yaml +0 -0
  61. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/examples/text_analysis/input.yaml +0 -0
  62. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/discovery.py +0 -0
  63. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/hooks/__init__.py +0 -0
  64. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/object_model.py +0 -0
  65. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/py.typed +0 -0
  66. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro/task.py +0 -0
  67. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/taskmaestro.png +0 -0
  68. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/__init__.py +0 -0
  69. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/conftest.py +0 -0
  70. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_context.py +0 -0
  71. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_object_model.py +0 -0
  72. {taskmaestro-0.2.0 → taskmaestro-0.3.0}/tests/test_task.py +0 -0
@@ -5,9 +5,19 @@ on:
5
5
  branches: [main]
6
6
  pull_request:
7
7
 
8
+ # Least privilege: the CI job only reads the repository.
9
+ permissions:
10
+ contents: read
11
+
12
+ # Cancel superseded runs for the same ref (e.g. force-pushes to a PR).
13
+ concurrency:
14
+ group: ci-${{ github.workflow }}-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
8
17
  jobs:
9
18
  ci:
10
19
  runs-on: ubuntu-latest
20
+ timeout-minutes: 15
11
21
  strategy:
12
22
  fail-fast: false
13
23
  matrix:
@@ -20,18 +30,20 @@ jobs:
20
30
  uses: actions/setup-python@v5
21
31
  with:
22
32
  python-version: ${{ matrix.python-version }}
33
+ cache: pip
34
+ cache-dependency-path: pyproject.toml
23
35
 
24
36
  - name: Install dependencies
25
37
  run: pip install -e ".[dev]"
26
38
 
27
39
  - name: Ruff check
28
- run: ruff check taskmaestro/ tests/
40
+ run: ruff check taskmaestro/ tests/ examples/
29
41
 
30
42
  - name: Ruff format check
31
- run: ruff format --check taskmaestro/ tests/
43
+ run: ruff format --check taskmaestro/ tests/ examples/
32
44
 
33
45
  - name: Mypy type check
34
46
  run: mypy taskmaestro
35
47
 
36
48
  - name: Run tests with coverage
37
- run: pytest --cov=taskmaestro --cov-report=term-missing
49
+ run: pytest --cov=taskmaestro --cov-report=term-missing --cov-fail-under=100
@@ -4,10 +4,15 @@ on:
4
4
  push:
5
5
  tags: ["v*"]
6
6
 
7
+ # Default to read-only; the publish job opts into id-token below.
8
+ permissions:
9
+ contents: read
10
+
7
11
  jobs:
8
12
  build:
9
13
  name: Build distributions
10
14
  runs-on: ubuntu-latest
15
+ timeout-minutes: 10
11
16
  steps:
12
17
  - uses: actions/checkout@v4
13
18
 
@@ -15,6 +20,8 @@ jobs:
15
20
  uses: actions/setup-python@v5
16
21
  with:
17
22
  python-version: "3.12"
23
+ cache: pip
24
+ cache-dependency-path: pyproject.toml
18
25
 
19
26
  - name: Install build
20
27
  run: pip install build
@@ -32,6 +39,7 @@ jobs:
32
39
  name: Publish to PyPI
33
40
  needs: build
34
41
  runs-on: ubuntu-latest
42
+ timeout-minutes: 10
35
43
  environment: pypi
36
44
  permissions:
37
45
  id-token: write
@@ -43,4 +51,6 @@ jobs:
43
51
  path: dist/
44
52
 
45
53
  - name: Publish via trusted publishing
46
- uses: pypa/gh-action-pypi-publish@release/v1
54
+ # Third-party action pinned to a full commit SHA (tag v1.14.2); the
55
+ # `release/v1` branch is mutable and could be repointed.
56
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskmaestro
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Python 3.12+ library for defining and executing typed DAG task workflows
5
5
  Project-URL: Homepage, https://github.com/OPM/taskmaestro
6
6
  Project-URL: Repository, https://github.com/OPM/taskmaestro
@@ -15,6 +15,7 @@ Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
20
  Classifier: Typing :: Typed
20
21
  Requires-Python: >=3.12
@@ -36,6 +37,8 @@ Description-Content-Type: text/markdown
36
37
 
37
38
  A Python 3.12+ library for defining and executing typed DAG task workflows with Pydantic models, lifecycle hooks, and fail-fast semantics.
38
39
 
40
+ For an agent-friendly, runnable YAML example with validation and troubleshooting steps, see the [agent quickstart](docs/agent-quickstart.md).
41
+
39
42
  ## Installation
40
43
 
41
44
  ```bash
@@ -140,16 +143,55 @@ You define **Tasks** (typed units of work), compose them into a **Workflow** (li
140
143
  | Concept | Description |
141
144
  |---|---|
142
145
  | **Task** | Subclass `Task[I, O]` with Pydantic models for input and output, then implement `run(input, ctx)`. Each task can declare an optional `timeout_seconds`. For tasks with multiple named outputs, use inline `Inputs`/`Outputs` classes inside the task body. |
143
- | **Workflow** | Build a linear pipeline with `Workflow(tasks=[...])` or a DAG with `Workflow.builder()`. The builder accepts `depends_on` for single dependencies, fan-in dicts (`{"field": UpstreamTask}`), and `(Task, "field")` tuples for output field routing. Use `config_fields` to declare which input fields come from `JobConfiguration`. Workflows are validated at build time for cycles, type compatibility, and input completeness. |
146
+ | **Workflow** | Build a linear pipeline with `Workflow(tasks=[...])` or a DAG with `Workflow.builder()`. Prefer `builder.task()` and task handles for unambiguous dependencies; the fluent `add_task()` API remains supported. The builder accepts `collect()` for gathering outputs into collection fields and `mapped_over=TaskMap(...)` for sequential expansion over configured mappings. Use `config_fields` to declare which input fields come from `JobConfiguration`. Workflows are validated at build time for cycles, type compatibility, and input completeness. |
144
147
  | **Job** | Binds a Workflow to a typed config (the root task's input). Tracks `status` (`pending` → `running` → `completed`/`failed`), the final `result`, any `error`, and per-task `task_results`. Optionally accepts a `JobConfiguration` for per-task static config values. A job can only be run once. |
145
148
  | **Runner** | Executes tasks in topological order, stopping on the first failure (fail-fast). Supports per-task and per-job timeouts via `signal.alarm` (Unix only). Dispatches lifecycle events to registered hooks. |
146
149
  | **ExecutionContext** | Passed to every `run()` call. Provides a `logger`, an auto-generated `correlation_id` (UUID), a `scratch_dir` (temporary directory), and a service registry (`register()`/`resolve()`) for injecting shared resources like DB connections. |
147
150
  | **Hooks** | Subclass `BaseHook` and override methods like `on_job_start`, `on_task_complete`, etc. Hook errors are swallowed and reported via `warnings.warn()`, so they never crash the job. Built-ins: `LoggingHook`, `TimingHook`, `ResultPersistenceHook`. |
148
151
  | **ObjectModel** | Generic `ObjectModel[T]` base model for wrapping arbitrary (non-Pydantic) objects. Enables `arbitrary_types_allowed` so fields can hold native library objects like database connections or API clients. |
149
152
 
153
+ For common cases, run a workflow directly without constructing `Job` and `Runner`:
154
+
155
+ ```python
156
+ result = workflow.run(
157
+ Input(value=5),
158
+ task_config={"configured_task": {"option": "value"}},
159
+ hooks=[LoggingHook()],
160
+ )
161
+ ```
162
+
163
+ The explicit `Job` and `Runner` API remains available for advanced lifecycle control.
164
+
165
+ ## Task Handles
166
+
167
+ `builder.task()` adds a task and returns a handle to that specific instance. Handles avoid ambiguous class and string references, especially when the same task class is registered more than once:
168
+
169
+ ```python
170
+ builder = Workflow.builder(name="parallel_wells")
171
+ model = builder.task(LoadModel)
172
+ well_1 = builder.task(LoadWellPath, name="well_1", depends_on=model)
173
+ well_2 = builder.task(LoadWellPath, name="well_2", depends_on=model)
174
+ builder.task(Process, name="proc_1", depends_on=well_1)
175
+ builder.task(Process, name="proc_2", depends_on=well_2)
176
+ workflow = builder.build()
177
+ ```
178
+
179
+ Use `handle.field("field_name")` to route one output field. Named input dependencies can be passed directly to `task()`, while keyword arguments to `collect()` provide a concise keyed collection:
180
+
181
+ ```python
182
+ merged = builder.task(
183
+ MergeResults,
184
+ primary=producer.field("result"),
185
+ checks=collect(tests=tests, lint=lint, types=types),
186
+ )
187
+ builder.set_result_task(merged)
188
+ ```
189
+
190
+ Handles are accepted anywhere dependency references are accepted. A handle from a different builder is rejected. Use the `depends_on` dictionary form when an input field conflicts with a reserved builder argument such as `name` or `config_fields`. The existing fluent `add_task()` API remains fully supported for backward compatibility.
191
+
150
192
  ## Named Task Instances
151
193
 
152
- The same Task class can appear multiple times in a workflow with different names. Use the `name=` parameter in `add_task()`:
194
+ The same Task class can appear multiple times in a workflow with different names. With the fluent API, use the `name=` parameter in `add_task()`:
153
195
 
154
196
  ```python
155
197
  workflow = (
@@ -190,6 +232,8 @@ job = Job(workflow=workflow, config=EmptyConfig(), job_configuration=job_config)
190
232
  result = Runner().run(job)
191
233
  ```
192
234
 
235
+ Every declared config field, on root, dependent and mapped tasks alike, must have a value in the `JobConfiguration`; otherwise `Job(...)` raises `WorkflowDefinitionError`.
236
+
193
237
  ## Nested Workflows
194
238
 
195
239
  A workflow can be wrapped as a typed task and used inside a larger workflow. Its input
@@ -261,6 +305,155 @@ workflow = (
261
305
  )
262
306
  ```
263
307
 
308
+ ## Collecting Multiple Outputs
309
+
310
+ Use `collect()` when several task outputs should populate one `list[T]` or
311
+ `dict[str, T]` field. Positional members preserve declaration order:
312
+
313
+ ```python
314
+ from taskmaestro import collect
315
+
316
+ class GridInput(BaseModel):
317
+ surfaces: list[Surface]
318
+
319
+ workflow = (
320
+ Workflow.builder("create_grid")
321
+ .add_task(LoadSurface, name="top")
322
+ .add_task(GenerateSurface, name="middle")
323
+ .add_task(LoadSurface, name="base")
324
+ .add_task(
325
+ CreateGrid,
326
+ depends_on={"surfaces": collect("top", "middle", "base")},
327
+ )
328
+ .build()
329
+ )
330
+ ```
331
+
332
+ Use a mapping to preserve aliases in a `dict[str, T]`, and use `(task, "field")`
333
+ to collect a specific output field:
334
+
335
+ ```python
336
+ depends_on={
337
+ "surfaces": collect({
338
+ "top": ("top_loader", "surface"),
339
+ "base": ("base_loader", "surface"),
340
+ })
341
+ }
342
+ ```
343
+
344
+ The equivalent YAML forms are:
345
+
346
+ ```yaml
347
+ depends_on:
348
+ surfaces:
349
+ collect:
350
+ - top
351
+ - [middle, generated_surface]
352
+ - base
353
+ ```
354
+
355
+ ```yaml
356
+ depends_on:
357
+ surfaces:
358
+ collect:
359
+ top: [top_loader, surface]
360
+ base: [base_loader, surface]
361
+ ```
362
+
363
+ Every member is checked against the field's element type when the workflow is
364
+ built. Subtypes are accepted. `collect()` and `collect({})` explicitly create
365
+ empty list and dictionary inputs, respectively.
366
+
367
+ ## Mapped Tasks
368
+
369
+ A mapped task invokes one task declaration for every entry in a configured
370
+ mapping. Mapped items execute sequentially in mapping declaration order.
371
+ Each item gets a fresh task instance and child `ExecutionContext`.
372
+
373
+ ```python
374
+ builder = Workflow.builder("create_grid")
375
+ connection = builder.task(ConnectToResInsight)
376
+ surfaces = builder.map_task(
377
+ LoadRegularSurface,
378
+ name="load_surfaces",
379
+ depends_on={"resinsight": connection},
380
+ config_fields=["unit"],
381
+ over="surfaces",
382
+ key_as="surface_name",
383
+ value_as="path",
384
+ error_mode="fail_fast",
385
+ )
386
+ builder.task(CreateGrid, depends_on={"surfaces": surfaces})
387
+ workflow = builder.build()
388
+ ```
389
+
390
+ The mapped task's input model contains the injected key and value fields, not
391
+ the source mapping:
392
+
393
+ ```python
394
+ class LoadSurfaceInput(BaseModel):
395
+ resinsight: RipsInstance
396
+ unit: str
397
+ surface_name: str # key_as
398
+ path: str # value_as
399
+ ```
400
+
401
+ Configure the source through `JobConfiguration`:
402
+
403
+ ```python
404
+ job_configuration = JobConfiguration({
405
+ "load_surfaces": {
406
+ "unit": "meters",
407
+ "surfaces": {
408
+ "top": "/data/top.irap",
409
+ "base": "/data/base.irap",
410
+ },
411
+ },
412
+ })
413
+ ```
414
+
415
+ The logical output is a `MappedOutput[O]` Pydantic root model containing an
416
+ insertion-ordered `dict[str, O]`, where `O` is the task's declared output type.
417
+ When a mapped task is connected to a named `dict[str, O]` input field, its
418
+ `root` value is unwrapped automatically:
419
+
420
+ ```python
421
+ class CreateGridInput(BaseModel):
422
+ surfaces: dict[str, RegularSurface]
423
+ ```
424
+
425
+ The equivalent YAML task declaration is:
426
+
427
+ ```yaml
428
+ - task: resinsight.load_regular_surface
429
+ name: load_surfaces
430
+ map:
431
+ over: surfaces
432
+ key_as: surface_name
433
+ value_as: path
434
+ error_mode: fail_fast
435
+ depends_on:
436
+ resinsight: resinsight.connect
437
+ config_fields: [unit]
438
+ ```
439
+
440
+ Input YAML:
441
+
442
+ ```yaml
443
+ load_surfaces:
444
+ unit: meters
445
+ surfaces:
446
+ top: /data/top.irap
447
+ base: /data/base.irap
448
+ ```
449
+
450
+ `fail_fast` stops at the first failed item. `collect_all` attempts every item
451
+ and reports an aggregate `MappedTaskExecutionError`. An empty mapping succeeds
452
+ with `MappedOutput(root={})`. Per-item records are available in
453
+ `job.mapped_item_results`, and
454
+ built-in logging, timing, and persistence hooks observe individual items.
455
+ Concurrent mapped execution is intentionally deferred.
456
+
264
457
  ## ObjectModel
265
458
 
266
459
  `ObjectModel[T]` wraps arbitrary (non-Pydantic) objects so they can flow through workflows. Use it as a type alias for simple wrappers, or subclass it to add extra fields:
@@ -312,6 +505,25 @@ without importing plugin modules, or `get_registered_task(name)` and
312
505
  `get_registered_workflow(name)` to load one plugin. Duplicate names and invalid plugin
313
506
  types raise `PluginLoadError`.
314
507
 
508
+ Agents can inspect installed task plugins from the CLI without loading every plugin:
509
+
510
+ ```bash
511
+ taskmaestro tasks list --json
512
+ taskmaestro tasks describe acme.prepare --json
513
+ ```
514
+
515
+ `list --json` emits `{"tasks": ["acme.prepare", ...]}` in sorted order; an empty list
516
+ means no task plugins are installed. `describe --json` loads only the named plugin
517
+ and emits its registered `identifier`, task `name`, `timeout_seconds`, and Pydantic
518
+ `input_schema` / `output_schema` (JSON Schema objects). These commands inspect
519
+ **installed entry points**, not task classes local to a workflow YAML file. Without
520
+ `--json`, `list` prints one identifier per line and `describe` prints indented JSON.
521
+ For runtime-only Python objects (such as `ObjectModel[rips.EclipseCase]`), schema
522
+ fields include `"not": {}`, `"x-taskmaestro-opaque": true`, and
523
+ `"x-taskmaestro-python-type"`. They cannot be supplied as JSON; wire them from
524
+ upstream tasks or a Python context instead. Other unsupported schema constructs,
525
+ unknown identifiers, and invalid plugins report an error on stderr and exit with status 2.
526
+
315
527
  ## YAML Configuration
316
528
 
317
529
  Workflows can be defined entirely in YAML instead of Python. A `task:` value may be
@@ -354,8 +566,9 @@ context:
354
566
 
355
567
  ```yaml
356
568
  # input.yaml
357
- text: "Python is a high-level programming language..."
358
- title: "Python Overview"
569
+ prepare_text:
570
+ text: "Python is a high-level programming language..."
571
+ title: "Python Overview"
359
572
  ```
360
573
 
361
574
  Load and run:
@@ -371,7 +584,18 @@ result = loaded.run()
371
584
  result = run_workflow_from_yaml("workflow.yaml", "input.yaml")
372
585
  ```
373
586
 
374
- YAML supports named task instances (`name:`), per-task input config (keyed by task name in the input file), fan-in dicts, and output field routing via `[task, field]` lists.
587
+ YAML input always uses per-task configuration: every top-level key in `input.yaml` must be a registered task instance name, and its value must be a mapping or `null`. Unknown task names and scalar task values are rejected. Fields are validated against the task's input model and can configure root tasks, downstream tasks, and mapped tasks.
588
+
589
+ YAML also supports named task instances (`name:`), fan-in dictionaries, and output field routing via `[task, field]` lists. Named instances use their instance name as the input key:
590
+
591
+ ```yaml
592
+ load_well_path_1:
593
+ path: first.dev
594
+ load_well_path_2:
595
+ path: second.dev
596
+ ```
597
+
598
+ When the same task class (or the same inner YAML file) appears more than once under different `name:`s, `depends_on` and `result_task` must use the instance name — referencing the class path is rejected as ambiguous.
375
599
 
376
600
  Use `workflow:` instead of `task:` to compose another YAML workflow. Paths are resolved
377
601
  relative to the containing workflow file, and `workflow_input:` optionally supplies the
@@ -435,19 +659,99 @@ WorkflowRunnerError (base)
435
659
  ├── JobStateError # e.g., re-running a completed job
436
660
  ├── ConfigLoadError # YAML config loading failure
437
661
  └── TaskExecutionError # Runtime task failure
662
+ ├── MappedTaskExecutionError # One or more mapped items failed
438
663
  ├── TaskOutputTypeError # Output type mismatch
439
664
  └── TaskTimeoutError # Task exceeded timeout
440
665
  ```
441
666
 
667
+ ## Command-Line Interface
668
+
669
+ Installed packages provide a `taskmaestro` command for YAML workflows:
670
+
671
+ ```bash
672
+ taskmaestro validate workflow.yaml --input input.yaml
673
+ taskmaestro graph workflow.yaml --input input.yaml
674
+ taskmaestro run workflow.yaml --input input.yaml --log-level INFO
675
+ taskmaestro workflow describe workflow.yaml --json
676
+ ```
677
+
678
+ By default, `run` prints the final output as JSON and reports errors on stderr;
679
+ `validate` prints a human-readable confirmation. `graph` prints Mermaid markup.
680
+
681
+ For automation, use `--json` with `validate` or `run`:
682
+
683
+ ```bash
684
+ taskmaestro validate workflow.yaml --input input.yaml --json
685
+ taskmaestro run workflow.yaml --input input.yaml --json
686
+ ```
687
+
688
+ Successful validation emits `{"status":"valid","workflow":"..."}`. A successful
689
+ run emits `{"status":"completed","workflow":"...","result":{...}}`. A failure
690
+ emits one JSON object on stdout, for example:
691
+
692
+ ```json
693
+ {"status":"failed","workflow":"example","failed_task":"prepare","error":{"code":"task_failed","type":"ValueError","message":"Task failed","task":"prepare","field":null,"issues":[]}}
694
+ ```
695
+
696
+ Errors contain `code`, exception `type`, a safe `message`, nullable `task` and
697
+ `field`, and `issues` (field paths and error codes, without input values).
698
+ Loading failures have status `invalid` and `code: "configuration_error"`; task
699
+ failures have status `failed` and `code: "task_failed"`. If a completed result
700
+ cannot be encoded as JSON (e.g. a Python-only object), `run --json` returns
701
+ `code: "serialization_error"`. Missing configuration fields produce `issues`
702
+ with code `missing`; when no structured field metadata is available, `field`
703
+ is `null`. Use text mode when you need the original exception message.
704
+
705
+ Exit codes: `0` success, `1` task or result-serialization failure, `2` workflow
706
+ configuration failure. In JSON mode logs and ordinary Python `print()` output
707
+ from imports/tasks go to stderr, reserving stdout for the result document.
708
+ Application code can still write directly to file descriptor 1; JSON mode is
709
+ not a sandbox. Error objects intentionally omit raw exception messages, but
710
+ application-generated stderr may contain sensitive data.
711
+
712
+ Inspect a workflow **before its input file is complete** with
713
+ `taskmaestro workflow describe workflow.yaml --json`. The JSON contains the
714
+ workflow name and result task plus topologically ordered task instances. Each
715
+ instance includes its `name`, `python_type`, `depends_on` references (`task` /
716
+ `field`), `config_fields`, `required_input_fields`, optional `map` configuration,
717
+ and Pydantic input/output schemas. Collection dependencies include their kind
718
+ (`positional` or `keyed`) and member references. `python_type` identifies the
719
+ loaded class; it may differ from the plugin entry-point identifier used in YAML.
720
+
721
+ Pass `--input input.yaml` to check the input against the workflow. The input
722
+ may be incomplete (for example a template whose runtime-picker fields a UI
723
+ fills in): `provided_config_fields` lists the field names set for each task,
724
+ `missing_config_fields` lists declared `config_fields` the input does not set,
725
+ and `config_values` maps exactly the provided names to their values; without
726
+ `--input`, all three are `null`. Other problems, such as unknown task keys or a
727
+ missing or invalid map source, still fail. `validate` and `run` reject missing
728
+ configuration fields. Values are what `input.yaml` contains for
729
+ the task, **before** Pydantic validation, so opaque markers (for example
730
+ `{"__resinsight_ref__": "EclipseCase", "case_id": 0}`) come through unchanged.
731
+ Dates and datetimes become ISO 8601 strings, paths become strings, sets become
732
+ sorted lists and non-string mapping keys become JSON key strings; binary values,
733
+ NaN/infinity and keys that collide after conversion are rejected as invalid.
734
+ Fields without a value fall back to `input_schema.properties.<field>.default`.
735
+ **The output contains the input values, including any secrets.** This check
736
+ does not run tasks or hooks and does not validate every configured value's
737
+ runtime type. Loading YAML still imports
738
+ Python modules (and nested workflows); **do not inspect untrusted YAML or plugins**
739
+ under a privileged account. In JSON mode inspection failures have status
740
+ `invalid`, an `error` object, and exit code 2. Without `--json`, inspection
741
+ prints indented JSON.
742
+
743
+ `python -m taskmaestro ...` is equivalent, which is useful when the scripts directory is not on `PATH`.
744
+
442
745
  ## Examples
443
746
 
444
- Three full example pipelines are included in the `examples/` directory:
747
+ Four full example pipelines are included in the `examples/` directory:
445
748
 
446
749
  | Example | Features |
447
750
  |---|---|
448
751
  | `examples/text_analysis/` | DAG with fan-out/fan-in, output field routing, inline `Inputs`/`Outputs` classes, YAML config, Mermaid visualization |
449
752
  | `examples/resinsight/` | `ObjectModel[T]` for gRPC objects, `JobConfiguration` with per-task config, named task instances, `config_fields`, YAML config |
450
753
  | `examples/image_processing/` | Nested workflows through `Workflow.as_task()` and YAML `workflow:`, typed boundaries, expanded Mermaid subgraph |
754
+ | `examples/release_pipeline/` | Keyed `collect()` dependencies, mapped tasks, mapped output routing, per-task YAML config |
451
755
 
452
756
  Run an example:
453
757