pi-durable-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,357 @@
1
+ """Submission admission, waits, and withdrawal (spec section 6).
2
+
3
+ ``Conversation.submit()`` durably admits an input or passive write in one commit; the returned
4
+ handle tracks its settlement. Admission decides between immediate placement and the inbox; a
5
+ boundary later places queued items, and run control lives in ``pi.live``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable, Mapping
11
+ from typing import Any, Literal, cast
12
+
13
+ from ..context import Context
14
+ from ..errors import ConversationBusy
15
+ from ..ids import ConversationId, SubmissionId
16
+ from ..json import JsonValue, copy_json
17
+ from ..session.drafts import DocumentDraft
18
+ from ..session.transaction import Transaction
19
+ from ..types import EntryDraft, SubmissionCreate, SubmissionRecord
20
+ from .inbox import (
21
+ InboxDoc,
22
+ QueueModes,
23
+ append_input,
24
+ apply_boundary,
25
+ has_queued_reset,
26
+ is_display_only,
27
+ is_stale,
28
+ pick_input_entry,
29
+ prepare_boundary,
30
+ remove_inbox_item,
31
+ )
32
+ from .live import LiveDoc
33
+ from .types import InputEntry, SettledSubmissionRecord, Submission, SubmissionDraft
34
+ from .util import Waiters, closed_error, resolved
35
+
36
+ AbortResult = Literal["aborted", "already_placed", "settled"]
37
+
38
+ StartRun = Callable[[Transaction, ConversationId, DocumentDraft, list[SubmissionId]], Awaitable[None]]
39
+
40
+
41
+ def _type(draft: Mapping[str, Any]) -> Any:
42
+ return draft.get("type")
43
+
44
+
45
+ def _request_id(draft: Mapping[str, Any]) -> str | None:
46
+ value = draft.get("request_id", draft.get("requestId"))
47
+ if value is None:
48
+ return None
49
+ if not isinstance(value, str):
50
+ raise TypeError("A submission request ID must be a string")
51
+ return value
52
+
53
+
54
+ def _when_busy(draft: Mapping[str, Any]) -> str | None:
55
+ return draft.get("when_busy", draft.get("whenBusy"))
56
+
57
+
58
+ def is_settled(record: SubmissionRecord) -> bool:
59
+ """Whether a submission reached ``done`` or ``unanswered``."""
60
+
61
+ return record["status"] in ("done", "unanswered")
62
+
63
+
64
+ def check_input_entry(entry: InputEntry) -> None:
65
+ """Reject an input entry whose kind is not a non-empty string outside the ``pi.`` prefix."""
66
+
67
+ kind = entry.get("kind")
68
+ if not isinstance(kind, str) or not kind or kind.startswith("pi."):
69
+ raise TypeError(
70
+ "An input entry's kind must be a non-empty string not starting with \"pi.\", "
71
+ f"got {kind!r}"
72
+ )
73
+
74
+
75
+ def _clean_entry(entry: Mapping[str, Any]) -> dict[str, JsonValue]:
76
+ """Store the fields the caller supplied as strict JSON, preserving explicit JSON nulls."""
77
+
78
+ return {key: copy_json(value) for key, value in entry.items()}
79
+
80
+
81
+ class Submissions:
82
+ """Admission, waits, and withdrawal of the durable submissions of one Harness."""
83
+
84
+ def __init__(
85
+ self,
86
+ session: Any,
87
+ now: Callable[[], int],
88
+ queue_modes: Callable[[], QueueModes],
89
+ resume: Callable[[], None],
90
+ start_run: StartRun,
91
+ ) -> None:
92
+ self._session = session
93
+ self._now = now
94
+ self._queue_modes = queue_modes
95
+ self._resume = resume
96
+ self._start_run = start_run
97
+ self._waiters: Waiters[SubmissionId, SettledSubmissionRecord] = Waiters()
98
+ self._closed = False
99
+ session.observe_commits(self._observe)
100
+ session.subscribe_close(self._close)
101
+
102
+ async def submit(
103
+ self, conversation_id: ConversationId, draft: SubmissionDraft, context: Context
104
+ ) -> Submission:
105
+ self._resume()
106
+ value = cast(Mapping[str, Any], draft)
107
+
108
+ async def admit(tx: Transaction) -> SubmissionId:
109
+ return await admit_submission(
110
+ tx,
111
+ conversation_id,
112
+ value,
113
+ self._now(),
114
+ self._queue_modes(),
115
+ self._start_run,
116
+ )
117
+
118
+ id = await self._session.commit_with(admit, context)
119
+ return SubmissionHandle(cast(SubmissionId, id), self)
120
+
121
+ async def _record(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
122
+ return await self._session.read_on_line(
123
+ lambda: self._session.storage.submission(id, context)
124
+ )
125
+
126
+ async def get(self, id: SubmissionId, context: Context) -> Submission | None:
127
+ record = await self._record(id, context)
128
+ return None if record is None else SubmissionHandle(record["id"], self)
129
+
130
+ async def status(self, id: SubmissionId, context: Context) -> SubmissionRecord:
131
+ record = await self._record(id, context)
132
+ if record is None:
133
+ raise ValueError(f"Submission {id} does not exist")
134
+ return record
135
+
136
+ async def wait(self, id: SubmissionId, context: Context) -> SettledSubmissionRecord:
137
+ self._resume()
138
+
139
+ async def find() -> dict[str, Any]:
140
+ record = await self._session.storage.submission(id, context)
141
+ if record is None:
142
+ raise ValueError(f"Submission {id} does not exist")
143
+ if is_settled(record):
144
+ return {"promise": resolved(cast(SettledSubmissionRecord, record))}
145
+ if self._closed:
146
+ raise closed_error(self._session)
147
+ return {"promise": self._waiters.add(id, context)}
148
+
149
+ found = await self._session.read_on_line(find)
150
+ return cast(SettledSubmissionRecord, await found["promise"])
151
+
152
+ async def abort(
153
+ self, id: SubmissionId, context: Context, conversation_id: ConversationId | None = None
154
+ ) -> AbortResult | Literal["not_found"]:
155
+ async def withdraw(tx: Transaction) -> AbortResult | Literal["not_found"]:
156
+ record = await tx.submission(id)
157
+ if record is None or (
158
+ conversation_id is not None and record["conversationId"] != conversation_id
159
+ ):
160
+ return "not_found"
161
+ if record["status"] == "queued":
162
+ tx.settle_submission(id, {"status": "unanswered", "reason": "aborted"})
163
+ await remove_inbox_item(tx, record["conversationId"], id)
164
+ return "aborted"
165
+ return "already_placed" if record["status"] == "placed" else "settled"
166
+
167
+ return await self._session.commit_with(withdraw, context)
168
+
169
+ def _observe(self, publication: Any, _context: Context) -> None:
170
+ for change in publication["changes"]:
171
+ if change["type"] != "submission":
172
+ continue
173
+ value = change["value"]
174
+ if is_settled(value):
175
+ self._waiters.resolve(value["id"], cast(SettledSubmissionRecord, value))
176
+
177
+ def _close(self) -> None:
178
+ self._closed = True
179
+ self._waiters.reject_all(closed_error(self._session))
180
+
181
+
182
+ class SubmissionHandle:
183
+ """One admitted submission, tracked by the Harness that returned it."""
184
+
185
+ __slots__ = ("id", "_submissions")
186
+
187
+ def __init__(self, id: SubmissionId, submissions: Submissions) -> None:
188
+ self.id = id
189
+ self._submissions = submissions
190
+
191
+ async def status(self, context: Context) -> SubmissionRecord:
192
+ return await self._submissions.status(self.id, context)
193
+
194
+ async def wait(self, context: Context) -> SettledSubmissionRecord:
195
+ return await self._submissions.wait(self.id, context)
196
+
197
+ async def abort(self, context: Context) -> AbortResult:
198
+ result = await self._submissions.abort(self.id, context)
199
+ if result == "not_found":
200
+ raise ValueError(f"Submission {self.id} does not exist")
201
+ return result
202
+
203
+
204
+ async def admit_submission(
205
+ tx: Transaction,
206
+ conversation_id: ConversationId,
207
+ draft: Mapping[str, Any],
208
+ now: int,
209
+ queue_modes: QueueModes,
210
+ start_run: StartRun,
211
+ ) -> SubmissionId:
212
+ """Admit one submission inside a commit (spec section 6)."""
213
+
214
+ kind = _type(draft)
215
+ if kind not in ("input", "write"):
216
+ raise TypeError(f"Submission draft type must be 'input' or 'write', got {kind!r}")
217
+ entry = draft.get("entry")
218
+ if kind == "input" and entry is not None:
219
+ if not isinstance(entry, Mapping):
220
+ raise TypeError("An input entry must be an object")
221
+ check_input_entry(cast(InputEntry, entry))
222
+ request_id = _request_id(draft)
223
+ if request_id is not None:
224
+ existing = await tx.submission_by_request(conversation_id, request_id)
225
+ if existing is not None:
226
+ if existing["type"] != kind:
227
+ raise ValueError(
228
+ f"Request {request_id} already identifies a submission of type {existing['type']}"
229
+ )
230
+ return existing["id"]
231
+ request = {} if request_id is None else {"requestId": request_id}
232
+
233
+ display_only = False
234
+ if kind == "write":
235
+ if not isinstance(entry, Mapping):
236
+ raise TypeError("A write submission requires an entry draft")
237
+ inbox = await tx.doc(InboxDoc, conversation_id)
238
+ display_only = is_display_only(cast(Any, entry))
239
+ if display_only and not has_queued_reset(inbox["items"]):
240
+ return await _place_write(tx, conversation_id, request, entry)
241
+
242
+ live = await tx.doc(LiveDoc, conversation_id)
243
+ busy = live.get("run") is not None
244
+ when_busy = _when_busy(draft)
245
+ if busy and kind == "input" and when_busy == "reject":
246
+ raise ConversationBusy(conversation_id)
247
+
248
+ boundary = None if busy else await prepare_boundary(tx, conversation_id, queue_modes)
249
+ if boundary is None or len(boundary.inbox["items"]) > 0:
250
+ inbox = boundary.inbox if boundary is not None else await tx.doc(InboxDoc, conversation_id)
251
+ record = await tx.create_submission(
252
+ cast(
253
+ SubmissionCreate,
254
+ {
255
+ "conversationId": conversation_id,
256
+ **request,
257
+ "type": kind,
258
+ "status": "queued",
259
+ },
260
+ )
261
+ )
262
+ id = record["id"]
263
+ if kind == "write":
264
+ inbox["items"].append(
265
+ {"id": id, "mode": "write", "entry": _clean_entry(cast(Mapping[str, Any], entry))}
266
+ )
267
+ else:
268
+ mode = "steer" if when_busy == "steer" else "followUp"
269
+ item: dict[str, Any] = {
270
+ "id": id,
271
+ "mode": mode,
272
+ "content": copy_json(draft.get("content")),
273
+ }
274
+ if entry is not None:
275
+ item["entry"] = pick_input_entry(cast(InputEntry, entry))
276
+ inbox["items"].append(item)
277
+ if boundary is None or display_only:
278
+ return id
279
+ placed = await apply_boundary(tx, boundary, "final", now)
280
+ if placed.users:
281
+ await start_run(tx, conversation_id, live, placed.users)
282
+ return id
283
+
284
+ entry = cast(Mapping[str, Any], entry) if entry is not None else None
285
+ if kind == "write":
286
+ assert entry is not None
287
+ if is_stale(boundary, cast(Any, entry)):
288
+ record = await tx.create_submission(
289
+ cast(
290
+ SubmissionCreate,
291
+ {
292
+ "conversationId": conversation_id,
293
+ **request,
294
+ "type": "write",
295
+ "status": "unanswered",
296
+ "reason": "stale",
297
+ },
298
+ )
299
+ )
300
+ return record["id"]
301
+ return await _place_write(tx, conversation_id, request, entry)
302
+
303
+ placed_entry = await append_input(
304
+ tx,
305
+ conversation_id,
306
+ copy_json(draft.get("content")),
307
+ None if entry is None else cast(InputEntry, entry),
308
+ now,
309
+ )
310
+ record = await tx.create_submission(
311
+ cast(
312
+ SubmissionCreate,
313
+ {
314
+ "conversationId": conversation_id,
315
+ **request,
316
+ "type": "input",
317
+ "status": "placed",
318
+ "entry": placed_entry["id"],
319
+ },
320
+ )
321
+ )
322
+ await start_run(tx, conversation_id, live, [record["id"]])
323
+ return record["id"]
324
+
325
+
326
+ async def _place_write(
327
+ tx: Transaction,
328
+ conversation_id: ConversationId,
329
+ request: Mapping[str, str],
330
+ entry: Mapping[str, Any],
331
+ ) -> SubmissionId:
332
+ """Append a write that a boundary or display-only admission places at once."""
333
+
334
+ appended = await tx.append_entry(conversation_id, cast(EntryDraft, _clean_entry(entry)))
335
+ record = await tx.create_submission(
336
+ cast(
337
+ SubmissionCreate,
338
+ {
339
+ "conversationId": conversation_id,
340
+ **request,
341
+ "type": "write",
342
+ "status": "done",
343
+ "entry": appended["id"],
344
+ },
345
+ )
346
+ )
347
+ return record["id"]
348
+
349
+
350
+ __all__ = [
351
+ "AbortResult",
352
+ "SubmissionHandle",
353
+ "Submissions",
354
+ "admit_submission",
355
+ "check_input_entry",
356
+ "is_settled",
357
+ ]
@@ -0,0 +1,334 @@
1
+ """Task graph view: every live task, its waits, and the conversations it owns (spec §9.5).
2
+
3
+ The graph is to the Session's tasks what the conversation view is to one conversation: a
4
+ structural mount built on the Session line by its first observer, advanced only from committed
5
+ publications, and dropped with its last observer. A node holds the committed task record without
6
+ its input, checkpoint payload, outcome payload, or memos.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+ from functools import partial
15
+ from typing import Any, Literal, NotRequired, TypeAlias, TypedDict, cast
16
+
17
+ from ..context import Context, without_abort_signal
18
+ from ..delta import Op
19
+ from ..ids import ConversationId, TaskId
20
+ from ..json import JsonValue
21
+ from ..session.observation import CommittedState, CommittedWatch
22
+ from ..session.session import SessionImpl, contained
23
+ from ..types import (
24
+ CommitPublication,
25
+ JoinPolicy,
26
+ Storage,
27
+ TaskOutcome,
28
+ TaskRecord,
29
+ )
30
+ from .scheduler import json_equal
31
+ from .util import cancellation_error, closed_error, scan_all
32
+
33
+ LIVE_STATUSES: tuple[Literal["pending", "running", "waiting", "completing"], ...] = (
34
+ "pending",
35
+ "running",
36
+ "waiting",
37
+ "completing",
38
+ )
39
+ """Durable statuses of a live task; terminal tasks leave the graph."""
40
+
41
+ SCAN_PAGE_SIZE = 256
42
+
43
+
44
+ class TaskGraphStatePending(TypedDict):
45
+ status: Literal["pending", "running"]
46
+ phase: str
47
+
48
+
49
+ class TaskGraphStateWaiting(TypedDict):
50
+ status: Literal["waiting"]
51
+ phase: str
52
+ on: list[TaskId]
53
+ policy: JoinPolicy
54
+
55
+
56
+ class TaskGraphStateCompleting(TypedDict):
57
+ """Outcome held until its ordinary owned work drains (spec §5.5)."""
58
+
59
+ status: Literal["completing"]
60
+ outcome: str
61
+
62
+
63
+ TaskGraphState = TaskGraphStatePending | TaskGraphStateWaiting | TaskGraphStateCompleting
64
+ """One live task's durable state without its checkpoint or outcome payloads."""
65
+
66
+
67
+ class TaskGraphNode(TypedDict):
68
+ id: TaskId
69
+ kind: str
70
+ conversationId: ConversationId
71
+ owner: NotRequired[TaskId]
72
+ background: bool
73
+ abortRequested: bool
74
+ state: TaskGraphState
75
+ conversations: list[ConversationId]
76
+
77
+
78
+ class TaskGraph(TypedDict):
79
+ """Every live task of the Session, keyed by its decimal ID."""
80
+
81
+ tasks: dict[str, TaskGraphNode]
82
+
83
+
84
+ TaskGraphWatch: TypeAlias = CommittedWatch
85
+ """A serialized exact-frame watch of the task graph."""
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class GraphMount:
90
+ value: TaskGraph
91
+ observers: set[Any] = field(default_factory=set)
92
+
93
+
94
+ class TaskGraphView:
95
+ """The Harness's single task-graph mount, alive while observed."""
96
+
97
+ def __init__(self, session: SessionImpl, storage: Storage) -> None:
98
+ self._session = session
99
+ self._storage = storage
100
+ self._mount: GraphMount | None = None
101
+ self._closed = False
102
+ session.observe_commits(self._on_publication)
103
+ session.subscribe_close(self._on_close)
104
+
105
+ def report(self, error: BaseException) -> None:
106
+ self._session.report(error)
107
+
108
+ async def state(self, context: Context) -> CommittedState:
109
+ """A disposable read-only state of the graph."""
110
+
111
+ return await self._attach(_state_observer, context)
112
+
113
+ async def watch(self, context: Context) -> CommittedWatch:
114
+ """A serialized exact-frame watch of the graph; cancelling ``context`` stops it."""
115
+
116
+ observer = await self._attach(_watch_observer, context)
117
+ signal = context.abort_signal
118
+ if signal is not None and signal.aborted:
119
+ observer.cancel()
120
+ raise cancellation_error(signal)
121
+ if signal is not None:
122
+ observer.observe_cancellation(signal)
123
+ return observer
124
+
125
+ async def _attach(
126
+ self,
127
+ create: Callable[[TaskGraph, Callable[[], None], TaskGraphView], Any],
128
+ context: Context,
129
+ ) -> Any:
130
+ async def job() -> Any:
131
+ self._session.assert_usable()
132
+ mount = self._mount
133
+ if mount is None:
134
+ mount = GraphMount(value=await self._build(context))
135
+ detached = False
136
+
137
+ def detach() -> None:
138
+ nonlocal detached
139
+ if detached:
140
+ return
141
+ detached = True
142
+ mount.observers.discard(observer)
143
+ if not mount.observers and self._mount is mount:
144
+ self._mount = None
145
+
146
+ observer = create(mount.value, detach, self)
147
+ if asyncio.iscoroutine(observer):
148
+ observer = await observer
149
+ if self._closed:
150
+ raise closed_error(self._session)
151
+ self._session.assert_usable()
152
+ signal = context.abort_signal
153
+ if signal is not None and signal.aborted:
154
+ raise cancellation_error(signal)
155
+ self._mount = mount
156
+ mount.observers.add(observer)
157
+ return observer
158
+
159
+ return await self._session.read_on_line(job)
160
+
161
+ async def _build(self, context: Context) -> TaskGraph:
162
+ records: list[TaskRecord] = []
163
+ for status in LIVE_STATUSES:
164
+ records.extend(
165
+ await scan_all(
166
+ partial(
167
+ self._storage.scan_tasks,
168
+ {"status": status},
169
+ SCAN_PAGE_SIZE,
170
+ context=context,
171
+ )
172
+ )
173
+ )
174
+ records.sort(key=lambda record: record["id"])
175
+ tasks: dict[str, TaskGraphNode] = {}
176
+ for record in records:
177
+ owned = await scan_all(
178
+ partial(
179
+ self._storage.scan_conversations,
180
+ {"ownerTaskId": record["id"]},
181
+ SCAN_PAGE_SIZE,
182
+ context=context,
183
+ )
184
+ )
185
+ conversations = sorted(conversation["id"] for conversation in owned)
186
+ tasks[str(record["id"])] = _node_of(record, conversations)
187
+ return {"tasks": tasks}
188
+
189
+ def _on_publication(self, publication: CommitPublication, context: Context) -> None:
190
+ if self._mount is not None:
191
+ advance_graph(self._mount, publication, context, self.report)
192
+
193
+ def _on_close(self) -> None:
194
+ self._closed = True
195
+ failure = self._session.failure
196
+ mount = self._mount
197
+ if mount is not None:
198
+ for observer in list(mount.observers):
199
+ contained(partial(observer.close_session, failure), self.report)
200
+ self._mount = None
201
+
202
+
203
+ def _state_observer(
204
+ value: TaskGraph, release: Callable[[], None], view: TaskGraphView
205
+ ) -> CommittedState:
206
+ return CommittedState(value, release, view.report)
207
+
208
+
209
+ def _watch_observer(
210
+ value: TaskGraph, release: Callable[[], None], view: TaskGraphView
211
+ ) -> CommittedWatch:
212
+ return CommittedWatch(value, release, view.report)
213
+
214
+
215
+ def advance_graph(
216
+ mount: GraphMount,
217
+ publication: CommitPublication,
218
+ context: Context,
219
+ report: Callable[[BaseException], None],
220
+ ) -> None:
221
+ """Derive the mount's operations from one publication and publish the new revision."""
222
+
223
+ ops: list[Op] = []
224
+ changed: dict[str, TaskGraphNode | None] = {}
225
+
226
+ def node(key: str) -> TaskGraphNode | None:
227
+ if key in changed:
228
+ return changed[key]
229
+ return mount.value["tasks"].get(key)
230
+
231
+ for change in publication["changes"]:
232
+ if change["type"] != "task":
233
+ continue
234
+ record = change["value"]
235
+ key = str(record["id"])
236
+ previous = node(key)
237
+ if record["state"]["status"] == "terminal":
238
+ if previous is None:
239
+ continue
240
+ ops.append(["d", ["tasks", key]])
241
+ changed[key] = None
242
+ continue
243
+ next_node = _node_of(record, previous["conversations"] if previous is not None else [])
244
+ if previous is not None and json_equal(next_node, previous):
245
+ continue
246
+ ops.append(["s", ["tasks", key], next_node])
247
+ changed[key] = next_node
248
+
249
+ # After the tasks, so a conversation created with its owner task in one commit finds the owner's node.
250
+ created: dict[str, list[ConversationId]] = {}
251
+ for change in publication["changes"]:
252
+ if change["type"] != "conversation":
253
+ continue
254
+ owner = change["value"].get("owner")
255
+ if owner is None:
256
+ continue
257
+ key = str(owner["taskId"])
258
+ if node(key) is not None:
259
+ created.setdefault(key, []).append(change["value"]["id"])
260
+ for key, ids in created.items():
261
+ current = node(key)
262
+ assert current is not None
263
+ conversations = sorted([*current["conversations"], *ids])
264
+ ops.append(["s", ["tasks", key, "conversations"], conversations])
265
+ changed[key] = {**current, "conversations": conversations}
266
+
267
+ if not ops:
268
+ return
269
+ tasks = dict(mount.value["tasks"])
270
+ for op in ops:
271
+ path = op[1]
272
+ if op[0] == "d":
273
+ tasks.pop(path[1], None)
274
+ elif len(path) == 2:
275
+ tasks[path[1]] = op[2]
276
+ else:
277
+ current = tasks[path[1]]
278
+ tasks[path[1]] = {**current, "conversations": op[2]}
279
+ mount.value = {"tasks": tasks}
280
+ frame_context = without_abort_signal(context)
281
+ for observer in list(mount.observers):
282
+ advance = getattr(observer, "advance_frame", None) or observer.advance
283
+ contained(partial(advance, mount.value, ops, frame_context), report)
284
+
285
+
286
+ def _node_of(record: TaskRecord, conversations: list[ConversationId]) -> TaskGraphNode:
287
+ node = TaskGraphNode(
288
+ id=record["id"],
289
+ kind=record["kind"],
290
+ conversationId=record["conversationId"],
291
+ background=record["background"],
292
+ abortRequested=record["abortRequested"],
293
+ state=_state_of(record),
294
+ conversations=list(conversations),
295
+ )
296
+ owner = record.get("owner")
297
+ if owner is not None:
298
+ node["owner"] = owner
299
+ return node
300
+
301
+
302
+ def _state_of(record: TaskRecord) -> TaskGraphState:
303
+ state = cast(dict[str, Any], record["state"])
304
+ status = state["status"]
305
+ if status in ("pending", "running"):
306
+ return {"status": status, "phase": _phase_of(state["checkpoint"])}
307
+ if status == "waiting":
308
+ return {
309
+ "status": "waiting",
310
+ "phase": _phase_of(state["checkpoint"]),
311
+ "on": list(state["on"]),
312
+ "policy": state["policy"],
313
+ }
314
+ outcome = _outcome_status(state["outcome"])
315
+ return {"status": "completing", "outcome": outcome}
316
+
317
+
318
+ def _phase_of(checkpoint: JsonValue) -> str:
319
+ return str(cast(dict[str, Any], checkpoint)["phase"])
320
+
321
+
322
+ def _outcome_status(outcome: TaskOutcome) -> str:
323
+ return outcome["status"]
324
+
325
+
326
+ __all__ = [
327
+ "LIVE_STATUSES",
328
+ "TaskGraph",
329
+ "TaskGraphNode",
330
+ "TaskGraphState",
331
+ "TaskGraphView",
332
+ "TaskGraphWatch",
333
+ "advance_graph",
334
+ ]