labcode 0.5.0__tar.gz → 0.7.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 (91) hide show
  1. {labcode-0.5.0 → labcode-0.7.0}/PKG-INFO +23 -3
  2. {labcode-0.5.0 → labcode-0.7.0}/README.md +20 -0
  3. {labcode-0.5.0 → labcode-0.7.0}/labcode/backend.py +28 -9
  4. {labcode-0.5.0 → labcode-0.7.0}/labcode/objectid.py +27 -10
  5. {labcode-0.5.0 → labcode-0.7.0}/labcode/record.py +5 -0
  6. {labcode-0.5.0 → labcode-0.7.0}/labcode/run_cli.py +111 -24
  7. {labcode-0.5.0 → labcode-0.7.0}/labcode/runner.py +94 -16
  8. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/PKG-INFO +23 -3
  9. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/SOURCES.txt +2 -0
  10. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/requires.txt +2 -2
  11. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/scm_file_list.json +2 -0
  12. labcode-0.7.0/labcode.egg-info/scm_version.json +8 -0
  13. {labcode-0.5.0 → labcode-0.7.0}/pyproject.toml +13 -2
  14. labcode-0.7.0/tests/fixtures/joint.run.yaml +6 -0
  15. labcode-0.7.0/tests/test_joint.py +265 -0
  16. labcode-0.5.0/labcode.egg-info/scm_version.json +0 -8
  17. {labcode-0.5.0 → labcode-0.7.0}/.github/workflows/ci.yml +0 -0
  18. {labcode-0.5.0 → labcode-0.7.0}/.github/workflows/publish.yml +0 -0
  19. {labcode-0.5.0 → labcode-0.7.0}/.gitignore +0 -0
  20. {labcode-0.5.0 → labcode-0.7.0}/LICENSE +0 -0
  21. {labcode-0.5.0 → labcode-0.7.0}/MANIFEST.in +0 -0
  22. {labcode-0.5.0 → labcode-0.7.0}/SPECIFICATIONS.md +0 -0
  23. {labcode-0.5.0 → labcode-0.7.0}/examples/README.md +0 -0
  24. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/plate_line.boundary.yaml +0 -0
  25. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/plate_line.observation.yaml +0 -0
  26. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/plate_line.plan.yaml +0 -0
  27. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/plate_line.svg +0 -0
  28. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
  29. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
  30. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
  31. {labcode-0.5.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.svg +0 -0
  32. {labcode-0.5.0 → labcode-0.7.0}/examples/plate_line.boundary.yaml +0 -0
  33. {labcode-0.5.0 → labcode-0.7.0}/examples/plate_line.env.yaml +0 -0
  34. {labcode-0.5.0 → labcode-0.7.0}/examples/plate_line.workflow.yaml +0 -0
  35. {labcode-0.5.0 → labcode-0.7.0}/examples/preflight_sila2_env.py +0 -0
  36. {labcode-0.5.0 → labcode-0.7.0}/examples/render_plate_line.py +0 -0
  37. {labcode-0.5.0 → labcode-0.7.0}/examples/render_sila2_plate_cycle.py +0 -0
  38. {labcode-0.5.0 → labcode-0.7.0}/examples/run_all_sila2_examples.py +0 -0
  39. {labcode-0.5.0 → labcode-0.7.0}/examples/run_sila2_plate_cycle.py +0 -0
  40. {labcode-0.5.0 → labcode-0.7.0}/examples/run_sila2_plate_cycle_no_atc.py +0 -0
  41. {labcode-0.5.0 → labcode-0.7.0}/examples/run_sila2_seal.py +0 -0
  42. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_plate_cycle.boundary.yaml +0 -0
  43. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_plate_cycle.workflow.yaml +0 -0
  44. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
  45. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_plate_cycle_no_atc.boundary.yaml +0 -0
  46. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_plate_cycle_no_atc.workflow.yaml +0 -0
  47. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_plate_cycle_no_atc.wrapped.env.yaml +0 -0
  48. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_seal.boundary.yaml +0 -0
  49. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_seal.env.yaml +0 -0
  50. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_seal.workflow.yaml +0 -0
  51. {labcode-0.5.0 → labcode-0.7.0}/examples/sila2_seal.wrapped.env.yaml +0 -0
  52. {labcode-0.5.0 → labcode-0.7.0}/labcode/__init__.py +0 -0
  53. {labcode-0.5.0 → labcode-0.7.0}/labcode/__main__.py +0 -0
  54. {labcode-0.5.0 → labcode-0.7.0}/labcode/_child.py +0 -0
  55. {labcode-0.5.0 → labcode-0.7.0}/labcode/cli.py +0 -0
  56. {labcode-0.5.0 → labcode-0.7.0}/labcode/dialect.py +0 -0
  57. {labcode-0.5.0 → labcode-0.7.0}/labcode/extension.py +0 -0
  58. {labcode-0.5.0 → labcode-0.7.0}/labcode/idgen.py +0 -0
  59. {labcode-0.5.0 → labcode-0.7.0}/labcode/otel.py +0 -0
  60. {labcode-0.5.0 → labcode-0.7.0}/labcode/otel_sila2.py +0 -0
  61. {labcode-0.5.0 → labcode-0.7.0}/labcode/probe.py +0 -0
  62. {labcode-0.5.0 → labcode-0.7.0}/labcode/py.typed +0 -0
  63. {labcode-0.5.0 → labcode-0.7.0}/labcode/sila2.py +0 -0
  64. {labcode-0.5.0 → labcode-0.7.0}/labcode/sila2_commands.py +0 -0
  65. {labcode-0.5.0 → labcode-0.7.0}/labcode/sila2_instrument.py +0 -0
  66. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/dependency_links.txt +0 -0
  67. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/entry_points.txt +0 -0
  68. {labcode-0.5.0 → labcode-0.7.0}/labcode.egg-info/top_level.txt +0 -0
  69. {labcode-0.5.0 → labcode-0.7.0}/setup.cfg +0 -0
  70. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/device_script.env.yaml +0 -0
  71. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/device_script.workflow.yaml +0 -0
  72. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/replenishment.env.yaml +0 -0
  73. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/reroute_device.env.yaml +0 -0
  74. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/reroute_transporter.env.yaml +0 -0
  75. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/transport.env.yaml +0 -0
  76. {labcode-0.5.0 → labcode-0.7.0}/tests/fixtures/transport.workflow.yaml +0 -0
  77. {labcode-0.5.0 → labcode-0.7.0}/tests/test_backend.py +0 -0
  78. {labcode-0.5.0 → labcode-0.7.0}/tests/test_cli.py +0 -0
  79. {labcode-0.5.0 → labcode-0.7.0}/tests/test_dialect.py +0 -0
  80. {labcode-0.5.0 → labcode-0.7.0}/tests/test_objectid.py +0 -0
  81. {labcode-0.5.0 → labcode-0.7.0}/tests/test_otel.py +0 -0
  82. {labcode-0.5.0 → labcode-0.7.0}/tests/test_otel_child.py +0 -0
  83. {labcode-0.5.0 → labcode-0.7.0}/tests/test_otel_grpc.py +0 -0
  84. {labcode-0.5.0 → labcode-0.7.0}/tests/test_otel_sila2.py +0 -0
  85. {labcode-0.5.0 → labcode-0.7.0}/tests/test_probe.py +0 -0
  86. {labcode-0.5.0 → labcode-0.7.0}/tests/test_record.py +0 -0
  87. {labcode-0.5.0 → labcode-0.7.0}/tests/test_recording.py +0 -0
  88. {labcode-0.5.0 → labcode-0.7.0}/tests/test_run_cli.py +0 -0
  89. {labcode-0.5.0 → labcode-0.7.0}/tests/test_sila2.py +0 -0
  90. {labcode-0.5.0 → labcode-0.7.0}/tests/test_sila2_commands.py +0 -0
  91. {labcode-0.5.0 → labcode-0.7.0}/tests/test_sila2_instrument.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
5
5
  Author-email: Kazunari Kaizu <kwaizu@gmail.com>
6
6
  License-Expression: MIT
@@ -21,8 +21,8 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate<0.3,>=0.2
24
- Requires-Dist: ofplang-schedule<0.10,>=0.9
25
- Requires-Dist: ofplang-run<0.9,>=0.8
24
+ Requires-Dist: ofplang-schedule<0.11,>=0.9
25
+ Requires-Dist: ofplang-run<0.12,>=0.11
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
@@ -145,9 +145,29 @@ lc run <workflow> --env <env>
145
145
  [--seconds-per-tick S] [--op-timeout S | --no-op-timeout] [--no-probe]
146
146
  [--max-transport-legs N] [--ignore-resources] [--trace] [--mission-id ID]
147
147
  [--object-ids seeded|real]
148
+
149
+ lc run --jobs <run doc> --env <env> [--on-job-failure continue|stop] [...]
148
150
  ```
149
151
 
150
152
  - `<workflow>` — the portable v0 workflow: *what* happens.
153
+ - `--jobs RUNDOC` — run **several jobs together** in one laboratory (schedule SPEC
154
+ §6.11) in place of the single `<workflow>`. The run document names each job — an `id`,
155
+ its workflow, its own boundary, and when it may start — plus what the laboratory's own
156
+ stocks hold to begin with and which spots it is already holding. They are planned
157
+ *together*, so they compete for the same machines and share a refill neither needs
158
+ alone. Each job's workflow goes through both front doors separately and a rejection
159
+ names the job it came from; a boundary belongs to a job, so `--boundary` is not used
160
+ with `--jobs`.
161
+ 🔴 Object identities are minted **per job**: two jobs of one workflow bind the same
162
+ port names and render the same node paths, so a reproducible generator keyed on those
163
+ alone would give one job's plate the other's `_id`. `--trace` records the job on every
164
+ operation (`ofp.job`) for the same reason — the node alone does not say whose work it
165
+ was. A run of a single workflow names no job and is recorded exactly as it always was.
166
+ - `--on-job-failure continue|stop` — what one job's failure does to the rest of a
167
+ `--jobs` run. `continue` (the default) stops that job alone and lets the others
168
+ finish — which is why they were planned together — while `stop` stops the whole run.
169
+ A stopped job's plate stays where it is, and the plan is made around it rather than
170
+ onto it. A single workflow is a single job, so this makes no difference to it.
151
171
  - `--env` (required) — the labcode environment: the execution environment (spec §5) plus
152
172
  the `x-labcode` extension saying *how* each operation is carried out.
153
173
  - `--boundary DOC` — the whole-workflow I/O as one document: a `boundary:` mapping with a
@@ -101,9 +101,29 @@ lc run <workflow> --env <env>
101
101
  [--seconds-per-tick S] [--op-timeout S | --no-op-timeout] [--no-probe]
102
102
  [--max-transport-legs N] [--ignore-resources] [--trace] [--mission-id ID]
103
103
  [--object-ids seeded|real]
104
+
105
+ lc run --jobs <run doc> --env <env> [--on-job-failure continue|stop] [...]
104
106
  ```
105
107
 
106
108
  - `<workflow>` — the portable v0 workflow: *what* happens.
109
+ - `--jobs RUNDOC` — run **several jobs together** in one laboratory (schedule SPEC
110
+ §6.11) in place of the single `<workflow>`. The run document names each job — an `id`,
111
+ its workflow, its own boundary, and when it may start — plus what the laboratory's own
112
+ stocks hold to begin with and which spots it is already holding. They are planned
113
+ *together*, so they compete for the same machines and share a refill neither needs
114
+ alone. Each job's workflow goes through both front doors separately and a rejection
115
+ names the job it came from; a boundary belongs to a job, so `--boundary` is not used
116
+ with `--jobs`.
117
+ 🔴 Object identities are minted **per job**: two jobs of one workflow bind the same
118
+ port names and render the same node paths, so a reproducible generator keyed on those
119
+ alone would give one job's plate the other's `_id`. `--trace` records the job on every
120
+ operation (`ofp.job`) for the same reason — the node alone does not say whose work it
121
+ was. A run of a single workflow names no job and is recorded exactly as it always was.
122
+ - `--on-job-failure continue|stop` — what one job's failure does to the rest of a
123
+ `--jobs` run. `continue` (the default) stops that job alone and lets the others
124
+ finish — which is why they were planned together — while `stop` stops the whole run.
125
+ A stopped job's plate stays where it is, and the plan is made around it rather than
126
+ onto it. A single workflow is a single job, so this makes no difference to it.
107
127
  - `--env` (required) — the labcode environment: the execution environment (spec §5) plus
108
128
  the `x-labcode` extension saying *how* each operation is carried out.
109
129
  - `--boundary DOC` — the whole-workflow I/O as one document: a `boundary:` mapping with a
@@ -71,6 +71,7 @@ from labcode.probe import (
71
71
  )
72
72
  from labcode.record import (
73
73
  ATTR_DEVICE,
74
+ ATTR_JOB,
74
75
  ATTR_MODE,
75
76
  ATTR_NODE,
76
77
  ATTR_OBJECT_ID,
@@ -408,7 +409,8 @@ class LabcodeBackend(SubprocessBackend):
408
409
  self._record_objects.pop(key, None)
409
410
  self._recorder.op_finished(key, error_type=RUN_STOPPED, message="the run ended first")
410
411
 
411
- def _resolve_model(self, process, mode, inputs, output_schema, definition, node=None):
412
+ def _resolve_model(self, process, mode, inputs, output_schema, definition,
413
+ node=None, job=None):
412
414
  """The value model `_complete` calls at completion: merge the script's *partial*
413
415
  outputs onto the defaults (§1.2), then stamp Object identities (`_id`).
414
416
 
@@ -416,13 +418,20 @@ class LabcodeBackend(SubprocessBackend):
416
418
  defaults); the script's returned values override it. A returned name outside
417
419
  `output_schema` is a runtime failure. `stamp_object_ids` then mints ``_id`` for a
418
420
  created Object and carries it for a mapped one (keyed by `node`, the workflow
419
- provenance). A timed op (no child) or a child error is handled as in the base
420
- backend."""
421
+ provenance, **and by `job`** -- two jobs of one workflow render the same node
422
+ path, so the node alone would mint one identity for two plates). A timed op (no
423
+ child) or a child error is handled as in the base backend.
424
+
425
+ 🔴 Both halves arrive here rather than at dispatch because this is where an
426
+ identity is *made*: the runner offers provenance to a model that declares it
427
+ (`ofplang-run` >= 0.11), and the simulator carries it on the operation from the
428
+ dispatch that started it."""
421
429
  pending = self._pending
422
430
  if not isinstance(pending, dict): # the _TIMED sentinel: no child ran
423
431
  outputs = default_device_model(process, mode, inputs, output_schema, definition)
424
432
  return stamp_object_ids(
425
- outputs, definition, inputs or {}, node, self._id_gen, output_schema
433
+ outputs, definition, inputs or {}, node, self._id_gen, output_schema,
434
+ job=job,
426
435
  )
427
436
  if "error" in pending:
428
437
  err = pending["error"]
@@ -438,7 +447,8 @@ class LabcodeBackend(SubprocessBackend):
438
447
  )
439
448
  base = default_device_model(process, mode, inputs, output_schema, definition)
440
449
  return stamp_object_ids(
441
- {**base, **raw}, definition, inputs or {}, node, self._id_gen, output_schema
450
+ {**base, **raw}, definition, inputs or {}, node, self._id_gen, output_schema,
451
+ job=job,
442
452
  )
443
453
 
444
454
  def advance(self, until: int) -> int:
@@ -484,11 +494,15 @@ class LabcodeBackend(SubprocessBackend):
484
494
 
485
495
  def dispatch_processing(
486
496
  self, process, mode, duration=None, output_schema=None, inputs=None,
487
- definition=None, node=None,
497
+ definition=None, node=None, job=None,
488
498
  ) -> str:
489
499
  attributes: dict[str, Any] = {ATTR_PROCESS: str(process), ATTR_MODE: str(mode)}
490
500
  if node:
491
501
  attributes[ATTR_NODE] = "/".join(str(step) for step in node)
502
+ # Recorded beside the node, and absent where the run names no job: a record of a
503
+ # single workflow says exactly what it always said.
504
+ if job:
505
+ attributes[ATTR_JOB] = str(job)
492
506
  key = self._begin_record(
493
507
  f"{SPAN_PROCESS_PREFIX}{process}", attributes,
494
508
  duration=duration, identities=_object_ids(inputs),
@@ -497,17 +511,22 @@ class LabcodeBackend(SubprocessBackend):
497
511
  with self._recorder.op_active(key):
498
512
  uuid = super().dispatch_processing(
499
513
  process, mode, duration=duration, output_schema=output_schema,
500
- inputs=inputs, definition=definition, node=node,
514
+ inputs=inputs, definition=definition, node=node, job=job,
501
515
  )
502
516
  except BaseException as exc:
503
517
  self._refused(key, exc)
504
518
  raise
505
519
  return self._dispatched(key, uuid)
506
520
 
507
- def dispatch_transport(self, transporter, from_spot, to_spot, duration=None, view=None) -> str:
521
+ def dispatch_transport(self, transporter, from_spot, to_spot, duration=None,
522
+ view=None, job=None) -> str:
508
523
  attributes: dict[str, Any] = {ATTR_SPOT_FROM: str(from_spot), ATTR_SPOT_TO: str(to_spot)}
509
524
  if transporter is not None:
510
525
  attributes[ATTR_TRANSPORTER] = str(transporter)
526
+ # Two jobs of one workflow can move between the same pair of spots, so the route
527
+ # alone does not say whose plate this was.
528
+ if job:
529
+ attributes[ATTR_JOB] = str(job)
511
530
  key = self._begin_record(
512
531
  SPAN_TRANSPORT, attributes, duration=duration, identities=_object_ids(view)
513
532
  )
@@ -516,7 +535,7 @@ class LabcodeBackend(SubprocessBackend):
516
535
  # starts and the transport's own child land under this operation.
517
536
  with self._recorder.op_active(key):
518
537
  uuid = super().dispatch_transport(
519
- transporter, from_spot, to_spot, duration=duration, view=view
538
+ transporter, from_spot, to_spot, duration=duration, view=view, job=job
520
539
  )
521
540
  code = self._transport_resolver(transporter, from_spot, to_spot)
522
541
  if code is not None:
@@ -121,17 +121,31 @@ def entry_object_inputs(workflow: dict) -> dict[str, str]:
121
121
  return result
122
122
 
123
123
 
124
+ def _key(job: str | None, rest: str) -> str:
125
+ """A mint key, prefixed by the job where there is one (SPEC §6.11).
126
+
127
+ 🔴 **Only where there is one.** Two jobs of one workflow bind the same port names
128
+ and render the same node paths, so without the job a reproducible generator gives
129
+ one job's plate the other's identity -- silently, and the same way every run. But a
130
+ run of a *single* workflow names no job, and prefixing there would change every id
131
+ labcode has ever minted for one: the checked-in example observations are that
132
+ invariant, and a run that was reproducible must stay reproducible in the same way.
133
+ """
134
+ return f"job:{job}:{rest}" if job else rest
135
+
136
+
124
137
  def inject_boundary_ids(
125
- boundary: dict | None, workflow: dict, id_gen: IdGenerator
138
+ boundary: dict | None, workflow: dict, id_gen: IdGenerator, job: str | None = None
126
139
  ) -> dict | None:
127
140
  """Return `boundary` with each Object input's view carrying an ``_id``.
128
141
 
129
142
  For every Object-bearing entry input, ensure ``boundary.inputs[port].view`` exists,
130
143
  fill any declared view field the user omitted with a typed default, and mint ``_id``
131
- (keyed by the port) unless one is already present -- so a result boundary fed back in
132
- round-trips its ids. `workflow` must be the ``_id``-injected document (so the view
133
- schema includes ``_id``). Returns `boundary` unchanged when it is None or has no
134
- Object inputs. Mutates a deep copy, not the caller's dict."""
144
+ (keyed by the port, and by `job` where the run names one) unless one is already
145
+ present -- so a result boundary fed back in round-trips its ids. `workflow` must be
146
+ the ``_id``-injected document (so the view schema includes ``_id``). Returns
147
+ `boundary` unchanged when it is None or has no Object inputs. Mutates a deep copy,
148
+ not the caller's dict."""
135
149
  obj_inputs = entry_object_inputs(workflow)
136
150
  if boundary is None or not obj_inputs:
137
151
  return boundary
@@ -149,7 +163,7 @@ def inject_boundary_ids(
149
163
  if field != RESERVED_ID and field not in view:
150
164
  view[field] = _default_field(descriptor)
151
165
  if not view.get(RESERVED_ID):
152
- view[RESERVED_ID] = id_gen.new_id(f"boundary:{port}")
166
+ view[RESERVED_ID] = id_gen.new_id(_key(job, f"boundary:{port}"))
153
167
  desc["view"] = view
154
168
  return out
155
169
 
@@ -191,6 +205,7 @@ def stamp_object_ids(
191
205
  node,
192
206
  id_gen: IdGenerator,
193
207
  output_schema: dict | None = None,
208
+ job: str | None = None,
194
209
  ) -> dict:
195
210
  """Stamp ``_id`` onto a process op's produced Object output views (mutates `outputs`).
196
211
 
@@ -206,10 +221,12 @@ def stamp_object_ids(
206
221
  returned the port explicitly (overwriting the carried view).
207
222
  * A **created** Object output (``objects.create``) whose ``_id`` is empty/absent gets
208
223
  a freshly minted id, keyed by this node instance + port -- so two creates of the
209
- same process (different nodes) get distinct, reproducible ids.
224
+ same process (different nodes) get distinct, reproducible ids -- **and by the job
225
+ where the run names one**, since two jobs of one workflow render the same node
226
+ path and would otherwise mint one identity for two plates.
210
227
 
211
- `node` is the workflow provenance (a node-path tuple, or None); `inputs` are the op's
212
- input views."""
228
+ `node` is the workflow provenance (a node-path tuple, or None) and `job` which job of
229
+ a joint run this is (or None); `inputs` are the op's input views."""
213
230
  created, mapped = _object_output_ports(definition)
214
231
  for port in (*mapped, *created):
215
232
  if not _declares_id(output_schema, port):
@@ -227,5 +244,5 @@ def stamp_object_ids(
227
244
  for port in created:
228
245
  view = outputs.get(port)
229
246
  if isinstance(view, dict) and not view.get(RESERVED_ID):
230
- view[RESERVED_ID] = id_gen.new_id(f"node:{node_key}:{port}")
247
+ view[RESERVED_ID] = id_gen.new_id(_key(job, f"node:{node_key}:{port}"))
231
248
  return outputs
@@ -51,6 +51,11 @@ ATTR_MISSION = "mission.id"
51
51
  #: Where in the workflow this operation came from, and what it was assigned to. These are
52
52
  #: *pointers* into the workflow and environment documents, not copies of them.
53
53
  ATTR_NODE = "ofp.node"
54
+ #: Which job of a joint run (`lc run --jobs`, schedule SPEC §6.11) the operation belongs
55
+ #: to. Absent on a run of a single workflow, which names no job. Recorded beside the node
56
+ #: because the node alone does not identify the work: two jobs of one workflow render the
57
+ #: same node path and can move between the same pair of spots.
58
+ ATTR_JOB = "ofp.job"
54
59
  ATTR_PROCESS = "ofp.process"
55
60
  ATTR_MODE = "ofp.mode"
56
61
  ATTR_SPOT_FROM = "ofp.spot.from"
@@ -28,6 +28,7 @@ import argparse
28
28
  import contextlib
29
29
  import sys
30
30
  from collections.abc import Sequence
31
+ from dataclasses import replace
31
32
  from pathlib import Path
32
33
 
33
34
  import yaml
@@ -37,6 +38,7 @@ from ofplang.run.runner import (
37
38
  ContractSyntaxError,
38
39
  RunnerError,
39
40
  load_document,
41
+ parse_run_document,
40
42
  serialize_document,
41
43
  )
42
44
  from ofplang.run.simulator import SimulatorError
@@ -57,9 +59,28 @@ def _build_parser() -> argparse.ArgumentParser:
57
59
  prog="lc run",
58
60
  description="Run an ofplang v0 workflow on the labcode backend (env x-labcode scripts).",
59
61
  )
60
- p.add_argument("workflow", metavar="WORKFLOW", help="ofplang v0 workflow YAML")
62
+ p.add_argument(
63
+ "workflow", metavar="WORKFLOW", nargs="?",
64
+ help="ofplang v0 workflow YAML (omit when --jobs names a run document)",
65
+ )
66
+ p.add_argument(
67
+ "--jobs", metavar="RUNDOC",
68
+ help="run document (YAML): a `jobs:` list naming each job's id, workflow, "
69
+ "boundary and release, plus the laboratory's own starting `inventories` and "
70
+ "`occupied` spots. The jobs are planned TOGETHER (schedule SPEC §6.11), so they "
71
+ "compete for the same machines and share the refills neither needs alone. Each "
72
+ "job's Object identities are minted per job, so two jobs of one workflow are "
73
+ "told apart in the record and in every `_id`",
74
+ )
61
75
  p.add_argument("--env", required=True, metavar="ENV", help="execution environment YAML (§5)")
62
76
  p.add_argument("--boundary", metavar="DOC", help="run boundary document (§6.8 / value layer)")
77
+ p.add_argument(
78
+ "--on-job-failure", choices=("continue", "stop"), default="continue", metavar="POLICY",
79
+ help="what one job's failure does to the rest of a --jobs run (§6.11): "
80
+ "`continue` (the default) stops that job alone and lets the others finish -- "
81
+ "which is why they were planned together -- while `stop` stops the whole run. A "
82
+ "single workflow is a single job, so this makes no difference to it",
83
+ )
63
84
  p.add_argument("--seed", type=int, metavar="N", help="scheduler random seed")
64
85
  p.add_argument(
65
86
  "--margin", type=int, default=None, metavar="M",
@@ -210,8 +231,24 @@ def main(argv: Sequence[str] | None = None) -> int:
210
231
 
211
232
  args = _build_parser().parse_args(argv)
212
233
 
213
- for label, path in (("workflow", args.workflow), ("environment", args.env)):
214
- if not Path(path).is_file():
234
+ # One form or the other: a workflow, or a run document naming several jobs. A
235
+ # boundary belongs to a job, so a roster carries one per job rather than one for the
236
+ # run -- passing both would be two answers to the same question.
237
+ if bool(args.workflow) == bool(args.jobs):
238
+ print("lc run: give either a WORKFLOW or --jobs RUNDOC, not both", file=sys.stderr)
239
+ return EXIT_USAGE
240
+ if args.jobs and args.boundary:
241
+ print(
242
+ "lc run: --boundary applies to a single workflow; with --jobs each job "
243
+ "carries its own boundary in the run document",
244
+ file=sys.stderr,
245
+ )
246
+ return EXIT_USAGE
247
+
248
+ for label, path in (
249
+ ("workflow", args.workflow), ("run document", args.jobs), ("environment", args.env)
250
+ ):
251
+ if path and not Path(path).is_file():
215
252
  print(f"lc run: {label} not found: {path!r}", file=sys.stderr)
216
253
  return EXIT_USAGE
217
254
 
@@ -242,28 +279,65 @@ def main(argv: Sequence[str] | None = None) -> int:
242
279
  if args.mission_id and not args.trace:
243
280
  print("lc run: warning: --mission-id is only recorded with --trace", file=sys.stderr)
244
281
 
245
- # Shared workflow front door (ofplang-validate + capability gate; validate skippable).
246
- fd = front_door_check(args.workflow, validate=not args.no_validate)
247
- if not fd.ok:
248
- _print_front_door(fd)
249
- return EXIT_USAGE
250
-
251
- # labcode dialect front door: validate the env x-labcode extension (P5) over the
252
- # workflow (for (1)/(2) exclusivity) and the environment. Warnings are printed but do
253
- # not block; errors are a usage error. Use the *expanded* workflow the shared front
254
- # door already resolved (`fd.document`), so `$import` is applied once, no second read
255
- # happens, and imported types/ops are visible to the dialect check and `_id` setup.
256
- workflow_doc = fd.document or {}
257
282
  env_doc, err = _read_document(args.env, "environment")
258
283
  if err is not None:
259
284
  return err
260
- dialect = validate_dialect(workflow_doc, env_doc or {})
261
- for warning in dialect.warnings:
262
- print(f"lc run: warning: {warning}", file=sys.stderr)
263
- if not dialect.ok:
264
- for error in dialect.errors:
265
- print(f"lc run: x-labcode error: {error}", file=sys.stderr)
266
- return EXIT_USAGE
285
+
286
+ run_doc = None
287
+ if args.jobs:
288
+ doc, err = _read_document(args.jobs, "run document")
289
+ if err is not None:
290
+ return err
291
+ try:
292
+ run_doc = parse_run_document(doc or {}, Path(args.jobs).parent)
293
+ except RunnerError as exc:
294
+ print(f"lc run: {exc}", file=sys.stderr)
295
+ return EXIT_USAGE
296
+
297
+ # Both front doors, per job. The shared one (ofplang-validate + capability gate) and
298
+ # the labcode dialect one (the env's x-labcode extension read over *this* workflow,
299
+ # for (1)/(2) exclusivity and the no-op warnings) are about a workflow, so a roster
300
+ # goes through them one job at a time -- and the failing job is named, since several
301
+ # jobs commonly run the same file and a diagnostic that does not say which job it
302
+ # came from sends the reader to the wrong place.
303
+ #
304
+ # The *expanded* workflow the shared front door resolved (`fd.document`) is what goes
305
+ # on: `$import` is applied once, no second read happens, and imported types / ops are
306
+ # visible to the dialect check and to the `_id` setup.
307
+ def _doors(workflow, where: str):
308
+ fd = front_door_check(workflow, validate=not args.no_validate)
309
+ if not fd.ok:
310
+ if where:
311
+ print(f"lc run: {where}:", file=sys.stderr)
312
+ _print_front_door(fd)
313
+ return None
314
+ expanded = fd.document or {}
315
+ dialect = validate_dialect(expanded, env_doc or {})
316
+ prefix = f"{where}: " if where else ""
317
+ for warning in dialect.warnings:
318
+ print(f"lc run: warning: {prefix}{warning}", file=sys.stderr)
319
+ if not dialect.ok:
320
+ for error in dialect.errors:
321
+ print(f"lc run: x-labcode error: {prefix}{error}", file=sys.stderr)
322
+ return None
323
+ return expanded
324
+
325
+ target: object
326
+ if run_doc is not None:
327
+ requests = []
328
+ for request in run_doc.jobs:
329
+ expanded = _doors(request.workflow, f"job {request.id!r}")
330
+ if expanded is None:
331
+ return EXIT_USAGE
332
+ requests.append(replace(request, workflow=expanded))
333
+ target = requests
334
+ workflow_doc: dict = {}
335
+ else:
336
+ expanded = _doors(args.workflow, "")
337
+ if expanded is None:
338
+ return EXIT_USAGE
339
+ workflow_doc = expanded
340
+ target = workflow_doc
267
341
 
268
342
  boundary = None
269
343
  if args.boundary:
@@ -323,7 +397,7 @@ def main(argv: Sequence[str] | None = None) -> int:
323
397
  # declare `_id`, mint the boundary's Object ids, share one IdGenerator with the
324
398
  # backend); it runs the rewritten document in memory (no temp file).
325
399
  result = run_labcode(
326
- workflow_doc,
400
+ target,
327
401
  # The environment document read above, not its path: the dialect front door
328
402
  # already parsed it, and the runner takes a document (ofplang-run >= 0.1.13),
329
403
  # so the file is read once. The plan's `meta.environment` reads `<in-memory>`
@@ -357,6 +431,7 @@ def main(argv: Sequence[str] | None = None) -> int:
357
431
  else FROM_ENVIRONMENT if args.op_timeout is None
358
432
  else args.op_timeout
359
433
  ),
434
+ on_job_failure=args.on_job_failure,
360
435
  trace=args.trace,
361
436
  mission_id=args.mission_id,
362
437
  on_trace_id=report_trace_id,
@@ -389,7 +464,19 @@ def main(argv: Sequence[str] | None = None) -> int:
389
464
  write_err = _emit(result.status, args.output) or write_err
390
465
  if result.failed:
391
466
  failure = result.failure
392
- if failure is not None:
467
+ # A run of named jobs reports one line per job that stopped: they stopped for
468
+ # unrelated reasons and naming only the first would hide the rest. A failure
469
+ # belonging to no job -- an unplannable replan, a refill that failed -- leaves
470
+ # that list empty and falls through to the run-level line, which is also the only
471
+ # line a single workflow ever prints.
472
+ if result.job_failures:
473
+ for job_id, reason in result.job_failures:
474
+ print(
475
+ f"lc run: job {job_id!r} failed: {reason.kind}: {reason.detail}"
476
+ f"{probe_note()}{timeout_note(reason.kind)}",
477
+ file=sys.stderr,
478
+ )
479
+ elif failure is not None:
393
480
  print(
394
481
  f"lc run: execution failed: {failure.kind}: {failure.detail}"
395
482
  f"{probe_note()}{timeout_note(failure.kind)}",
@@ -17,7 +17,8 @@ building block; using it on an Object workflow without this runner fails (no rew
17
17
  from __future__ import annotations
18
18
 
19
19
  import time
20
- from collections.abc import Callable
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import replace
21
22
  from typing import Literal
22
23
 
23
24
  from ofplang.run.app import RunResult
@@ -39,9 +40,17 @@ class LabcodeRunner(RollingRunner):
39
40
 
40
41
  `workflow` and `environment` are each a path or an already-loaded document (the
41
42
  `lc run` front door passes the environment it has already read for its dialect check,
42
- so the file is not read twice). Object types are rewritten to declare ``_id``, the boundary's
43
- Object inputs are minted, and the labcode backend is wired with a shared `IdGenerator`
44
- (default: reproducible, seeded). `seconds_per_tick` / `speed` / `spawn` / `monotonic` /
43
+ so the file is not read twice). `workflow` may instead be a **list of `JobRequest`s**
44
+ -- a run of several jobs planned together (schedule SPEC §6.11), each with its own
45
+ workflow, boundary and release. Everything labcode does to a workflow is then done
46
+ per job, and the identities are minted **per job**: two jobs of one workflow bind the
47
+ same port names and render the same node paths, so a reproducible generator keyed on
48
+ those alone would give one job's plate the other's `_id`. `boundary` must be left
49
+ unset for a roster, each job carrying its own.
50
+
51
+ Object types are rewritten to declare ``_id``, the boundary's Object inputs are
52
+ minted, and the labcode backend is wired with a shared `IdGenerator` (default:
53
+ reproducible, seeded). `seconds_per_tick` / `speed` / `spawn` / `monotonic` /
45
54
  `sleep` configure the wall-clock backend, `probe` / `prober` /
46
55
  `on_availability_change` its availability probing (`labcode.probe`), and `op_timeout`
47
56
  how long one operation may run before it is stopped and failed (default: whatever the
@@ -94,14 +103,13 @@ class LabcodeRunner(RollingRunner):
94
103
  recorder: Recorder | None = None,
95
104
  **rolling_kwargs,
96
105
  ) -> None:
97
- doc = workflow if isinstance(workflow, dict) else load_document(workflow)
98
- if not isinstance(doc, dict):
99
- raise RunnerError("workflow must be a mapping")
100
- clashes = reserved_collisions(doc)
101
- if clashes:
106
+ roster = workflow if isinstance(workflow, Sequence) and not isinstance(
107
+ workflow, (str, bytes)
108
+ ) else None
109
+ if roster is not None and boundary is not None:
102
110
  raise RunnerError(
103
- f"type(s) {clashes} declare the reserved view field '_id'; labcode owns it "
104
- f"as an implicit Object identity and it must not be declared"
111
+ "a run of named jobs carries a boundary per job; pass each job's "
112
+ "boundary on its JobRequest rather than one for the run"
105
113
  )
106
114
  # A recorded run mints **real** ids by default: a reproducible generator gives the same
107
115
  # `_id` to the same port on every run, which is what makes the checked-in example
@@ -120,8 +128,27 @@ class LabcodeRunner(RollingRunner):
120
128
  self._on_trace_id = on_trace_id
121
129
  #: The id an operator can find this run's record by, once `run` has started it.
122
130
  self.trace_id: str | None = None
123
- rewritten = inject_id_field(doc)
124
- boundary = inject_boundary_ids(boundary, rewritten, self.id_generator)
131
+ # The same three steps per job, or once for a lone workflow: refuse a type that
132
+ # declares the reserved field, rewrite the Object types to carry `_id`, and mint
133
+ # the boundary's ids -- keyed by the job where the run names one.
134
+ if roster is None:
135
+ rewritten = self._prepare(workflow)
136
+ boundary = inject_boundary_ids(boundary, rewritten, self.id_generator)
137
+ target: object = rewritten
138
+ else:
139
+ prepared = []
140
+ for request in roster:
141
+ job_doc = self._prepare(request.workflow)
142
+ prepared.append(
143
+ replace(
144
+ request,
145
+ workflow=job_doc,
146
+ boundary=inject_boundary_ids(
147
+ request.boundary, job_doc, self.id_generator, job=request.id
148
+ ),
149
+ )
150
+ )
151
+ target = prepared
125
152
  factory = labcode_backend_factory(
126
153
  seconds_per_tick=seconds_per_tick,
127
154
  speed=speed,
@@ -143,7 +170,7 @@ class LabcodeRunner(RollingRunner):
143
170
  if running_task_margin is None:
144
171
  running_task_margin = poll_interval if poll_interval is not None else 1
145
172
  super().__init__(
146
- rewritten,
173
+ target,
147
174
  environment,
148
175
  boundary,
149
176
  backend_factory=factory,
@@ -152,6 +179,24 @@ class LabcodeRunner(RollingRunner):
152
179
  **rolling_kwargs,
153
180
  )
154
181
 
182
+ def _prepare(self, workflow) -> dict:
183
+ """One workflow, loaded and made a labcode workflow: the reserved-field check
184
+ and the ``_id`` type rewrite.
185
+
186
+ Per job rather than per run, because a roster's jobs need not run the same
187
+ workflow -- and because a job that declares the reserved field should be named
188
+ by the error rather than leaving the reader to find which of several it was."""
189
+ doc = workflow if isinstance(workflow, dict) else load_document(workflow)
190
+ if not isinstance(doc, dict):
191
+ raise RunnerError("workflow must be a mapping")
192
+ clashes = reserved_collisions(doc)
193
+ if clashes:
194
+ raise RunnerError(
195
+ f"type(s) {clashes} declare the reserved view field '_id'; labcode owns it "
196
+ f"as an implicit Object identity and it must not be declared"
197
+ )
198
+ return inject_id_field(doc)
199
+
155
200
  def run(self) -> dict:
156
201
  """Drive the workflow to completion, recording the run around it.
157
202
 
@@ -166,10 +211,38 @@ class LabcodeRunner(RollingRunner):
166
211
  except BaseException as exc:
167
212
  self._finish_record(type(exc).__name__, str(exc))
168
213
  raise
169
- failure = self.failure
170
- self._finish_record(*((failure.kind, failure.detail) if failure else (None, None)))
214
+ self._finish_record(*self._record_reason())
171
215
  return status
172
216
 
217
+ def _record_reason(self) -> tuple[str | None, str | None]:
218
+ """Why the run ended, for the record: one reason, however many jobs had one.
219
+
220
+ A run of a *laboratory* can stop several jobs for unrelated reasons (SPEC §6.11),
221
+ and `--on-job-failure continue` is what makes that the ordinary case rather than
222
+ a curiosity. The record keeps one run rather than one per job, so the reasons are
223
+ gathered into its message -- naming only the first would hide the rest, which is
224
+ the same conclusion the CLI reached about what it prints.
225
+
226
+ The `error_type` stays the run's own, because that is the code a reader filters
227
+ on, and a run of a single workflow is reported exactly as it always was: one job,
228
+ unnamed, one reason.
229
+ """
230
+ failure = self.failure
231
+ jobs = self.jobs
232
+ if len(jobs) == 1 and not jobs[0].id: # a single workflow is a single job
233
+ return (failure.kind, failure.detail) if failure else (None, None)
234
+ per_job = [(job.id, job.failure) for job in jobs if job.failure is not None]
235
+ if not per_job:
236
+ # A failure belonging to no job: a refill that failed, or a replan nothing
237
+ # could be planned from. Neither can be attributed, and both stop everything.
238
+ return (failure.kind, failure.detail) if failure else (None, None)
239
+ return (
240
+ failure.kind if failure is not None else per_job[0][1].kind,
241
+ "; ".join(
242
+ f"{job_id}: {reason.kind}: {reason.detail}" for job_id, reason in per_job
243
+ ),
244
+ )
245
+
173
246
  def _finish_record(self, error_type: str | None, message: str | None) -> None:
174
247
  self.recorder.run_finished(error_type=error_type, message=message)
175
248
  self.recorder.shutdown()
@@ -192,6 +265,11 @@ def run_labcode(
192
265
  """Drive `workflow` on the labcode backend (with Object ``_id`` injected/minted) to
193
266
  completion and return a `RunResult` -- the labcode analogue of `ofplang.run.run_workflow`.
194
267
 
268
+ `workflow` may be a list of `JobRequest`s instead, a run of several jobs planned
269
+ together (schedule SPEC §6.11) -- `lc run --jobs`. `boundary` is then left unset,
270
+ each job carrying its own, and `on_job_failure` says what one job's failure does to
271
+ the rest.
272
+
195
273
  The caller is expected to have validated the workflow (the `lc run` front doors); this
196
274
  runs trusting. The labcode backend holds child processes, so its `close` is called in a
197
275
  `finally` whether the run finished or raised.