labcode 0.6.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.
- {labcode-0.6.0 → labcode-0.7.0}/PKG-INFO +22 -2
- {labcode-0.6.0 → labcode-0.7.0}/README.md +20 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/backend.py +28 -9
- {labcode-0.6.0 → labcode-0.7.0}/labcode/objectid.py +27 -10
- {labcode-0.6.0 → labcode-0.7.0}/labcode/record.py +5 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/run_cli.py +111 -24
- {labcode-0.6.0 → labcode-0.7.0}/labcode/runner.py +94 -16
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/PKG-INFO +22 -2
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/SOURCES.txt +2 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/requires.txt +1 -1
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/scm_file_list.json +2 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/scm_version.json +2 -2
- {labcode-0.6.0 → labcode-0.7.0}/pyproject.toml +11 -6
- labcode-0.7.0/tests/fixtures/joint.run.yaml +6 -0
- labcode-0.7.0/tests/test_joint.py +265 -0
- {labcode-0.6.0 → labcode-0.7.0}/.github/workflows/ci.yml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/.github/workflows/publish.yml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/.gitignore +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/LICENSE +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/MANIFEST.in +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/SPECIFICATIONS.md +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/README.md +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/plate_line.boundary.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/plate_line.observation.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/plate_line.plan.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/plate_line.svg +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/outputs/sila2_plate_cycle.svg +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/plate_line.boundary.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/plate_line.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/plate_line.workflow.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/preflight_sila2_env.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/render_plate_line.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/render_sila2_plate_cycle.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/run_all_sila2_examples.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/run_sila2_plate_cycle.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/run_sila2_plate_cycle_no_atc.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/run_sila2_seal.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_plate_cycle.boundary.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_plate_cycle.workflow.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_plate_cycle_no_atc.boundary.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_plate_cycle_no_atc.workflow.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_plate_cycle_no_atc.wrapped.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_seal.boundary.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_seal.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_seal.workflow.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/examples/sila2_seal.wrapped.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/__init__.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/__main__.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/_child.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/cli.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/dialect.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/extension.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/idgen.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/otel.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/otel_sila2.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/probe.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/py.typed +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/sila2.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/sila2_commands.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode/sila2_instrument.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/dependency_links.txt +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/entry_points.txt +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/labcode.egg-info/top_level.txt +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/setup.cfg +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/device_script.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/device_script.workflow.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/replenishment.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/reroute_device.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/reroute_transporter.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/transport.env.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/fixtures/transport.workflow.yaml +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_backend.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_cli.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_dialect.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_objectid.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_otel.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_otel_child.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_otel_grpc.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_otel_sila2.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_probe.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_record.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_recording.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_run_cli.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_sila2.py +0 -0
- {labcode-0.6.0 → labcode-0.7.0}/tests/test_sila2_commands.py +0 -0
- {labcode-0.6.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.
|
|
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
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: ofplang-validate<0.3,>=0.2
|
|
24
24
|
Requires-Dist: ofplang-schedule<0.11,>=0.9
|
|
25
|
-
Requires-Dist: ofplang-run<0.
|
|
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,
|
|
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
|
|
420
|
-
|
|
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,
|
|
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
|
|
132
|
-
round-trips its ids. `workflow` must be
|
|
133
|
-
schema includes ``_id``). Returns
|
|
134
|
-
Object inputs. Mutates a deep copy,
|
|
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)
|
|
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(
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|