harness-sdk-python 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.
@@ -0,0 +1,3 @@
1
+ from .run_manager import RunManager
2
+
3
+ __all__ = ["RunManager"]
@@ -0,0 +1,626 @@
1
+ """RunManager: the harness-sdk runs subsystem over a Statewire host.
2
+
3
+ Implements the full state x command matrix from runs.mdx on a single
4
+ ``start(ctx)`` executor entrypoint, per the RunManager design (d4987).
5
+ """
6
+
7
+ import asyncio
8
+ from dataclasses import dataclass
9
+ from typing import Any, Awaitable, Callable, Iterable
10
+
11
+ from statewire import CommandExecution, StatewireClientHandle, StatewireReject
12
+ from statewire.state import plain
13
+
14
+ _ABSENT: Any = object()
15
+
16
+ _CAPABILITIES = frozenset(
17
+ {
18
+ "files",
19
+ "adjacent-text-parts",
20
+ "interleaved-parts",
21
+ "rewind",
22
+ "rewind-during-run",
23
+ "assistant-edit",
24
+ "assistant-continuation",
25
+ "incomplete-continuation",
26
+ }
27
+ )
28
+
29
+ _ENTRY_TYPES = (
30
+ "message-send",
31
+ "message-edit",
32
+ "message-reload",
33
+ "input-resume",
34
+ "error-continue",
35
+ "stop-continue",
36
+ )
37
+
38
+ GetMessageMeta = Callable[[str], Awaitable[dict[str, Any] | None]]
39
+
40
+
41
+ def _reject(reason: str, message: str) -> StatewireReject:
42
+ return StatewireReject(message, payload={"reason": reason})
43
+
44
+
45
+ class RunManager:
46
+ def __init__(
47
+ self,
48
+ *,
49
+ state: Any,
50
+ start: Callable[["RunManager.StartContext"], Awaitable[Any]],
51
+ get_message_meta: GetMessageMeta,
52
+ create_task: Callable[[Any], "asyncio.Task[Any]"],
53
+ capabilities: Iterable[str] = (),
54
+ max_queued: int = 50,
55
+ ) -> None:
56
+ caps = frozenset(capabilities)
57
+ unknown = caps - _CAPABILITIES
58
+ if unknown:
59
+ raise ValueError(f"unknown capabilities: {sorted(unknown)}")
60
+ if "rewind-during-run" in caps and "rewind" not in caps:
61
+ raise ValueError("rewind-during-run requires the rewind capability")
62
+ if max_queued < 1:
63
+ raise ValueError("max_queued must be >= 1")
64
+ self._state = state
65
+ self._start = start
66
+ self._get_message_meta = get_message_meta
67
+ self._capabilities = caps
68
+ self._create_task = create_task
69
+ self._max_queued = max_queued
70
+ self._task: "asyncio.Task[Any] | None" = None
71
+ self._ctx: "RunManager.StartContext | None" = None
72
+ self._dispatched_ids: tuple[str, ...] = ()
73
+ self._dispatch_record: dict[str, Any] | None = None
74
+ self._callers: dict[str, StatewireClientHandle] = {}
75
+ self._init_state()
76
+
77
+ def _init_state(self) -> None:
78
+ # Runs state is write-only and not durable: overwrite whatever is there.
79
+ self._state["status"] = "ready"
80
+ self._state["error"] = None
81
+ self._state["queue"] = []
82
+ self._state["steerQueue"] = []
83
+ self._state.pop("dispatch", None)
84
+
85
+ def input_resume(self) -> None:
86
+ if self._status() != "input-required":
87
+ raise RuntimeError(
88
+ f"input_resume is only valid in input-required, not {self._status()!r}"
89
+ )
90
+ self._dispatch("input-resume", [])
91
+
92
+ # ─── State access ───────────────────────────────────────
93
+
94
+ def _status(self) -> str:
95
+ return self._state["status"]
96
+
97
+ def _lane_items(self, lane: str) -> list[dict[str, Any]]:
98
+ return list(plain(self._state[lane]))
99
+
100
+ def _lane_of(self, message_id: str) -> str | None:
101
+ for lane in ("steerQueue", "queue"):
102
+ if any(item["id"] == message_id for item in self._lane_items(lane)):
103
+ return lane
104
+ return None
105
+
106
+ def _continue_type(self) -> str:
107
+ return "error-continue" if self._status() == "error" else "stop-continue"
108
+
109
+ # ─── Dispatch and settle ────────────────────────────────
110
+
111
+ def _dispatch(
112
+ self,
113
+ type: str,
114
+ messages: list[dict[str, Any]],
115
+ *,
116
+ rollback_to: Any = _ABSENT,
117
+ ) -> None:
118
+ if type not in _ENTRY_TYPES:
119
+ raise ValueError(f"invalid entry type: {type!r}")
120
+ if self._task is not None:
121
+ raise RuntimeError("a run is already in flight")
122
+ caller = self._callers.get(messages[0]["id"]) if messages else None
123
+ messages = [
124
+ {k: v for k, v in message.items() if k != "caller"} for message in messages
125
+ ]
126
+ for message in messages:
127
+ self._callers.pop(message["id"], None)
128
+ record: dict[str, Any] = {"type": type, "messages": list(messages)}
129
+ if rollback_to is not _ABSENT:
130
+ record["rollbackTo"] = rollback_to
131
+ self._dispatch_record = record
132
+ self._state["error"] = None
133
+ if messages:
134
+ self._dispatched_ids = tuple(m["id"] for m in messages)
135
+ self._state["status"] = "running"
136
+ ctx = RunManager.StartContext(
137
+ type=type,
138
+ messages=tuple(messages),
139
+ caller=caller,
140
+ stop_requested=asyncio.Event(),
141
+ _manager=self,
142
+ _rollback_to=rollback_to,
143
+ )
144
+ self._ctx = ctx
145
+ self._task = self._create_task(self._run(ctx))
146
+
147
+ async def _run(self, ctx: "RunManager.StartContext") -> None:
148
+ try:
149
+ outcome = await self._start(ctx)
150
+ if not isinstance(
151
+ outcome,
152
+ (
153
+ RunManager.Complete,
154
+ RunManager.InputRequired,
155
+ RunManager.Error,
156
+ RunManager.Stop,
157
+ ),
158
+ ):
159
+ raise TypeError(
160
+ "start must return a RunManager outcome, got "
161
+ f"{type(outcome).__name__}"
162
+ )
163
+ except Exception as exc:
164
+ self._settle(ctx)
165
+ self._freeze(str(exc) or type(exc).__name__)
166
+ return
167
+ self._settle(ctx)
168
+ if isinstance(outcome, RunManager.Complete):
169
+ self._dispatched_ids = ()
170
+ if not self._drain():
171
+ self._state["status"] = "ready"
172
+ return
173
+ if isinstance(outcome, RunManager.InputRequired):
174
+ self._state["status"] = "input-required"
175
+ return
176
+ status = "error" if isinstance(outcome, RunManager.Error) else "stopped"
177
+ if outcome.dispatch_queue and self._drain():
178
+ return
179
+ self._state["status"] = status
180
+
181
+ def _settle(self, ctx: "RunManager.StartContext") -> None:
182
+ if self._ctx is ctx:
183
+ self._ctx = None
184
+ self._task = None
185
+ self._dispatch_record = None
186
+
187
+ def _drain(self) -> bool:
188
+ steer = self._lane_items("steerQueue")
189
+ if steer:
190
+ self._state["steerQueue"] = []
191
+ self._dispatch("message-send", steer)
192
+ return True
193
+ queue = self._lane_items("queue")
194
+ if queue:
195
+ self._state["queue"].pop(0)
196
+ self._dispatch("message-send", [queue[0]])
197
+ return True
198
+ return False
199
+
200
+ def _freeze(self, message: str) -> None:
201
+ self._state["status"] = "error"
202
+ self._state["error"] = {"message": message}
203
+
204
+ # ─── Message and placement validation ───────────────────
205
+
206
+ def _validated_message(self, message: Any) -> dict[str, Any]:
207
+ if not isinstance(message, dict):
208
+ raise _reject("invalid-message", "message must be an object")
209
+ if not isinstance(message.get("id"), str) or message["id"] == "":
210
+ raise _reject("invalid-message", "message.id must be a non-empty string")
211
+ if message.get("role") != "user":
212
+ raise _reject("invalid-message", 'message.role must be "user"')
213
+ parts = message.get("parts")
214
+ if not isinstance(parts, list) or len(parts) == 0:
215
+ raise _reject("invalid-message", "message.parts must be a non-empty array")
216
+ previous: str | None = None
217
+ seen_text = False
218
+ for part in parts:
219
+ kind = part.get("type") if isinstance(part, dict) else None
220
+ if kind == "text":
221
+ if not isinstance(part.get("text"), str):
222
+ raise _reject("invalid-message", "text parts must carry a string text")
223
+ if previous == "text" and "adjacent-text-parts" not in self._capabilities:
224
+ raise _reject(
225
+ "invalid-message",
226
+ "adjacent text parts require the adjacent-text-parts capability",
227
+ )
228
+ seen_text = True
229
+ elif kind == "file":
230
+ if "files" not in self._capabilities:
231
+ raise _reject("capability-missing", "the files capability is not enabled")
232
+ if not isinstance(part.get("mediaType"), str) or not isinstance(
233
+ part.get("url"), str
234
+ ):
235
+ raise _reject(
236
+ "invalid-message", "file parts must carry mediaType and url"
237
+ )
238
+ if seen_text and "interleaved-parts" not in self._capabilities:
239
+ raise _reject(
240
+ "invalid-message",
241
+ "file parts after text require the interleaved-parts capability",
242
+ )
243
+ else:
244
+ raise _reject("invalid-message", f"unknown part type: {kind!r}")
245
+ previous = kind
246
+ metadata = message.get("metadata", _ABSENT)
247
+ if metadata is not _ABSENT and not isinstance(metadata, dict):
248
+ raise _reject("invalid-message", "message.metadata must be an object")
249
+ return message
250
+
251
+ def _anchor_index(self, items: list[dict[str, Any]], anchor: str) -> int:
252
+ for index, item in enumerate(items):
253
+ if item["id"] == anchor:
254
+ return index
255
+ raise _reject("unknown-anchor", f"anchor {anchor} is not in the lane")
256
+
257
+ def _resolve_index(
258
+ self, items: list[dict[str, Any]], placement: dict[str, Any], fallback: int
259
+ ) -> int:
260
+ insert_after = placement.get("insertAfter", _ABSENT)
261
+ insert_before = placement.get("insertBefore", _ABSENT)
262
+ for name, value in (("insertAfter", insert_after), ("insertBefore", insert_before)):
263
+ if value is not _ABSENT and value is not None and not isinstance(value, str):
264
+ raise _reject("invalid-message", f"{name} must be an id or null")
265
+ if insert_before is _ABSENT:
266
+ if insert_after is _ABSENT:
267
+ return fallback
268
+ return 0 if insert_after is None else self._anchor_index(items, insert_after) + 1
269
+ if insert_after is _ABSENT:
270
+ return (
271
+ len(items)
272
+ if insert_before is None
273
+ else self._anchor_index(items, insert_before)
274
+ )
275
+ slot = 0 if insert_after is None else self._anchor_index(items, insert_after) + 1
276
+ if insert_before is not None:
277
+ self._anchor_index(items, insert_before)
278
+ at_slot = items[slot]["id"] if slot < len(items) else None
279
+ if at_slot != insert_before:
280
+ raise _reject("not-adjacent", "insertAfter and insertBefore are not adjacent")
281
+ return slot
282
+
283
+ # ─── Queue mutations ────────────────────────────────────
284
+
285
+ def _stamped(self, message: dict[str, Any]) -> dict[str, Any]:
286
+ entry = {k: v for k, v in message.items() if k != "caller"}
287
+ caller = self._callers.get(message["id"])
288
+ if caller is not None:
289
+ entry["caller"] = {"clientId": caller.client_id}
290
+ return entry
291
+
292
+ def _check_capacity(self, lane: str) -> None:
293
+ if len(self._lane_items(lane)) >= self._max_queued:
294
+ raise _reject(
295
+ "queue-full", f"queue is full ({self._max_queued} messages)"
296
+ )
297
+
298
+ def _place(
299
+ self,
300
+ target: str,
301
+ current: str,
302
+ message: dict[str, Any],
303
+ placement: dict[str, Any],
304
+ ) -> None:
305
+ lane_change = current != target
306
+ if lane_change:
307
+ self._check_capacity(target)
308
+ current_items = self._lane_items(current)
309
+ current_index = next(
310
+ i for i, item in enumerate(current_items) if item["id"] == message["id"]
311
+ )
312
+ without = [item for item in current_items if item["id"] != message["id"]]
313
+ base = self._lane_items(target) if lane_change else without
314
+ index = self._resolve_index(
315
+ base, placement, len(base) if lane_change else current_index
316
+ )
317
+ items = list(base)
318
+ items.insert(index, self._stamped(message))
319
+ if lane_change:
320
+ self._state[current] = without
321
+ self._state[target] = items
322
+ if target == "steerQueue" and self._status() in ("error", "stopped"):
323
+ self._dispatch(self._continue_type(), [])
324
+
325
+ def _add_new(
326
+ self, lane: str, message: dict[str, Any], placement: dict[str, Any]
327
+ ) -> None:
328
+ status = self._status()
329
+ if status == "ready":
330
+ self._dispatch("message-send", [message])
331
+ return
332
+ if status in ("error", "stopped"):
333
+ if lane == "steerQueue":
334
+ self._check_capacity(lane)
335
+ items = self._lane_items(lane)
336
+ items.insert(
337
+ self._resolve_index(items, placement, len(items)),
338
+ self._stamped(message),
339
+ )
340
+ self._state[lane] = items
341
+ self._dispatch(self._continue_type(), [])
342
+ return
343
+ if not self._lane_items("steerQueue") and not self._lane_items("queue"):
344
+ self._dispatch("message-send", [message])
345
+ return
346
+ self._check_capacity(lane)
347
+ items = self._lane_items(lane)
348
+ items.insert(
349
+ self._resolve_index(items, placement, len(items)), self._stamped(message)
350
+ )
351
+ self._state[lane] = items
352
+
353
+ async def _move(self, lane: str, message_id: Any, placement: dict[str, Any]) -> None:
354
+ if not isinstance(message_id, str):
355
+ raise _reject("invalid-message", "messageId must be a string")
356
+ current = self._lane_of(message_id)
357
+ if current is None:
358
+ if message_id in self._dispatched_ids:
359
+ if lane == "steerQueue":
360
+ return
361
+ raise _reject(
362
+ "already-dispatched", f"message {message_id} already dispatched"
363
+ )
364
+ raise _reject("unknown-id", f"message {message_id} is not queued")
365
+ item = next(i for i in self._lane_items(current) if i["id"] == message_id)
366
+ self._place(lane, current, item, placement)
367
+
368
+ async def _send(
369
+ self, lane: str, params: Any, caller: StatewireClientHandle | None
370
+ ) -> Any:
371
+ if not isinstance(params, dict):
372
+ raise _reject("invalid-message", "params must be an object")
373
+ has_message = "message" in params
374
+ has_message_id = "messageId" in params
375
+ if has_message == has_message_id:
376
+ raise _reject(
377
+ "invalid-message", "exactly one of message and messageId is required"
378
+ )
379
+ if not has_message:
380
+ return await self._move(lane, params["messageId"], params)
381
+ message = self._validated_message(params["message"])
382
+ message_id = message["id"]
383
+ previous = self._callers.get(message_id)
384
+ if caller is None:
385
+ self._callers.pop(message_id, None)
386
+ else:
387
+ self._callers[message_id] = caller
388
+ try:
389
+ current = self._lane_of(message_id)
390
+ if current is not None:
391
+ self._place(lane, current, message, params)
392
+ return None
393
+ if message_id in self._dispatched_ids:
394
+ return await self._edit_dispatched(message)
395
+ if await self._get_message_meta(message_id) is not None:
396
+ raise _reject("duplicate-id", f"message id {message_id} is already used")
397
+ self._add_new(lane, message, params)
398
+ return None
399
+ except StatewireReject:
400
+ if previous is None:
401
+ self._callers.pop(message_id, None)
402
+ else:
403
+ self._callers[message_id] = previous
404
+ raise
405
+
406
+ async def _edit_dispatched(self, message: dict[str, Any]) -> Any:
407
+ if self._task is not None:
408
+ if "rewind-during-run" not in self._capabilities:
409
+ raise _reject(
410
+ "capability-missing",
411
+ "editing during a run requires the rewind-during-run capability",
412
+ )
413
+ elif "rewind" not in self._capabilities:
414
+ raise _reject("capability-missing", "the rewind capability is not enabled")
415
+ meta = await self._get_message_meta(message["id"])
416
+ if meta is None:
417
+ raise _reject("unknown-id", f"message {message['id']} is unknown")
418
+ if self._task is not None and not meta["isLeaf"]:
419
+ raise _reject("not-leaf", "only the leaf may be edited during a run")
420
+ return self._rewind_dispatch("message-edit", [message], meta["parentId"])
421
+
422
+ def _rewind_dispatch(
423
+ self, type: str, messages: list[dict[str, Any]], rollback_to: Any
424
+ ) -> Any:
425
+ if self._task is None:
426
+ self._dispatch(type, messages, rollback_to=rollback_to)
427
+ return None
428
+
429
+ async def flow(ack: Callable[[], None]) -> None:
430
+ ack()
431
+ while self._task is not None:
432
+ assert self._ctx is not None
433
+ self._ctx.stop_requested.set()
434
+ await self._task
435
+ self._dispatch(type, messages, rollback_to=rollback_to)
436
+
437
+ return CommandExecution(flow)
438
+
439
+ # ─── Command handlers ───────────────────────────────────
440
+
441
+ async def enqueue(
442
+ self, params: Any, *, caller: StatewireClientHandle | None = None
443
+ ) -> Any:
444
+ return await self._send("queue", params, caller)
445
+
446
+ async def steer(
447
+ self, params: Any, *, caller: StatewireClientHandle | None = None
448
+ ) -> Any:
449
+ return await self._send("steerQueue", params, caller)
450
+
451
+ async def dequeue(self, params: Any) -> None:
452
+ message_id = params.get("messageId") if isinstance(params, dict) else None
453
+ if not isinstance(message_id, str):
454
+ raise _reject("invalid-message", "messageId must be a string")
455
+ lane = self._lane_of(message_id)
456
+ if lane is None:
457
+ raise _reject("unknown-id", f"message {message_id} is not queued")
458
+ self._callers.pop(message_id, None)
459
+ self._state[lane] = [
460
+ item for item in self._lane_items(lane) if item["id"] != message_id
461
+ ]
462
+
463
+ def _check_rewind_gate(self, command: str) -> None:
464
+ status = self._status()
465
+ if status == "input-required":
466
+ raise _reject("wrong-state", f"{command} is rejected in input-required")
467
+ if status == "running":
468
+ if "rewind-during-run" not in self._capabilities:
469
+ raise _reject(
470
+ "capability-missing",
471
+ f"{command} during a run requires the rewind-during-run capability",
472
+ )
473
+ elif "rewind" not in self._capabilities:
474
+ raise _reject("capability-missing", "the rewind capability is not enabled")
475
+
476
+ def _check_leaf(self, meta: dict[str, Any], command: str) -> None:
477
+ if meta["isLeaf"]:
478
+ return
479
+ if self._status() == "running":
480
+ raise _reject("not-leaf", f"only the leaf accepts {command} during a run")
481
+ if self._lane_items("steerQueue") or self._lane_items("queue"):
482
+ raise _reject(
483
+ "not-leaf",
484
+ f"only the leaf accepts {command} while the queue is non-empty",
485
+ )
486
+
487
+ async def edit(self, params: Any) -> Any:
488
+ self._check_rewind_gate("run/edit")
489
+ source_id = params.get("sourceId") if isinstance(params, dict) else None
490
+ if not isinstance(source_id, str):
491
+ raise _reject("invalid-message", "sourceId must be a string")
492
+ meta = await self._get_message_meta(source_id)
493
+ if meta is None:
494
+ raise _reject("unknown-id", f"message {source_id} is unknown")
495
+ self._check_leaf(meta, "run/edit")
496
+ if meta["role"] != "user" and "assistant-edit" not in self._capabilities:
497
+ raise _reject(
498
+ "capability-missing", "the assistant-edit capability is not enabled"
499
+ )
500
+ message = self._validated_message(
501
+ params.get("message") if isinstance(params, dict) else None
502
+ )
503
+ if message["id"] != source_id and (
504
+ self._lane_of(message["id"]) is not None
505
+ or await self._get_message_meta(message["id"]) is not None
506
+ ):
507
+ raise _reject("duplicate-id", f"message id {message['id']} is already used")
508
+ return self._rewind_dispatch("message-edit", [message], meta["parentId"])
509
+
510
+ async def reload(self, params: Any) -> Any:
511
+ self._check_rewind_gate("run/reload")
512
+ source_id = params.get("sourceId") if isinstance(params, dict) else None
513
+ if not isinstance(source_id, str):
514
+ raise _reject("invalid-message", "sourceId must be a string")
515
+ meta = await self._get_message_meta(source_id)
516
+ if meta is None:
517
+ raise _reject("unknown-id", f"message {source_id} is unknown")
518
+ if meta["role"] != "assistant":
519
+ raise _reject("invalid-message", "sourceId must name an assistant message")
520
+ self._check_leaf(meta, "run/reload")
521
+ if meta["parentId"] is not None:
522
+ parent = await self._get_message_meta(meta["parentId"])
523
+ if (
524
+ parent is not None
525
+ and parent["role"] == "assistant"
526
+ and "assistant-continuation" not in self._capabilities
527
+ ):
528
+ raise _reject(
529
+ "capability-missing",
530
+ "the assistant-continuation capability is not enabled",
531
+ )
532
+ return self._rewind_dispatch("message-reload", [], meta["parentId"])
533
+
534
+ async def stop(self) -> Any:
535
+ status = self._status()
536
+ if status == "running":
537
+ task = self._task
538
+ ctx = self._ctx
539
+ assert task is not None and ctx is not None
540
+ ctx.stop_requested.set()
541
+
542
+ async def settle(ack: Callable[[], None]) -> None:
543
+ ack()
544
+ await task
545
+
546
+ return CommandExecution(settle)
547
+ if status == "input-required":
548
+ self._state["status"] = "stopped"
549
+ return None
550
+ raise _reject("wrong-state", f"run/stop is rejected in {status}")
551
+
552
+ async def continue_run(self) -> None:
553
+ status = self._status()
554
+ if status not in ("error", "stopped"):
555
+ raise _reject("wrong-state", f"run/continue is rejected in {status}")
556
+ if not self._lane_items("steerQueue") and (
557
+ "incomplete-continuation" not in self._capabilities
558
+ ):
559
+ raise _reject(
560
+ "capability-missing",
561
+ "bare continue requires the incomplete-continuation capability",
562
+ )
563
+ self._dispatch(self._continue_type(), [])
564
+
565
+ # ─── Outcomes and context ───────────────────────────────
566
+
567
+ @dataclass(frozen=True)
568
+ class Complete:
569
+ pass
570
+
571
+ @dataclass(frozen=True)
572
+ class InputRequired:
573
+ pass
574
+
575
+ @dataclass(frozen=True, kw_only=True)
576
+ class Error:
577
+ dispatch_queue: bool
578
+
579
+ @dataclass(frozen=True, kw_only=True)
580
+ class Stop:
581
+ dispatch_queue: bool
582
+
583
+ @dataclass(frozen=True, eq=False)
584
+ class StartContext:
585
+ type: str
586
+ messages: tuple[dict[str, Any], ...]
587
+ caller: StatewireClientHandle | None
588
+ stop_requested: asyncio.Event
589
+ _manager: "RunManager"
590
+ _rollback_to: Any
591
+
592
+ @property
593
+ def has_rollback(self) -> bool:
594
+ return self._rollback_to is not _ABSENT
595
+
596
+ @property
597
+ def rollback_to(self) -> str | None:
598
+ if self._rollback_to is _ABSENT:
599
+ raise AttributeError(
600
+ "rollback_to is only present on rewind entries; check has_rollback"
601
+ )
602
+ return self._rollback_to
603
+
604
+ def _ensure_active(self) -> None:
605
+ if self._manager._ctx is not self:
606
+ raise RuntimeError("this run has already settled")
607
+
608
+ def has_steered(self) -> bool:
609
+ self._ensure_active()
610
+ return len(self._manager._lane_items("steerQueue")) > 0
611
+
612
+ def take_steered(self) -> tuple[dict[str, Any], ...]:
613
+ self._ensure_active()
614
+ items = self._manager._lane_items("steerQueue")
615
+ self._manager._state["steerQueue"] = []
616
+ for item in items:
617
+ self._manager._callers.pop(item["id"], None)
618
+ return tuple(
619
+ {k: v for k, v in item.items() if k != "caller"} for item in items
620
+ )
621
+
622
+ def set_recovery_state(self, value: Any) -> None:
623
+ self._ensure_active()
624
+ record = self._manager._dispatch_record
625
+ assert record is not None
626
+ record["recoveryState"] = value
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: harness-sdk-python
3
+ Version: 0.1.0
4
+ Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
+ Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
+ License-Expression: MIT
7
+ Requires-Python: <4.0,>=3.11
8
+ Requires-Dist: statewire<0.3,>=0.2.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # harness-sdk-python
12
+
13
+ RunManager: the harness-sdk runs subsystem for Python Statewire hosts — the
14
+ `run/*` command protocol (queues, steering, edit/reload, stop/resume, crash
15
+ replay) driving a single async `start(ctx)` executor entrypoint.
16
+
17
+ ```python
18
+ class MyHost(Statewire):
19
+ async def lifespan(self):
20
+ self.state = initial_state()
21
+ self.runs = RunManager(
22
+ state=self.state["runs"],
23
+ start=self._start,
24
+ get_message_meta=self._get_message_meta,
25
+ create_task=self.create_task,
26
+ capabilities=("rewind",),
27
+ )
28
+ yield
29
+
30
+ @command("run/enqueue")
31
+ async def run_enqueue(self, params, *, caller):
32
+ return await self.runs.enqueue(params, caller=caller)
33
+
34
+ @command("run/steer")
35
+ async def run_steer(self, params, *, caller):
36
+ return await self.runs.steer(params, caller=caller)
37
+
38
+ @command("run/dequeue")
39
+ async def run_dequeue(self, params):
40
+ return await self.runs.dequeue(params)
41
+
42
+ @command("run/edit")
43
+ async def run_edit(self, params):
44
+ return await self.runs.edit(params)
45
+
46
+ @command("run/reload")
47
+ async def run_reload(self, params):
48
+ return await self.runs.reload(params)
49
+
50
+ @command("run/stop")
51
+ async def run_stop(self):
52
+ return await self.runs.stop()
53
+
54
+ @command("run/continue")
55
+ async def run_continue(self):
56
+ return await self.runs.continue_run()
57
+ ```
@@ -0,0 +1,5 @@
1
+ harness_sdk/__init__.py,sha256=E4jrA6uGoPEPuCW0NGJKTAK6s8Ue5S8-b29ydtjYNSA,62
2
+ harness_sdk/run_manager.py,sha256=2Gdt_gW95mUajousNW0VZlmW-cXGTVzqZrhdegFsOyg,25325
3
+ harness_sdk_python-0.1.0.dist-info/METADATA,sha256=OW4L3fTDs0_AptpDarTDoBl1S9_xPKJl27lHJEcggTI,1770
4
+ harness_sdk_python-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ harness_sdk_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any