ecosemantic-core 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. ecosemantic/backends/filesystem/__init__.py +0 -0
  2. ecosemantic/backends/filesystem/activity.py +103 -0
  3. ecosemantic/backends/filesystem/installs.py +81 -0
  4. ecosemantic/backends/filesystem/py.typed +0 -0
  5. ecosemantic/backends/filesystem/records.py +183 -0
  6. ecosemantic/backends/filesystem/run_folder.py +55 -0
  7. ecosemantic/backends/filesystem/runs.py +95 -0
  8. ecosemantic/backends/filesystem/storage.py +267 -0
  9. ecosemantic/backends/filesystem/watching.py +45 -0
  10. ecosemantic/backends/native/__init__.py +0 -0
  11. ecosemantic/backends/native/calculation.py +446 -0
  12. ecosemantic/backends/native/catalog.py +287 -0
  13. ecosemantic/backends/native/install.py +126 -0
  14. ecosemantic/backends/native/inventory.py +197 -0
  15. ecosemantic/backends/native/py.typed +0 -0
  16. ecosemantic/backends/native/search.py +230 -0
  17. ecosemantic/backends/native/store.py +439 -0
  18. ecosemantic/backends/native/terms.py +43 -0
  19. ecosemantic/backends/retry.py +54 -0
  20. ecosemantic/compute/__init__.py +0 -0
  21. ecosemantic/compute/accounts.py +52 -0
  22. ecosemantic/compute/consequences.py +66 -0
  23. ecosemantic/compute/edits.py +564 -0
  24. ecosemantic/compute/links.py +170 -0
  25. ecosemantic/compute/montecarlo.py +110 -0
  26. ecosemantic/compute/overrides.py +80 -0
  27. ecosemantic/compute/parameters.py +155 -0
  28. ecosemantic/compute/py.typed +0 -0
  29. ecosemantic/compute/render.py +76 -0
  30. ecosemantic/compute/results.py +105 -0
  31. ecosemantic/events/__init__.py +267 -0
  32. ecosemantic/events/py.typed +0 -0
  33. ecosemantic/geometry/__init__.py +175 -0
  34. ecosemantic/geometry/py.typed +0 -0
  35. ecosemantic/layout/__init__.py +304 -0
  36. ecosemantic/layout/py.typed +0 -0
  37. ecosemantic/package/__init__.py +0 -0
  38. ecosemantic/package/background.py +510 -0
  39. ecosemantic/package/base.py +159 -0
  40. ecosemantic/package/base_writer.py +321 -0
  41. ecosemantic/package/compile.py +57 -0
  42. ecosemantic/package/gates.py +58 -0
  43. ecosemantic/package/jsonld.py +699 -0
  44. ecosemantic/package/log.py +165 -0
  45. ecosemantic/package/manifest.py +112 -0
  46. ecosemantic/package/model_package.py +164 -0
  47. ecosemantic/package/olca.py +135 -0
  48. ecosemantic/package/package.py +180 -0
  49. ecosemantic/package/py.typed +0 -0
  50. ecosemantic/package/release.py +266 -0
  51. ecosemantic/package/requests.py +55 -0
  52. ecosemantic/package/resolver.py +56 -0
  53. ecosemantic/package/sidecar.py +122 -0
  54. ecosemantic/package/wholeness.py +148 -0
  55. ecosemantic/package/working.py +275 -0
  56. ecosemantic/ports/__init__.py +0 -0
  57. ecosemantic/ports/activity.py +25 -0
  58. ecosemantic/ports/calculation.py +64 -0
  59. ecosemantic/ports/graph.py +14 -0
  60. ecosemantic/ports/install.py +37 -0
  61. ecosemantic/ports/inventory.py +41 -0
  62. ecosemantic/ports/py.typed +0 -0
  63. ecosemantic/ports/resolver.py +43 -0
  64. ecosemantic/ports/run.py +37 -0
  65. ecosemantic/ports/search.py +37 -0
  66. ecosemantic/ports/storage.py +76 -0
  67. ecosemantic/ports/types.py +938 -0
  68. ecosemantic/quantity/__init__.py +15 -0
  69. ecosemantic/quantity/py.typed +0 -0
  70. ecosemantic/routes/__init__.py +233 -0
  71. ecosemantic/routes/py.typed +0 -0
  72. ecosemantic/scaling/__init__.py +69 -0
  73. ecosemantic/scaling/py.typed +0 -0
  74. ecosemantic/tables/__init__.py +1 -0
  75. ecosemantic/tables/folder.py +147 -0
  76. ecosemantic/tables/py.typed +0 -0
  77. ecosemantic/tables/reader.py +259 -0
  78. ecosemantic/tables/results.py +328 -0
  79. ecosemantic/textmap/__init__.py +231 -0
  80. ecosemantic/textmap/py.typed +0 -0
  81. ecosemantic/troubles/__init__.py +188 -0
  82. ecosemantic/troubles/py.typed +0 -0
  83. ecosemantic_core-0.1.8.dist-info/METADATA +62 -0
  84. ecosemantic_core-0.1.8.dist-info/RECORD +87 -0
  85. ecosemantic_core-0.1.8.dist-info/WHEEL +4 -0
  86. ecosemantic_core-0.1.8.dist-info/licenses/LICENSE +73 -0
  87. ecosemantic_core-0.1.8.dist-info/licenses/NOTICE +13 -0
