taskwire 0.1.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.
taskwire/reporter.py ADDED
@@ -0,0 +1,1162 @@
1
+ """`Reporter`, `operation()` and the lifecycle writes.
2
+
3
+ Three rules govern this module:
4
+
5
+ **The throttle is inline, never a background task** (TW-THR-002). `set()` reads an injected clock
6
+ and commits when the window has elapsed. The natural implementation - a task holding a timer - fails
7
+ exactly where it matters: a tight `for row in rows: await reporter.set(...)` loop never yields, so
8
+ the timer task never runs, the bar reads 0 % for a whole CPU-bound phase, and that phase's final
9
+ value is never written at all.
10
+
11
+ **`set()` has no `state=` keyword and no back door to one** (TW-PROG-012). The state is derived from
12
+ lifecycle events: `mark_queued()` writes `queued`, entering `operation()` writes `running`, and
13
+ leaving it writes exactly one of the three terminal states. A caller that could write `state` could
14
+ park an operation in `waiting_input` that nothing will ever release (TW-INV-006).
15
+
16
+ **The terminal write is what ends the operation, and it ends it once** (TW-REG-007, TW-INV-021).
17
+ Exit commits the terminal state and publishes the terminal envelope, in that order. The write takes
18
+ the entry out of the register - the store does that off the document's own state - and leaves the
19
+ document readable at its own address for `tombstone_ttl` (TW-RET-004), which is what a client that
20
+ was not watching reads to learn which of the three terminal states this was.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import logging
28
+ import threading
29
+ import time
30
+ import uuid
31
+
32
+ from collections.abc import Callable
33
+ from typing import Any
34
+
35
+ from .ambient import bind as ambient_bind, unbind as ambient_unbind
36
+ from .models import (
37
+ Button,
38
+ DialogAnswer,
39
+ DialogRequest,
40
+ DialogState,
41
+ encoded_size,
42
+ Envelope,
43
+ EnvelopeKind,
44
+ Error,
45
+ Input,
46
+ now_iso,
47
+ Progress,
48
+ ProgressState,
49
+ Result,
50
+ Text,
51
+ )
52
+ from .settings import configured, settings
53
+ from .store import CANCELLED_BUTTON, key as make_key
54
+
55
+ logger = logging.getLogger("taskwire.reporter")
56
+
57
+ _UNSET: Any = object()
58
+ """Distinguishes "not passed" from "passed as None". `set(percent=None)` means indeterminate;
59
+ omitting `percent` means leave it alone (TW-PROG-003)."""
60
+
61
+
62
+ class OperationCancelled(Exception): # noqa: N818 - the name is specified (TW-CANCEL-005)
63
+ """TW-CANCEL-005: its own type.
64
+
65
+ Never a bare `RuntimeError` - callers must be able to catch precisely this - and never a
66
+ subclass of `asyncio.CancelledError`, which would make every `except CancelledError` in the
67
+ application swallow a user-requested cancel as if the event loop had done it.
68
+ """
69
+
70
+
71
+ def new_token() -> str:
72
+ """A UUIDv4 and nothing more. An address, never an authorization grant."""
73
+ return str(uuid.uuid4())
74
+
75
+
76
+ def _clamp_percent(value: float | None) -> float | None:
77
+ """TW-PROG-002: `[0, 100]` or `None`. Out-of-range values clamp rather than raising."""
78
+ if value is None:
79
+ return None
80
+ return max(0.0, min(100.0, float(value)))
81
+
82
+
83
+ def _check_size(name: str, value: Any) -> None:
84
+ """TW-PROG-008: cap at `max_data_bytes`, by raising and naming the size, never by truncating.
85
+
86
+ Silent truncation is worse than a raise here: the field survives, the application believes it
87
+ wrote what it wrote, and the missing half only shows up in a browser weeks later.
88
+ """
89
+ size = encoded_size(value)
90
+ if size > settings.max_data_bytes:
91
+ raise ValueError(
92
+ f"taskwire: {name} is {size} bytes encoded, above the {settings.max_data_bytes}-byte "
93
+ f"cap (settings.max_data_bytes). It is not truncated; reduce it or store it yourself "
94
+ f"and pass a reference."
95
+ )
96
+
97
+
98
+ def _expand_buttons(buttons: list[str] | list[Button]) -> list[Button]:
99
+ """TW-DLG-003: the object form on the wire, a list of strings as sugar in Python.
100
+
101
+ Each expanded label defaults to `Text(key=f"taskwire.button.{id}")` with **no** `text`: a
102
+ frontend with a catalogue renders its own wording, and one without shows the id, which is a
103
+ better failure than a hard-coded English string nobody can translate.
104
+ """
105
+ expanded: list[Button] = []
106
+ for button in buttons:
107
+ if isinstance(button, Button):
108
+ expanded.append(button)
109
+ else:
110
+ expanded.append(Button(id=button, label=Text(key=f"taskwire.button.{button}")))
111
+ return expanded
112
+
113
+
114
+ class Reporter:
115
+ """What an operation writes its commentary through.
116
+
117
+ One reporter per operation, plus the children `subtask()` and `split()` hand out. The reporter
118
+ never touches a transport (TW-CORE-003): it writes to the store and calls `store.publish()`.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ *,
124
+ key: str,
125
+ ns: str,
126
+ token: str,
127
+ store: Any,
128
+ progress: Progress,
129
+ clock: Callable[[], float] | None = None,
130
+ raise_on_cancel: bool | None = None,
131
+ parent: Reporter | None = None,
132
+ notify_parent: Callable[..., Any] | None = None,
133
+ ) -> None:
134
+ self._key = key
135
+ self._ns = ns
136
+ self._token = token
137
+ self._store = store
138
+ self._progress = progress
139
+ self._clock = clock or time.monotonic
140
+ self._raise_on_cancel = raise_on_cancel
141
+ self._last_commit: float | None = None
142
+ self._last_yield: float | None = None
143
+ self._rev = 0
144
+ self._cancelled = False
145
+ self._loop: asyncio.AbstractEventLoop | None = None
146
+
147
+ # --- nesting ------------------------------------------------------
148
+ # `_parent` and `_notify_parent` are the whole of a subtask's relationship to its parent, and
149
+ # neither of them tells it its own range: TW-NEST-001 says the callee MUST NOT be able to
150
+ # learn it. A child reports a *local* 0-100 and the parent does the mapping, which is also
151
+ # what makes the composition in TW-NEST-002 fall out for free.
152
+ self._parent = parent
153
+ self._notify_parent = notify_parent
154
+ # TW-NEST-004 / TW-INV-010: one entry per child handed out, never a last-written percent.
155
+ # Each commit rewrites exactly the one entry. With a last-written percent, four equal
156
+ # siblings whose fastest finishes first pin the bar at 100% for three quarters of the work.
157
+ self._contribution: dict[int, float] = {}
158
+ self._child_seq = 0
159
+ self._own_percent: float | None = None
160
+ self._high_water: float | None = None
161
+ # TW-NEST-007: the map is mutated under the parent's lock, so any interleaving of concurrent
162
+ # siblings produces the same sum.
163
+ self._children_lock = asyncio.Lock()
164
+ self._ambient_token: Any = None
165
+ # How many questions this operation is waiting on, counted on the root so a subtask's `ask()`
166
+ # is visible to whoever handles the worker being killed (TW-DLG-006).
167
+ self._asking = 0
168
+ # TW-RES-004: a clean exit with a result already written lands in `waiting_input`
169
+ # rather than `done`, and `operation()`'s exit reads this to know which.
170
+ self._result_parked = False
171
+
172
+ # ---------------------------------------------------------------- properties
173
+
174
+ @property
175
+ def token(self) -> str:
176
+ """The operation's address. A UUIDv4, minted by the caller."""
177
+ return self._token
178
+
179
+ @property
180
+ def asking(self) -> bool:
181
+ """Whether this operation is waiting on an answer right now, subtasks included.
182
+
183
+ Free, and local: it is what a caller handling a worker's execution limit reads to tell a
184
+ deadline that expired on a question from one that expired on the work itself (TW-DLG-006).
185
+ Reading it costs no store round trip, which matters where it is read - the worker is being
186
+ killed.
187
+ """
188
+ root = self
189
+ while root._parent is not None:
190
+ root = root._parent
191
+ return root._asking > 0
192
+
193
+ @property
194
+ def cancelled(self) -> bool:
195
+ """TW-CANCEL-004: the last known value, refreshed by every commit and free to poll.
196
+
197
+ Free means free - no store read. `await was_aborted()` is the one that costs a round trip.
198
+ """
199
+ return self._cancelled
200
+
201
+ async def was_aborted(self) -> bool:
202
+ """Force a store read. Available regardless of `raise_on_cancel` (TW-CANCEL-004)."""
203
+ self._cancelled = bool(await self._store.is_cancelled(self._key))
204
+ return self._cancelled
205
+
206
+ @property
207
+ def sync(self) -> SyncReporter:
208
+ """TW-API-003: one attribute, not a duplicated method list.
209
+
210
+ A hand-written parallel surface drifts the first time a keyword is added to `set()`, and the
211
+ drift is silent because the sync form is exercised by different tests.
212
+ """
213
+ return SyncReporter(self)
214
+
215
+ # ---------------------------------------------------------------- the one mutator
216
+
217
+ async def set( # noqa: A003 - `set` is the specified name (TW-PROG-004)
218
+ self,
219
+ *,
220
+ percent: float | None = _UNSET,
221
+ title: Text = _UNSET,
222
+ label: Text = _UNSET,
223
+ icon: str = _UNSET,
224
+ data: dict = _UNSET,
225
+ raise_on_cancel: bool | None = None,
226
+ **forbidden: Any,
227
+ ) -> None:
228
+ """The **only** mutator of the display fields (TW-PROG-004).
229
+
230
+ There is no `set_label()` / `set_percent()` family, because each would need its own
231
+ throttling window and its own merge semantics against this one.
232
+
233
+ An omitted keyword leaves its field untouched; `data=` replaces the bag wholesale
234
+ (TW-PROG-003). `raise_on_cancel=` sets this reporter's policy from here on, overriding the
235
+ per-action and global defaults (TW-CANCEL-003).
236
+
237
+ Raises `ValueError` on `state=`, which no caller may write (TW-PROG-012), and `TypeError` on
238
+ any other unrecognised keyword.
239
+ """
240
+ if "state" in forbidden:
241
+ raise ValueError(
242
+ "taskwire: Reporter.set() has no `state` keyword and never will (TW-PROG-012). "
243
+ "The state is derived from the lifecycle: mark_queued() writes `queued`, entering "
244
+ "operation() writes `running`, and leaving it writes exactly one terminal state. "
245
+ "A caller-written state can park an operation that nothing will release."
246
+ )
247
+ if forbidden:
248
+ raise TypeError(f"taskwire: set() got unexpected keyword arguments {sorted(forbidden)}")
249
+
250
+ if data is not _UNSET:
251
+ _check_size("data", data)
252
+
253
+ if percent is not _UNSET:
254
+ self._own_percent = _clamp_percent(percent)
255
+ # The display fields live on the ONE progress document a token has (TW-NEST-013). A subtask
256
+ # shares its root's `Progress` object, so `sub.set(label=...)` names the current step of the
257
+ # operation rather than of a sub-range no client can see.
258
+ if title is not _UNSET:
259
+ self._progress.title = title
260
+ if label is not _UNSET:
261
+ self._progress.label = label
262
+ if icon is not _UNSET:
263
+ self._progress.icon = icon
264
+ if data is not _UNSET:
265
+ self._progress.data = data
266
+ if raise_on_cancel is not None:
267
+ self._raise_on_cancel = raise_on_cancel
268
+
269
+ await self._propagate()
270
+
271
+ # ---------------------------------------------------------------- nesting
272
+
273
+ def _local_percent(self) -> float | None:
274
+ """TW-NEST-005: own value when childless, the contribution sum when parented, the LARGER of
275
+ the two when both.
276
+
277
+ "Larger" rather than "sum" or "own": a reporter that both does work itself and hands ranges
278
+ out would otherwise go backwards the moment its first child reports, and a bar that goes
279
+ backwards reads as a bug to every user who sees it.
280
+ """
281
+ if not self._contribution:
282
+ return self._own_percent
283
+ total = sum(self._contribution.values())
284
+ if self._own_percent is None:
285
+ return total
286
+ return max(self._own_percent, total)
287
+
288
+ async def _propagate(self, *, force: bool = False) -> None:
289
+ """Push this reporter's local percent one level up, or commit it if this is the root.
290
+
291
+ `force` travels with the propagation rather than stopping at the reporter that was
292
+ asked: a subtask's `flush()` has to reach the store unthrottled (TW-THR-004), and the
293
+ store is several levels up.
294
+ """
295
+ local = self._local_percent()
296
+ if self._notify_parent is not None:
297
+ await self._notify_parent(local, force=force)
298
+ return
299
+
300
+ # The root, and only the root, writes to the document.
301
+ if local is None:
302
+ # TW-NEST-011: `null` reaches the committed document only when the ROOT's own local
303
+ # percent is None - a child reporting null freezes its contribution instead, which is
304
+ # handled where contributions are written.
305
+ self._progress.percent = None
306
+ else:
307
+ # TW-NEST-010: a monotonic clamp on the root's committed percent, as a final safety
308
+ # net only - it is emphatically not the correctness mechanism, the contribution map
309
+ # is. It nets the *nesting arithmetic*, so it applies only once ranges have been
310
+ # handed out: a childless root setting its own percent is not doing that arithmetic,
311
+ # and TW-PROG-002 entitles it to go backwards (-5 clamps to 0) when a phase restarts.
312
+ if self._contribution:
313
+ self._high_water = local if self._high_water is None else max(self._high_water, local)
314
+ self._progress.percent = self._high_water
315
+ else:
316
+ self._progress.percent = local
317
+ if force:
318
+ await self._commit()
319
+ else:
320
+ await self._tick()
321
+
322
+ def subtask(self, start: float, end: float) -> Reporter:
323
+ """A reporter covering `[start, end]` of this one's range (TW-NEST-001).
324
+
325
+ The child has the identical API and **cannot learn its range**: it reports a local 0-100 and
326
+ this reporter does the mapping. That is what makes TW-NEST-002's recursive composition fall
327
+ out rather than needing its own arithmetic - `r.subtask(10, 25).subtask(50, 100)` covers
328
+ 17.5-25 of `r` because each level maps only its own child's local value.
329
+ """
330
+ self._child_seq += 1
331
+ child_key = self._child_seq
332
+ width = float(end) - float(start)
333
+ self._contribution[child_key] = 0.0
334
+
335
+ async def notify(local: float | None, *, force: bool = False) -> None:
336
+ if local is None:
337
+ # TW-NEST-011: freeze this child's contribution at its last known value rather than
338
+ # dropping it to zero. A child that goes indeterminate has not un-done its work.
339
+ await self._propagate(force=force)
340
+ return
341
+ async with self._children_lock:
342
+ # Exactly this one entry is rewritten (TW-NEST-004). TW-NEST-012 falls out: the value
343
+ # can never exceed `width`, so it cannot overflow into a sibling's range.
344
+ self._contribution[child_key] = width * max(0.0, min(100.0, local)) / 100.0
345
+ await self._propagate(force=force)
346
+
347
+ return Reporter(
348
+ key=self._key,
349
+ ns=self._ns,
350
+ token=self._token,
351
+ store=self._store,
352
+ progress=self._progress,
353
+ clock=self._clock,
354
+ raise_on_cancel=self._raise_on_cancel,
355
+ parent=self,
356
+ notify_parent=notify,
357
+ )
358
+
359
+ def split(self, *weights: float) -> tuple[Reporter, ...]:
360
+ """TW-NEST-003: one reporter per weight, partitioning this range in proportion.
361
+
362
+ Concurrent siblings MUST each be passed their own reporter explicitly (TW-NEST-014). The
363
+ ambient binding must not be shared across a `gather` - see the ambient module's docstring for
364
+ the wrong and the right form side by side.
365
+ """
366
+ total = float(sum(weights))
367
+ if total <= 0:
368
+ raise ValueError("taskwire: split() needs at least one positive weight")
369
+ children: list[Reporter] = []
370
+ cursor = 0.0
371
+ for weight in weights:
372
+ span = 100.0 * float(weight) / total
373
+ children.append(self.subtask(cursor, cursor + span))
374
+ cursor += span
375
+ return tuple(children)
376
+
377
+ def __enter__(self) -> Reporter:
378
+ """Bind the ambient reporter for the duration of the block (TW-AMB-004)."""
379
+ from .ambient import bind
380
+
381
+ self._ambient_token = bind(self)
382
+ return self
383
+
384
+ def __exit__(self, *exc: Any) -> None:
385
+ """Unbind via the contextvars Token, and force an unthrottled flush (TW-THR-004).
386
+
387
+ The reset goes through the `Token` rather than overwriting the variable (TW-AMB-005): a
388
+ raising task that merely overwrote would leak its binding to the next task on the same pooled
389
+ thread, and operation A's progress would land on operation B's key.
390
+ """
391
+ from .ambient import unbind
392
+
393
+ unbind(self._ambient_token)
394
+ self._ambient_token = None
395
+
396
+ async def _tick(self) -> None:
397
+ """The inline throttle (TW-THR-002) and the once-per-interval yield (TW-THR-003)."""
398
+ now = self._clock()
399
+ interval = settings.progress_interval
400
+
401
+ # TW-THR-003: once per interval, not once per call. Tracked on its own timestamp so the
402
+ # guarantee survives a window in which nothing was worth committing.
403
+ if self._last_yield is None or (now - self._last_yield) >= interval:
404
+ self._last_yield = now
405
+ await asyncio.sleep(0)
406
+
407
+ if self._last_commit is None or (now - self._last_commit) >= interval:
408
+ await self._commit()
409
+
410
+ async def _commit(self, *, state: ProgressState | None = None, ttl: float | None = None) -> int:
411
+ """Write, then publish, in that order and never the other (TW-CORE-001, TW-INV-001).
412
+
413
+ A client that saw a push whose state a later read contradicts has no way to recover: it is
414
+ entitled to current state only (TW-CORE-005), and current state just disagreed with what it
415
+ was told.
416
+ """
417
+ if state is not None:
418
+ self._progress.state = state
419
+ self._progress.updated_at = now_iso()
420
+
421
+ # One write, and both of its answers: the new `rev`, and the sticky cancel flag as that
422
+ # write saw it (TW-STORE-014). Asking the store a second time would be a second round trip
423
+ # per commit against anything that is not a dictionary.
424
+ written = await self._store.commit(self._key, self._progress, settings.active_ttl if ttl is None else ttl)
425
+ self._rev = written.rev
426
+ self._last_commit = self._clock()
427
+ self._cancelled = written.cancelled
428
+ self._raise_if_cancelled(state)
429
+
430
+ await self._store.publish(
431
+ self._ns,
432
+ Envelope(
433
+ token=self._token,
434
+ rev=self._rev,
435
+ kind=EnvelopeKind.PROGRESS,
436
+ body=self._progress.to_dict(),
437
+ ),
438
+ )
439
+ return self._rev
440
+
441
+ # ---------------------------------------------------------------- terminal writes
442
+
443
+ async def done(self) -> None:
444
+ """On a root reporter this is the caller asking for a clean finish.
445
+
446
+ Only the root reporter's terminal transition may put the operation into a terminal state
447
+ (TW-PROG-011); in practice `operation()`'s exit is the one that calls it.
448
+
449
+ **A subtask's `done()` fills its contribution to its full width and does NOT touch the
450
+ operation state** (TW-NEST-008). It reports a local 100 rather than writing a width it is not
451
+ allowed to know (TW-NEST-001), which comes to the same number by a route that keeps the range
452
+ in the parent where it belongs.
453
+ """
454
+ if self._notify_parent is not None:
455
+ await self._notify_parent(100.0, force=True)
456
+ return
457
+ await self._commit(state=ProgressState.DONE)
458
+
459
+ async def fail(self, code: str, message: Text, retryable: bool = False) -> None:
460
+ """Write `failed` with an `Error`. Carries no stack trace (TW-PROG-005)."""
461
+ self._progress.error = Error(code=code, message=message, retryable=retryable)
462
+ await self._commit(state=ProgressState.FAILED)
463
+
464
+ def name_error(self, code: str, message: Text, retryable: bool = False) -> None:
465
+ """Decide what the terminal write will say, without making that write.
466
+
467
+ For an exception on its way out of `operation()`, where the code derived from the exception's
468
+ class would be less true than one the caller already knows: TW-DLG-006's `dialog_timeout` is
469
+ the case, and the class name there is whatever the worker's execution limit happens to raise.
470
+
471
+ `fail()` is the wrong tool for that: it commits, and the exit then commits a second terminal
472
+ state over it - two completions where TW-API-001 allows exactly one, and a terminal envelope
473
+ followed by another one. This names the error and leaves the single write to the exit, which
474
+ withdraws the dialogs and takes the entry out of the register in the same step
475
+ (TW-CANCEL-011, TW-REG-007).
476
+ """
477
+ self._progress.error = Error(code=code, message=message, retryable=retryable)
478
+
479
+ async def set_result(self, result: Result) -> None:
480
+ """Park a result for later collection. Root reporter only, and at most once.
481
+
482
+ **This is emphatically not an `ask()`** (TW-RES-005). The task has finished; only the
483
+ *record* waits. The reporter writes the result and the state, and the worker exits. An
484
+ implementation that waited for collection the way `ask()` waits for a reply would pin a
485
+ worker overnight on a download nobody clicked.
486
+
487
+ The operation does not go to `done` when its work finishes (TW-RES-004): it lands in
488
+ `waiting_input` holding the result and stays there until one of the three release paths
489
+ takes it (TW-RES-010). The awaiting caller's promise still resolves when the *work* finishes,
490
+ not when the result is collected (TW-RES-006).
491
+ """
492
+ if self._notify_parent is not None:
493
+ raise ValueError(
494
+ "taskwire: set_result() is a root reporter's, not a subtask's (TW-RES-003). One "
495
+ "token carries one result, however deep the tree."
496
+ )
497
+ if self._progress.result_kind is None:
498
+ raise ValueError(
499
+ "taskwire: this operation declared no `result_kind`, so it is private from start to "
500
+ "finish and has nothing to collect (TW-RES-001, TW-PRIV-001). There is no promotion "
501
+ "path - declare `result_kind` when the operation starts."
502
+ )
503
+ if self._progress.result is not None:
504
+ raise ValueError("taskwire: a Result is written at most once (TW-RES-001)")
505
+ _check_size("result", result.to_dict())
506
+
507
+ self._progress.result = result
508
+ # TW-RET-003: the key's TTL becomes `result_ttl` and no keepalive runs against it - nobody is
509
+ # running, which is the point. Set `result_ttl` no longer than your own artefact retention.
510
+ self._result_parked = True
511
+ await self._commit()
512
+ await self._store.touch(self._key, settings.result_ttl)
513
+
514
+ async def ask(
515
+ self,
516
+ dialog_id: str,
517
+ *,
518
+ buttons: list[str] | list[Button],
519
+ inputs: list[Input] | None = None,
520
+ params: dict | None = None,
521
+ title: Text | None = None,
522
+ text: Text | None = None,
523
+ ) -> DialogAnswer:
524
+ """Ask the user a question and **block until the store arbitrates a reply**.
525
+
526
+ **There is no timeout** (TW-DLG-005). No `timeout` parameter, no `settings.dialog_timeout`,
527
+ no `timeout_at`, no declared default outcome, no `DialogTimeout` and no `expired` state.
528
+ A deadline would need a default answer, and there is no answer a library can invent on a
529
+ user's behalf; a programmer who wants one implements it at whatever level suits them.
530
+
531
+ The consequence (TW-DLG-006): **an unanswered dialog blocks its worker** until Celery's own
532
+ execution timeout kills the task, at which point the operation becomes `failed` with code
533
+ `dialog_timeout` and `retryable: true` and its open dialogs are withdrawn. The general
534
+ problem of a blocked worker is deliberately unsolved.
535
+
536
+ `ask()` MUST NOT be called while holding a database transaction (TW-DLG-017). It waits for a
537
+ human. A transaction held across it holds its locks for as long as the user takes to answer,
538
+ which may be the rest of the afternoon.
539
+
540
+ Returns only when the store arbitrated a reply, so `DialogAnswer` carries no `timed_out`
541
+ flag - there is no second way out (TW-DLG-007).
542
+ """
543
+ if params is not None:
544
+ _check_size("dialog params", params)
545
+
546
+ # TW-DLG-001: `id` is *this asking* - a server-minted UUID unique within the token - and is
547
+ # the only one of the two ever used as a key, a path segment or a store argument.
548
+ # `dialog_id` is *which question*, the frontend component identity, and keying by it would
549
+ # let two concurrent operations rendering the same component answer each other's question
550
+ # (TW-DLG-002, TW-INV-008).
551
+ did = str(uuid.uuid4())
552
+ request = DialogRequest(
553
+ id=did,
554
+ dialog_id=dialog_id,
555
+ buttons=_expand_buttons(buttons),
556
+ inputs=list(inputs or []),
557
+ params=params,
558
+ state=DialogState.OPEN,
559
+ )
560
+ if title is not None:
561
+ self._progress.title = title
562
+ if text is not None:
563
+ self._progress.label = text
564
+
565
+ # A dialog open is never throttled (TW-THR-004/005, TW-INV-005): a question the user cannot
566
+ # see is a worker nobody can free.
567
+ await self.flush()
568
+ rev = await self._store.put_dialog(self._key, request, settings.active_ttl)
569
+ await self._store.publish(
570
+ self._ns,
571
+ Envelope(token=self._token, rev=rev, kind=EnvelopeKind.DIALOG_OPEN, body=request.to_dict()),
572
+ )
573
+
574
+ root = self
575
+ while root._parent is not None:
576
+ root = root._parent
577
+ root._asking += 1
578
+ reply = await self._store.await_dialog(self._key, did)
579
+ # The question is over the moment the store arbitrates it, answered or withdrawn. An
580
+ # exception on the way out of the wait leaves the count where it is, which is what the count
581
+ # means: the question is still open, and whoever unwinds this worker died on one.
582
+ root._asking -= 1
583
+
584
+ # TW-DLG-012: a dialog on a cancelled operation is flipped to `cancelled` and `ask()` raises
585
+ # OperationCancelled whatever `raise_on_cancel` says. A withdrawal is not an answer, and
586
+ # returning one would let the caller act on a choice nobody made.
587
+ if reply.button == CANCELLED_BUTTON:
588
+ self._cancelled = True
589
+ raise OperationCancelled(f"taskwire: operation {self._token} was cancelled while asking {dialog_id}")
590
+
591
+ snapshot = await self._store.snapshot(self._key)
592
+ closed = next((d for d in (snapshot.dialogs if snapshot else []) if d.id == did), None)
593
+ await self._store.publish(
594
+ self._ns,
595
+ Envelope(
596
+ token=self._token,
597
+ rev=snapshot.rev if snapshot else rev,
598
+ kind=EnvelopeKind.DIALOG_CLOSE,
599
+ body={
600
+ "id": did,
601
+ "state": DialogState(closed.state).value if closed else DialogState.ANSWERED.value,
602
+ "reply": reply.to_dict(),
603
+ },
604
+ ),
605
+ )
606
+ # The state returns to `running` on resolution (TW-DLG-009) with nothing written: it was
607
+ # never written on the way in either. `waiting_input` is derived from the open dialogs
608
+ # themselves, which is what TW-PROG-010 requires and what makes the two ends agree.
609
+ await self.flush()
610
+ return DialogAnswer(button=reply.button, values=reply.values)
611
+
612
+ def _raise_if_cancelled(self, writing_state: ProgressState | None) -> None:
613
+ """TW-CANCEL-002: by default the NEXT progress-reporting call raises.
614
+
615
+ This single behaviour is the whole argument for the raising default. A loop containing no
616
+ cancellation-handling code at all still stops, and still ends `cancelled` rather than
617
+ running to completion after the user pressed the button - which is what a polling API gives
618
+ you when somebody forgets the poll, and somebody always forgets the poll.
619
+
620
+ Not while writing a terminal state: `operation()`'s exit is what turns the raise into the
621
+ `cancelled` state, and raising there would leave the operation with no terminal write at all.
622
+ """
623
+ if not self._cancelled:
624
+ return
625
+ if writing_state is not None:
626
+ return
627
+ resolved = self._raise_on_cancel
628
+ if resolved is None:
629
+ resolved = settings.raise_on_cancel
630
+ if resolved:
631
+ raise OperationCancelled(f"taskwire: operation {self._token} was cancelled")
632
+
633
+ async def flush(self) -> None:
634
+ """A subtask has nothing of its own to flush; it forces the root's (TW-THR-004)."""
635
+ if self._notify_parent is not None:
636
+ await self._propagate(force=True)
637
+ return
638
+ await self._commit()
639
+
640
+
641
+ class InertReporter(Reporter):
642
+ """TW-AMB-010 / TW-AMB-009: live-looking but writes nothing, so callers never branch.
643
+
644
+ Two situations produce one: `operation(token=None)`, and a `session_resolver` that returned
645
+ `None` - an application opting a request out of taskwire entirely. Neither is an error, and
646
+ neither should force `if reporter is not None:` through the calling code.
647
+
648
+ The display fields, the nesting and the terminal writes are inert. `ask()` and `set_result()`
649
+ are not: an inert reporter has no store, so there is nobody to answer a question and nothing to
650
+ park a result on, and both raise `ValueError`.
651
+ """
652
+
653
+ def __init__(self) -> None:
654
+ super().__init__(
655
+ key="",
656
+ ns="",
657
+ token="",
658
+ store=None,
659
+ progress=Progress(),
660
+ )
661
+
662
+ async def set(self, **kwargs: Any) -> None: # noqa: A003
663
+ if "state" in kwargs:
664
+ # Still an error. An inert reporter is silent, not permissive: code that would be
665
+ # rejected in production must not pass merely because it ran with no namespace.
666
+ raise ValueError("taskwire: Reporter.set() has no `state` keyword (TW-PROG-012)")
667
+ return None
668
+
669
+ async def ask(self, dialog_id: str, **kwargs: Any) -> DialogAnswer: # noqa: ARG002
670
+ """A question with nobody to arbitrate it, named as such.
671
+
672
+ Progress an inert reporter drops is progress nobody asked for; a question it dropped would
673
+ be a worker waiting for an answer that no store can ever record.
674
+ """
675
+ raise ValueError(f"taskwire: ask({dialog_id!r}) needs an operation, and this reporter has none")
676
+
677
+ async def _tick(self) -> None:
678
+ return None
679
+
680
+ async def _propagate(self, *, force: bool = False) -> None: # noqa: ARG002
681
+ return None
682
+
683
+ def subtask(self, start: float, end: float) -> Reporter: # noqa: ARG002
684
+ """An inert reporter's children are inert too, so `with progress.subtask(...)` still works."""
685
+ return InertReporter()
686
+
687
+ def split(self, *weights: float) -> tuple[Reporter, ...]:
688
+ """One inert reporter per weight, so a split reads the same with no namespace bound."""
689
+ return tuple(InertReporter() for _ in weights)
690
+
691
+ def __enter__(self) -> Reporter:
692
+ return self
693
+
694
+ def __exit__(self, *exc: Any) -> None:
695
+ return None
696
+
697
+ async def _commit(self, *, state: ProgressState | None = None) -> int: # noqa: ARG002
698
+ return 0
699
+
700
+ async def flush(self) -> None:
701
+ return None
702
+
703
+ async def done(self) -> None:
704
+ return None
705
+
706
+ async def fail(self, code: str, message: Text, retryable: bool = False) -> None: # noqa: ARG002
707
+ return None
708
+
709
+ async def was_aborted(self) -> bool:
710
+ return False
711
+
712
+
713
+ class SyncReporter:
714
+ """TW-API-003: the identical member names, each run on the reporter's owning loop.
715
+
716
+ Resolved dynamically rather than written out. A hand-maintained parallel list drifts the moment
717
+ `set()` grows a keyword, and drifts silently.
718
+ """
719
+
720
+ def __init__(self, reporter: Reporter) -> None:
721
+ self._reporter = reporter
722
+
723
+ def __getattr__(self, name: str) -> Any:
724
+ attribute = getattr(self._reporter, name)
725
+ if not callable(attribute):
726
+ return attribute
727
+
728
+ def call(*args: Any, **kwargs: Any) -> Any:
729
+ result = attribute(*args, **kwargs)
730
+ if not asyncio.iscoroutine(result):
731
+ return result
732
+ loop = self._reporter._loop
733
+ if loop is None or not loop.is_running():
734
+ return asyncio.run(result)
735
+ return asyncio.run_coroutine_threadsafe(result, loop).result()
736
+
737
+ return call
738
+
739
+
740
+ def _opening_progress(
741
+ *,
742
+ state: ProgressState,
743
+ session: str,
744
+ title: Text | None,
745
+ icon: str | None,
746
+ data: dict | None,
747
+ result_kind: str | None,
748
+ connection: str | None,
749
+ ) -> Progress:
750
+ """The one write that fixes an operation's identity.
751
+
752
+ `result_kind`, `origin_session` and `origin_connection` are set here and never rewritten
753
+ (TW-PROG-006, TW-INV-009). Declaring a `result_kind` is the whole of being shared; declaring
754
+ none makes the operation private from start to finish, with no promotion path (TW-PRIV-001).
755
+ """
756
+ if data is not None:
757
+ _check_size("data", data)
758
+ stamp = now_iso()
759
+ return Progress(
760
+ state=state,
761
+ percent=None,
762
+ title=title,
763
+ icon=icon,
764
+ data=data or {},
765
+ result_kind=result_kind,
766
+ origin_session=session,
767
+ origin_connection=connection,
768
+ created_at=stamp,
769
+ updated_at=stamp,
770
+ )
771
+
772
+
773
+ class OperationScope:
774
+ """One object implementing all four dunders (TW-API-001, TW-API-002).
775
+
776
+ The asynchronous form is the primary one. The synchronous form runs a private event loop in a
777
+ thread it owns, which is what lets `reporter.sync` and the keepalive both work from code that
778
+ has no loop of its own - a Celery worker, for instance.
779
+ """
780
+
781
+ def __init__(
782
+ self,
783
+ token: str | None,
784
+ *,
785
+ session: str | None = None,
786
+ title: Text | None = None,
787
+ icon: str | None = None,
788
+ data: dict | None = None,
789
+ result_kind: str | None = None,
790
+ raise_on_cancel: bool | None = None,
791
+ connection: str | None = None,
792
+ store: Any = None,
793
+ clock: Callable[[], float] | None = None,
794
+ ) -> None:
795
+ self._token = token
796
+ self._session = session
797
+ self._title = title
798
+ self._icon = icon
799
+ self._data = data
800
+ self._result_kind = result_kind
801
+ self._raise_on_cancel = raise_on_cancel
802
+ self._connection = connection
803
+ self._store = store
804
+ self._clock = clock
805
+ self._reporter: Reporter | None = None
806
+ self._keepalive: asyncio.Task | None = None
807
+ self._loop: asyncio.AbstractEventLoop | None = None
808
+ self._loop_thread: threading.Thread | None = None
809
+ self._ambient_token: Any = None
810
+ # How many questions this operation is waiting on, counted on the root so a subtask's `ask()`
811
+ # is visible to whoever handles the worker being killed (TW-DLG-006).
812
+ self._asking = 0
813
+ # True when the binding belongs to the CALLER's context rather than to `__aenter__`'s. A
814
+ # `contextvars.Token` may only be reset in the context that created it, and a scope entered
815
+ # from a synchronous frame is entered inside a task of its own whose context is discarded
816
+ # on the way out - so binding there would leave nothing bound where the body runs and a
817
+ # Token that cannot be reset. See `bind_ambient_in_caller`.
818
+ self._caller_binds_ambient = False
819
+
820
+ @property
821
+ def _inert(self) -> bool:
822
+ return self._token is None or self._session is None
823
+
824
+ def _resolve_store(self) -> Any:
825
+ return self._store if self._store is not None else configured.store
826
+
827
+ # ---------------------------------------------------------------- async form
828
+
829
+ async def __aenter__(self) -> Reporter:
830
+ if self._inert:
831
+ self._reporter = InertReporter()
832
+ return self._reporter
833
+
834
+ store = self._resolve_store()
835
+ if store is None:
836
+ # No store configured is the same situation as no namespace: the application has not
837
+ # opted in. Be inert rather than raising into unrelated code.
838
+ self._reporter = InertReporter()
839
+ return self._reporter
840
+
841
+ ns = str(self._session)
842
+ token = str(self._token)
843
+ k = make_key(ns, token)
844
+
845
+ existing = await store.snapshot(k)
846
+ if existing is not None:
847
+ # `mark_queued()` already wrote the opening document; the write-once fields belong to
848
+ # it, and this transition only flips `queued` to `running` (TW-INV-009).
849
+ progress = existing.progress
850
+ progress.state = ProgressState.RUNNING
851
+ else:
852
+ progress = _opening_progress(
853
+ state=ProgressState.RUNNING,
854
+ session=ns,
855
+ title=self._title,
856
+ icon=self._icon,
857
+ data=self._data,
858
+ result_kind=self._result_kind,
859
+ connection=self._connection,
860
+ )
861
+
862
+ self._reporter = Reporter(
863
+ key=k,
864
+ ns=ns,
865
+ token=token,
866
+ store=store,
867
+ progress=progress,
868
+ clock=self._clock,
869
+ raise_on_cancel=self._raise_on_cancel,
870
+ )
871
+ self._reporter._loop = asyncio.get_running_loop()
872
+ # TW-API-001: bind the ambient reporter, so code six frames down can `await progress.set()`
873
+ # without every function between here and there growing a `reporter` parameter. The
874
+ # caller-bound form binds around the body instead - see `bind_ambient_in_caller`.
875
+ if not self._caller_binds_ambient:
876
+ self._ambient_token = ambient_bind(self._reporter)
877
+ await self._reporter._commit()
878
+ self._keepalive = asyncio.get_running_loop().create_task(self._keepalive_loop(store, k))
879
+ return self._reporter
880
+
881
+ async def _keepalive_loop(self, store: Any, k: str) -> None:
882
+ """TW-RET-002: touch every `active_ttl / 4`, without the operation's cooperation.
883
+
884
+ "Without cooperation" is the point. The phase that most needs the document to survive is the
885
+ one that never yields voluntarily, which is why the period is a quarter of the TTL rather
886
+ than something the caller schedules.
887
+ """
888
+ period = settings.keepalive_period
889
+ try:
890
+ while True:
891
+ await asyncio.sleep(period)
892
+ await store.touch(k, settings.active_ttl)
893
+ except asyncio.CancelledError: # pragma: no cover - normal shutdown path
894
+ raise
895
+ except Exception: # noqa: BLE001 - a failed keepalive must not surface in the operation
896
+ logger.debug("taskwire: keepalive stopped for %s", k, exc_info=True)
897
+
898
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
899
+ # First, and unconditionally. TW-AMB-005 wants the reset to go through the `Token` rather
900
+ # than an overwrite, and TW-INV-011 names the failure: a raising task that leaked its
901
+ # binding puts operation A's progress on operation B's key, on the next task to reuse the
902
+ # thread. Nothing below here may be able to skip it.
903
+ if not self._caller_binds_ambient:
904
+ ambient_unbind(self._ambient_token)
905
+ self._ambient_token = None
906
+
907
+ if self._keepalive is not None:
908
+ self._keepalive.cancel()
909
+ # The keepalive is being torn down deliberately; whatever it was doing is no longer
910
+ # wanted, and neither its cancellation nor a late failure inside it is news.
911
+ with contextlib.suppress(asyncio.CancelledError, Exception):
912
+ await self._keepalive
913
+ self._keepalive = None
914
+
915
+ reporter = self._reporter
916
+ if reporter is None or isinstance(reporter, InertReporter):
917
+ return False
918
+
919
+ state, error = _terminal_for(exc_type, exc)
920
+ # An error the operation named for itself outranks one derived from the exception's class
921
+ # (`name_error`). The class is the more specific fact only when nobody knew better: a worker
922
+ # killed while holding a question raises whatever its execution limit raises, and
923
+ # `dialog_timeout` with `retryable: true` is what the reader can act on (TW-DLG-006,
924
+ # TW-PROG-005).
925
+ if error is not None and reporter._progress.error is None:
926
+ reporter._progress.error = error
927
+
928
+ if state is ProgressState.DONE and reporter._result_parked:
929
+ # TW-RES-004: the work is over, the record is not. The operation parks in
930
+ # `waiting_input` and the block returns immediately - it does not wait for collection
931
+ # (TW-RES-005), and neither does the caller awaiting it (TW-RES-006).
932
+ await reporter._store.withdraw_dialogs(reporter._key)
933
+ await reporter._commit()
934
+ await reporter._store.touch(reporter._key, settings.result_ttl)
935
+ return False
936
+
937
+ # TW-CANCEL-011: withdraw first, so a dialog can never outlive the operation that asked it,
938
+ # and anyone blocked in `ask()` is woken rather than left on a question whose operation has
939
+ # already ended.
940
+ #
941
+ # The terminal write takes the entry out of the register in the same step (TW-REG-007, which
942
+ # the store enforces off the state) and leaves the document readable at its own address for
943
+ # `tombstone_ttl` (TW-RET-004). The envelope is still what a watching client sees; the
944
+ # tombstone is what a client that was not watching can read afterwards, and it is the only
945
+ # thing that can tell it `done` from `failed` from `cancelled`.
946
+ await reporter._store.withdraw_dialogs(reporter._key)
947
+ await reporter._commit(state=state, ttl=settings.tombstone_ttl)
948
+
949
+ # TW-API-001: re-raise in every failure case. Returning True here would swallow the
950
+ # application's exception behind a progress-reporting context manager, which is never what
951
+ # the caller meant.
952
+ return False
953
+
954
+ def bind_ambient_in_caller(self) -> None:
955
+ """Leave the ambient binding to the caller, who binds around the body in its own context.
956
+
957
+ For a caller that enters this scope from a synchronous frame by driving `__aenter__` on a
958
+ loop it already owns. The task that drives it has a context of its own which is discarded on
959
+ the way out, so a binding made inside `__aenter__` would be invisible where the body runs and
960
+ would carry a `contextvars.Token` that can never be reset (TW-AMB-005, TW-INV-011). Call this
961
+ before `__aenter__`, then `taskwire.ambient.bind` around the body and `unbind` in a `finally`.
962
+
963
+ `__enter__` does exactly that for the synchronous form; `wrap_sync_runner` does it for a
964
+ worker that owns its own loop (TW-CELERY-007).
965
+ """
966
+ self._caller_binds_ambient = True
967
+
968
+ # ---------------------------------------------------------------- sync form
969
+
970
+ def __enter__(self) -> Reporter:
971
+ """TW-API-002: a private event loop this scope creates and owns for this thread.
972
+
973
+ The loop runs in its own thread rather than being driven by `run_until_complete` from this
974
+ one. That is what lets the keepalive be an ordinary task on it and still run while the
975
+ calling thread is inside a CPU-bound phase - the synchronous form is the Celery form, and
976
+ Celery workers are exactly where a 90-second silent phase happens.
977
+ """
978
+ self._caller_binds_ambient = True
979
+ self._loop = asyncio.new_event_loop()
980
+ self._loop_thread = threading.Thread(target=self._loop.run_forever, name="taskwire-operation-loop", daemon=True)
981
+ self._loop_thread.start()
982
+ reporter = asyncio.run_coroutine_threadsafe(self.__aenter__(), self._loop).result()
983
+ reporter._loop = self._loop
984
+ # Bind here, in the caller's context, which is where the body will run.
985
+ self._ambient_token = ambient_bind(reporter)
986
+ return reporter
987
+
988
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
989
+ ambient_unbind(self._ambient_token)
990
+ self._ambient_token = None
991
+ try:
992
+ asyncio.run_coroutine_threadsafe(self.__aexit__(exc_type, exc, tb), self._loop).result()
993
+ finally:
994
+ loop, thread = self._loop, self._loop_thread
995
+ self._loop, self._loop_thread = None, None
996
+ if loop is not None:
997
+ loop.call_soon_threadsafe(loop.stop)
998
+ if thread is not None:
999
+ thread.join(timeout=5)
1000
+ if loop is not None:
1001
+ loop.close()
1002
+ return False
1003
+
1004
+
1005
+ def _terminal_for(exc_type: Any, exc: Any) -> tuple[ProgressState, Error | None]:
1006
+ """TW-API-001: exactly one completion, and `cancelled` is not `failed` (TW-CANCEL-006).
1007
+
1008
+ Collapsing `OperationCancelled` into `failed` would make a user-requested stop indistinguishable
1009
+ from a crash in every dashboard that reads the terminal state.
1010
+ """
1011
+ if exc_type is None:
1012
+ return ProgressState.DONE, None
1013
+ if issubclass(exc_type, OperationCancelled):
1014
+ return ProgressState.CANCELLED, None
1015
+ return ProgressState.FAILED, Error(
1016
+ code=exc_type.__name__,
1017
+ message=Text(text=str(exc) or exc_type.__name__),
1018
+ retryable=False,
1019
+ )
1020
+
1021
+
1022
+ def operation(
1023
+ token: str | None,
1024
+ *,
1025
+ session: str | None = None,
1026
+ title: Text | None = None,
1027
+ icon: str | None = None,
1028
+ data: dict | None = None,
1029
+ result_kind: str | None = None,
1030
+ raise_on_cancel: bool | None = None,
1031
+ connection: str | None = None,
1032
+ store: Any = None,
1033
+ clock: Callable[[], float] | None = None,
1034
+ ) -> OperationScope:
1035
+ """Open an operation. Async **and** sync context manager (TW-API-001, TW-API-002).
1036
+
1037
+ `session` is an already-resolved namespace string - never a user, a request or an identity
1038
+ object (TW-AMB-007). `session_resolver` is the only thing that produces one (TW-AMB-008), and a
1039
+ namespace crossing a process boundary travels as inert data rather than being resolved again on
1040
+ the far side.
1041
+
1042
+ `result_kind=None` makes the operation **private**: unlisted in the register, its progress
1043
+ fan-out scoped to the connection that started it, from start to finish (TW-PRIV-001). Privacy is
1044
+ not authorization - any caller of the namespace holding the token may still read it
1045
+ (TW-SEC-003).
1046
+ """
1047
+ return OperationScope(
1048
+ token,
1049
+ session=session,
1050
+ title=title,
1051
+ icon=icon,
1052
+ data=data,
1053
+ result_kind=result_kind,
1054
+ raise_on_cancel=raise_on_cancel,
1055
+ connection=connection,
1056
+ store=store,
1057
+ clock=clock,
1058
+ )
1059
+
1060
+
1061
+ async def mark_queued(
1062
+ token: str,
1063
+ *,
1064
+ session: str | None,
1065
+ title: Text | None = None,
1066
+ icon: str | None = None,
1067
+ data: dict | None = None,
1068
+ result_kind: str | None = None,
1069
+ connection: str | None = None,
1070
+ store: Any = None,
1071
+ ) -> None:
1072
+ """Write the opening `queued` document (TW-PROG-009).
1073
+
1074
+ This is the web side's write, and it is the one that fixes `result_kind` and `origin_connection`
1075
+ for the operation's whole life (TW-INV-009). The worker that later enters `operation()` inherits
1076
+ them and cannot change them.
1077
+ """
1078
+ if session is None:
1079
+ return None
1080
+ store = store if store is not None else configured.store
1081
+ if store is None:
1082
+ return None
1083
+
1084
+ k = make_key(session, token)
1085
+ if await store.snapshot(k) is not None:
1086
+ # Idempotent: a second `mark_queued` on a queued operation changes nothing, and on a running
1087
+ # or terminal one it must not reach back and rewrite the state (§4.1).
1088
+ return None
1089
+
1090
+ progress = _opening_progress(
1091
+ state=ProgressState.QUEUED,
1092
+ session=session,
1093
+ title=title,
1094
+ icon=icon,
1095
+ data=data,
1096
+ result_kind=result_kind,
1097
+ connection=connection,
1098
+ )
1099
+ written = await store.commit(k, progress, settings.active_ttl)
1100
+ await store.publish(
1101
+ session,
1102
+ Envelope(token=token, rev=written.rev, kind=EnvelopeKind.PROGRESS, body=progress.to_dict()),
1103
+ )
1104
+ return None
1105
+
1106
+
1107
+ async def mark_failed(
1108
+ token: str,
1109
+ *,
1110
+ session: str | None,
1111
+ error: BaseException | Error,
1112
+ store: Any = None,
1113
+ ) -> None:
1114
+ """Write `failed` from outside the operation - a dispatch that never reached a worker.
1115
+
1116
+ Takes the entry out of the register in the same step and leaves the document readable at its own
1117
+ address, exactly as `operation()`'s exit does (TW-REG-007, TW-RET-004): the caller is already
1118
+ watching a token, and a `404` would tell it nothing about why the work never started.
1119
+ """
1120
+ if session is None:
1121
+ return None
1122
+ store = store if store is not None else configured.store
1123
+ if store is None:
1124
+ return None
1125
+
1126
+ k = make_key(session, token)
1127
+ snapshot = await store.snapshot(k)
1128
+ if snapshot is None:
1129
+ return None
1130
+
1131
+ progress = snapshot.progress
1132
+ progress.error = (
1133
+ error
1134
+ if isinstance(error, Error)
1135
+ else Error(code=type(error).__name__, message=Text(text=str(error)), retryable=False)
1136
+ )
1137
+ progress.updated_at = now_iso()
1138
+ progress.state = ProgressState.FAILED
1139
+ written = await store.commit(k, progress, settings.tombstone_ttl)
1140
+ await store.publish(
1141
+ session,
1142
+ Envelope(token=token, rev=written.rev, kind=EnvelopeKind.PROGRESS, body=progress.to_dict()),
1143
+ )
1144
+ return None
1145
+
1146
+
1147
+ async def migrate_namespace(from_ns: str, to_ns: str) -> int:
1148
+ """TW-AMB-011: the **only** way operations change namespace, and the application is the only caller.
1149
+
1150
+ On login an application moves the anonymous session's operations onto the account with one call,
1151
+ the same shape as merging a shopping cart. taskwire does not infer that a login happened, does
1152
+ not compare a request's resolved namespace against a stored one, and does not expose this over
1153
+ REST (TW-SEC-008) - a migration is a bulk re-keying, and the only thing between it and a
1154
+ takeover is that the application, not a request, chooses both namespaces.
1155
+
1156
+ Publishes nothing, and is idempotent because `store.migrate` skips a token already present under
1157
+ the destination.
1158
+ """
1159
+ store = configured.store
1160
+ if store is None:
1161
+ return 0
1162
+ return await store.migrate(from_ns, to_ns)