File without changes
@@ -0,0 +1,103 @@
1
+ """The activity port over a directory: one JSON Lines file per agent under the systems
2
+ directory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import uuid
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from filelock import FileLock
12
+ from pydantic import ValidationError
13
+
14
+ from ecosemantic.errors import NotFound, Unavailable
15
+ from ecosemantic.ports.types import (
16
+ AgentSummary,
17
+ Call,
18
+ CallDraft,
19
+ agents_of,
20
+ checked_agent,
21
+ selected,
22
+ )
23
+
24
+ ACTIVITY_DIR = "activity"
25
+ SUFFIX = ".jsonl"
26
+
27
+
28
+ class FilesystemActivity:
29
+ """Recorded calls as one line each, appended to the file of the agent that made them.
30
+
31
+ Two processes over one directory may serve agents of one name, so a file is appended to and
32
+ read under a lock both take.
33
+ """
34
+
35
+ def __init__(self, root: Path) -> None:
36
+ self.root = root
37
+
38
+ def _folder(self) -> Path:
39
+ return self.root / ACTIVITY_DIR
40
+
41
+ def _lock(self, agent: str) -> FileLock:
42
+ return FileLock(self._folder() / f"{agent}.lock")
43
+
44
+ def _append(self, call: Call) -> Call:
45
+ self._folder().mkdir(parents=True, exist_ok=True)
46
+ with self._lock(call.agent):
47
+ with (self._folder() / f"{call.agent}{SUFFIX}").open("a", encoding="utf-8") as handle:
48
+ handle.write(call.model_dump_json() + "\n")
49
+ return call
50
+
51
+ def _read(self, agent: str | None) -> list[Call]:
52
+ if not self._folder().is_dir():
53
+ return []
54
+ if agent is not None:
55
+ files = [self._folder() / f"{checked_agent(agent)}{SUFFIX}"]
56
+ else:
57
+ files = sorted(self._folder().glob(f"*{SUFFIX}"))
58
+ calls: list[Call] = []
59
+ for file in files:
60
+ if file.is_file():
61
+ with self._lock(file.name.removesuffix(SUFFIX)):
62
+ calls.extend(_parsed(file))
63
+ return calls
64
+
65
+ async def record(self, draft: CallDraft) -> Call:
66
+ call = Call(id=uuid.uuid4(), **draft.model_dump())
67
+ return await asyncio.to_thread(self._append, call)
68
+
69
+ async def list_calls(
70
+ self,
71
+ agent: str | None = None,
72
+ product_system_id: uuid.UUID | None = None,
73
+ after: datetime | None = None,
74
+ ) -> list[Call]:
75
+ calls = await asyncio.to_thread(self._read, agent)
76
+ return selected(calls, agent, product_system_id, after)
77
+
78
+ async def get_call(self, call_id: uuid.UUID) -> Call:
79
+ for call in await asyncio.to_thread(self._read, None):
80
+ if call.id == call_id:
81
+ return call
82
+ raise NotFound(f"no call {call_id}")
83
+
84
+ async def list_agents(self) -> list[AgentSummary]:
85
+ return agents_of(await asyncio.to_thread(self._read, None))
86
+
87
+
88
+ def _parsed(file: Path) -> list[Call]:
89
+ """A file's calls, or Unavailable naming the file, the line and the first field this version
90
+ cannot read in it."""
91
+ calls: list[Call] = []
92
+ for number, line in enumerate(file.read_text(encoding="utf-8").splitlines(), start=1):
93
+ try:
94
+ calls.append(Call.model_validate_json(line))
95
+ except ValidationError as error:
96
+ first = error.errors(include_url=False)[0]
97
+ field = ".".join(str(part) for part in first["loc"])
98
+ raise Unavailable(
99
+ f"{file.name} line {number} cannot be read by this version: "
100
+ f"{field}: {first['msg']}",
101
+ hint=f"remove {file} to read the activity again",
102
+ ) from error
103
+ return calls
@@ -0,0 +1,81 @@
1
+ """The install port over a directory: one folder per install under the data directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from collections.abc import AsyncIterator
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from filelock import AsyncFileLock
12
+
13
+ from ecosemantic.backends.filesystem.records import RecordFolder, newest_first
14
+ from ecosemantic.ports.types import Install, InstallDraft, InstallPatch
15
+
16
+ INSTALLS_DIR = "installs"
17
+ INSTALL_FILE = "install.json"
18
+ UPLOADS_DIR = "uploads"
19
+
20
+
21
+ def install_dir(root: Path, install_id: uuid.UUID) -> Path:
22
+ """The directory of one install under the data directory, whether or not it exists yet."""
23
+ return root / INSTALLS_DIR / str(install_id)
24
+
25
+
26
+ def uploads_dir(root: Path) -> Path:
27
+ """The directory a package uploaded for an install is written to before that install reads
28
+ it."""
29
+ return root / INSTALLS_DIR / UPLOADS_DIR
30
+
31
+
32
+ class FilesystemInstalls:
33
+ """Install records as one JSON file per install, newest first by the time they were posted.
34
+
35
+ The install process writes a record from its own process while the server reads it, so a
36
+ record is replaced whole and every change is read and written under a lock both take.
37
+ """
38
+
39
+ def __init__(self, root: Path) -> None:
40
+ self.root = root
41
+ self.records = RecordFolder(root, INSTALLS_DIR, INSTALL_FILE, Install, "install")
42
+
43
+ def read_record(self, install_id: uuid.UUID) -> Install:
44
+ """One install, read synchronously; the install process's callback runs off the event
45
+ loop."""
46
+ return self.records.read(install_id)
47
+
48
+ def apply_patch(self, install_id: uuid.UUID, patch: InstallPatch) -> Install:
49
+ """The record with the patch's named fields applied, written synchronously."""
50
+ return self.records.patch(install_id, patch.applied_to)
51
+
52
+ async def post_install(self, draft: InstallDraft) -> Install:
53
+ def construct(posted_at: datetime) -> Install:
54
+ return Install(id=uuid.uuid4(), draft=draft, posted_at=posted_at)
55
+
56
+ return await asyncio.to_thread(self.records.post, construct)
57
+
58
+ async def get_install(self, install_id: uuid.UUID) -> Install:
59
+ return await asyncio.to_thread(self.read_record, install_id)
60
+
61
+ async def list_installs(self) -> list[Install]:
62
+ return newest_first(await asyncio.to_thread(self.records.all))
63
+
64
+ async def cancel_install(self, install_id: uuid.UUID) -> Install:
65
+ def ask() -> Install:
66
+ return self.records.patch(
67
+ install_id, lambda install: install.cancellation().applied_to(install)
68
+ )
69
+
70
+ return await asyncio.to_thread(ask)
71
+
72
+ async def update_install(self, install_id: uuid.UUID, patch: InstallPatch) -> Install:
73
+ return await asyncio.to_thread(self.apply_patch, install_id, patch)
74
+
75
+ def slot(self) -> AsyncFileLock:
76
+ """The workspace's one-install slot, which every process over this directory shares."""
77
+ return self.records.slot()
78
+
79
+ def changes(self) -> AsyncIterator[uuid.UUID]:
80
+ """Every install any process wrote, from the moment this is called."""
81
+ return self.records.changed()
File without changes
@@ -0,0 +1,183 @@
1
+ """A record folder: one directory per record under its kind's directory, the record replaced
2
+ whole under a lock both processes take."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import uuid
8
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable
9
+ from datetime import UTC, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Protocol, cast
12
+
13
+ from filelock import AsyncFileLock, FileLock
14
+ from pydantic import BaseModel, ValidationError
15
+
16
+ from ecosemantic.backends.filesystem.watching import touched
17
+ from ecosemantic.backends.retry import read_bytes, replace
18
+ from ecosemantic.errors import NotFound, Unavailable
19
+
20
+ SLOT_FILE = "slot.lock"
21
+ POST_LOCK = "post.lock"
22
+
23
+
24
+ class Posted(Protocol):
25
+ """What a record carries for a listing: its id, and the time it was posted."""
26
+
27
+ id: uuid.UUID
28
+ posted_at: datetime
29
+
30
+
31
+ def newest_first[T: Posted](records: Iterable[T]) -> list[T]:
32
+ """Records newest first, the id breaking a tie between two posted at once."""
33
+ return sorted(records, key=lambda record: (record.posted_at, str(record.id)), reverse=True)
34
+
35
+
36
+ def now() -> datetime:
37
+ """The current instant in UTC, the clock a record's posted time is measured from."""
38
+ return datetime.now(UTC)
39
+
40
+
41
+ def next_posted_at(now: datetime, newest: datetime | None) -> datetime:
42
+ """A new record's posted time, newest-first independent of the clock's resolution: the
43
+ clock, or one microsecond past the store's newest when the clock would tie two."""
44
+ if newest is None:
45
+ return now
46
+ return max(now, newest + timedelta(microseconds=1))
47
+
48
+
49
+ class RecordFolder[T: BaseModel]:
50
+ """The records of one kind, as one JSON file per record in a folder of its own.
51
+
52
+ A record is written from another process while this one reads it, so it is replaced whole,
53
+ never rewritten in place, and every change is read and written under a lock both take.
54
+ """
55
+
56
+ def __init__(self, root: Path, kind: str, record_file: str, model: type[T], noun: str) -> None:
57
+ self.root = root
58
+ self.kind = kind
59
+ self.record_file = record_file
60
+ self.model = model
61
+ self.noun = noun
62
+
63
+ def folder(self, record_id: uuid.UUID) -> Path:
64
+ """The directory of one record under its kind's directory, whether or not it exists."""
65
+ return self.root / self.kind / str(record_id)
66
+
67
+ def lock(self, record_id: uuid.UUID) -> FileLock:
68
+ """The record's file lock, taken by every read-and-write of it, from either process."""
69
+ folder = self.root / self.kind
70
+ folder.mkdir(parents=True, exist_ok=True)
71
+ return FileLock(folder / f"{record_id}.lock")
72
+
73
+ def post_lock(self) -> FileLock:
74
+ """The lock every poster of this kind takes, so two cannot mint the same posted time."""
75
+ folder = self.root / self.kind
76
+ folder.mkdir(parents=True, exist_ok=True)
77
+ return FileLock(folder / POST_LOCK)
78
+
79
+ def read(self, record_id: uuid.UUID) -> T:
80
+ """One record, or NotFound when no folder holds it."""
81
+ record = self.folder(record_id) / self.record_file
82
+ if not record.is_file():
83
+ raise NotFound(f"no {self.noun} {record_id}")
84
+ return self._parsed(record)
85
+
86
+ def write(self, record_id: uuid.UUID, record: T) -> T:
87
+ """The record written, staged beside its file and moved over it within the bounded
88
+ retry, so a reader sees the whole of one version or the whole of the other."""
89
+ folder = self.folder(record_id)
90
+ folder.mkdir(parents=True, exist_ok=True)
91
+ staged = folder / f"{self.record_file}.tmp"
92
+ staged.write_bytes(record.model_dump_json(indent=1).encode("utf-8"))
93
+ replace(staged, folder / self.record_file)
94
+ return record
95
+
96
+ def patch(self, record_id: uuid.UUID, change: Callable[[T], T]) -> T:
97
+ """The record with the change applied, read and written under its lock."""
98
+ with self.lock(record_id):
99
+ return self.write(record_id, change(self.read(record_id)))
100
+
101
+ def post(self, construct: Callable[[datetime], T]) -> T:
102
+ """A new record posted under the kind's lock, its posted time advanced past the newest
103
+ stored so two posters cannot mint the same instant."""
104
+ with self.post_lock():
105
+ record = construct(self._next_posted_at())
106
+ return self.write(cast(Posted, record).id, record)
107
+
108
+ def _next_posted_at(self) -> datetime:
109
+ """The posted time of the next record of this kind: the clock, advanced past the newest."""
110
+ newest = max(
111
+ (record.posted_at for record in cast(Iterable[Posted], self.all())), default=None
112
+ )
113
+ return next_posted_at(now(), newest)
114
+
115
+ def all(self) -> list[T]:
116
+ """Every record of this kind."""
117
+ folder = self.root / self.kind
118
+ if not folder.is_dir():
119
+ return []
120
+ return [
121
+ self._parsed(path / self.record_file)
122
+ for path in folder.iterdir()
123
+ if (path / self.record_file).is_file()
124
+ ]
125
+
126
+ def changed(self) -> AsyncGenerator[uuid.UUID]:
127
+ """Every record any process wrote under this kind's directory, from the moment this is
128
+ called: the watch is armed eagerly, so a write between this call and the first read is
129
+ announced."""
130
+ queue: asyncio.Queue[uuid.UUID] = asyncio.Queue()
131
+ watching = asyncio.create_task(self._watch(touched(self.root / self.kind), queue))
132
+
133
+ async def iterate() -> AsyncGenerator[uuid.UUID]:
134
+ try:
135
+ while True:
136
+ yield await queue.get()
137
+ finally:
138
+ watching.cancel()
139
+
140
+ return iterate()
141
+
142
+ async def _watch(
143
+ self, batches: AsyncIterator[set[Path]], queue: asyncio.Queue[uuid.UUID]
144
+ ) -> None:
145
+ async for batch in batches:
146
+ found = {record for path in batch if (record := self._record_of(path)) is not None}
147
+ for record_id in sorted(found, key=str):
148
+ if (self.folder(record_id) / self.record_file).is_file():
149
+ queue.put_nowait(record_id)
150
+
151
+ def _record_of(self, path: Path) -> uuid.UUID | None:
152
+ """The record a written path belongs to, or none for a path that names no record."""
153
+ folder = self.root / self.kind
154
+ try:
155
+ first = path.resolve().relative_to(folder.resolve()).parts[0]
156
+ except (ValueError, IndexError):
157
+ return None
158
+ try:
159
+ return uuid.UUID(first)
160
+ except ValueError:
161
+ return None
162
+
163
+ def slot(self) -> AsyncFileLock:
164
+ """The single slot every process over this directory takes, released when its holder's
165
+ process ends. Each try is non-blocking on the event loop, so a caller cancelled while it
166
+ waits holds nothing."""
167
+ folder = self.root / self.kind
168
+ folder.mkdir(parents=True, exist_ok=True)
169
+ return AsyncFileLock(folder / SLOT_FILE, run_in_executor=False)
170
+
171
+ def _parsed(self, record: Path) -> T:
172
+ """The record parsed, or Unavailable naming it and the first field this version cannot
173
+ read in it."""
174
+ try:
175
+ return self.model.model_validate_json(read_bytes(record))
176
+ except ValidationError as error:
177
+ first = error.errors(include_url=False)[0]
178
+ field = ".".join(str(part) for part in first["loc"])
179
+ raise Unavailable(
180
+ f"{self.noun} {record.parent.name} cannot be read by this version: "
181
+ f"{field}: {first['msg']}",
182
+ hint=f"remove {record.parent} to list and start {self.noun}s again",
183
+ ) from error
@@ -0,0 +1,55 @@
1
+ """Where a run lives on disk: one directory per run under the systems directory, and the
2
+ heartbeat its worker beats there."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ import uuid
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from threading import Event, Thread
12
+
13
+ RUNS_DIR = "runs"
14
+ RECORD_FILE = "run.json"
15
+ LOG_FILE = "worker.log"
16
+ HEARTBEAT_FILE = "heartbeat"
17
+
18
+
19
+ def run_dir(root: Path, run_id: uuid.UUID) -> Path:
20
+ """The directory of one run under a systems directory, whether or not it exists yet."""
21
+ return root / RUNS_DIR / str(run_id)
22
+
23
+
24
+ def beat(folder: Path) -> None:
25
+ """Mark the worker carrying the run in this folder as alive now, by the heartbeat file's
26
+ modification time."""
27
+ (folder / HEARTBEAT_FILE).touch()
28
+
29
+
30
+ def heartbeat_age(folder: Path) -> float | None:
31
+ """Seconds since the worker carrying the run in this folder last beat, or None when it never
32
+ has."""
33
+ heartbeat = folder / HEARTBEAT_FILE
34
+ if not heartbeat.is_file():
35
+ return None
36
+ return time.time() - heartbeat.stat().st_mtime
37
+
38
+
39
+ @contextmanager
40
+ def beating(folder: Path, every: float) -> Iterator[None]:
41
+ """Beat this folder's heartbeat every `every` seconds until the block ends, so a step that
42
+ reports nothing does not look like a process that is gone."""
43
+ stop = Event()
44
+
45
+ def keep_beating() -> None:
46
+ while not stop.wait(every):
47
+ beat(folder)
48
+
49
+ beater = Thread(target=keep_beating, daemon=True)
50
+ beater.start()
51
+ try:
52
+ yield
53
+ finally:
54
+ stop.set()
55
+ beater.join()
@@ -0,0 +1,95 @@
1
+ """The run port over a directory: one folder per run under the systems directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import shutil
7
+ import uuid
8
+ from collections.abc import AsyncIterator
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from filelock import AsyncFileLock
13
+
14
+ from ecosemantic.backends.filesystem.records import RecordFolder, newest_first
15
+ from ecosemantic.backends.filesystem.run_folder import RECORD_FILE, RUNS_DIR
16
+ from ecosemantic.ports.types import Run, RunDraft, RunPatch, StoreChange, refuse_open
17
+
18
+
19
+ class FilesystemRuns:
20
+ """Run records as one JSON file per run, newest first by the time they were posted.
21
+
22
+ The worker writes a record from its own process while the server reads it, so a record is
23
+ replaced whole, never rewritten in place, and every change is read and written under a lock
24
+ both processes take.
25
+ """
26
+
27
+ def __init__(self, root: Path) -> None:
28
+ self.root = root
29
+ self.records = RecordFolder(root, RUNS_DIR, RECORD_FILE, Run, "run")
30
+
31
+ def read_record(self, run_id: uuid.UUID) -> Run:
32
+ """One run, read synchronously; the worker's callback runs off the event loop."""
33
+ return self.records.read(run_id)
34
+
35
+ def apply_patch(self, run_id: uuid.UUID, patch: RunPatch) -> Run:
36
+ """The record with the patch's named fields applied, written synchronously."""
37
+ with self.records.lock(run_id):
38
+ return self.records.write(run_id, patch.applied_to(self.read_record(run_id)))
39
+
40
+ async def post_run(self, draft: RunDraft) -> Run:
41
+ def construct(posted_at: datetime) -> Run:
42
+ return Run(id=uuid.uuid4(), posted_at=posted_at, **draft.model_dump())
43
+
44
+ return await asyncio.to_thread(self.records.post, construct)
45
+
46
+ async def get_run(self, run_id: uuid.UUID) -> Run:
47
+ return await asyncio.to_thread(self.read_record, run_id)
48
+
49
+ async def list_runs(self, product_system_id: uuid.UUID | None = None) -> list[Run]:
50
+ runs = newest_first(await asyncio.to_thread(self.records.all))
51
+ if product_system_id is None:
52
+ return runs
53
+ return [run for run in runs if run.product_system_id == product_system_id]
54
+
55
+ async def cancel_run(self, run_id: uuid.UUID) -> Run:
56
+ def ask() -> Run:
57
+ return self.records.patch(run_id, lambda run: run.cancellation().applied_to(run))
58
+
59
+ return await asyncio.to_thread(ask)
60
+
61
+ async def update_run(self, run_id: uuid.UUID, patch: RunPatch) -> Run:
62
+ return await asyncio.to_thread(self.apply_patch, run_id, patch)
63
+
64
+ async def delete_runs(self, product_system_id: uuid.UUID) -> None:
65
+ """Remove every run of a product system, record and folder; Conflict while one is
66
+ open, since its worker still writes it."""
67
+
68
+ def remove() -> None:
69
+ owned = [r for r in self.records.all() if r.product_system_id == product_system_id]
70
+ refuse_open(owned)
71
+ for run in owned:
72
+ with self.records.lock(run.id):
73
+ shutil.rmtree(self.records.folder(run.id))
74
+
75
+ await asyncio.to_thread(remove)
76
+
77
+ def changes(self) -> AsyncIterator[StoreChange]:
78
+ """What any process wrote under the runs directory, from the moment this is called: the
79
+ watch is armed eagerly, so a write between this call and the first read is announced."""
80
+ found = self.records.changed()
81
+
82
+ async def iterate() -> AsyncIterator[StoreChange]:
83
+ try:
84
+ async for run_id in found:
85
+ run = await asyncio.to_thread(self.read_record, run_id)
86
+ yield StoreChange(product_system_id=run.product_system_id, run_id=run.id)
87
+ finally:
88
+ await found.aclose()
89
+
90
+ return iterate()
91
+
92
+ def slot(self) -> AsyncFileLock:
93
+ """The workspace's one-run slot: a file lock every process over this directory takes,
94
+ released when its holder's process ends."""
95
+ return self.records.slot()