harness-sdk-python 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/PKG-INFO +5 -3
  2. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/README.md +1 -1
  3. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/pyproject.toml +5 -2
  4. harness_sdk_python-0.3.0/src/harness_sdk/fenced_postgres.py +247 -0
  5. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/src/harness_sdk/run_manager.py +497 -156
  6. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/run_helpers.py +37 -23
  7. harness_sdk_python-0.3.0/tests/test_batches.py +93 -0
  8. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_caller.py +5 -4
  9. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_edit_dispatched.py +2 -2
  10. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_edit_reload.py +1 -1
  11. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_enqueue.py +12 -12
  12. harness_sdk_python-0.3.0/tests/test_fenced_postgres.py +362 -0
  13. harness_sdk_python-0.3.0/tests/test_input_required.py +277 -0
  14. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_outcomes.py +11 -10
  15. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_placement.py +10 -10
  16. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_rewind_during_run.py +6 -5
  17. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_steer.py +7 -7
  18. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_stop_continue.py +45 -3
  19. harness_sdk_python-0.1.0/tests/test_input_required.py +0 -65
  20. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/.gitignore +0 -0
  21. {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/src/harness_sdk/__init__.py +0 -0
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
7
7
  Requires-Python: <4.0,>=3.11
8
- Requires-Dist: statewire<0.3,>=0.2.0
8
+ Requires-Dist: statewire<0.4,>=0.3.0
9
+ Provides-Extra: postgres
10
+ Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
9
11
  Description-Content-Type: text/markdown
10
12
 
11
13
  # harness-sdk-python
@@ -19,7 +21,7 @@ class MyHost(Statewire):
19
21
  async def lifespan(self):
20
22
  self.state = initial_state()
21
23
  self.runs = RunManager(
22
- state=self.state["runs"],
24
+ state=self.state,
23
25
  start=self._start,
24
26
  get_message_meta=self._get_message_meta,
25
27
  create_task=self.create_task,
@@ -9,7 +9,7 @@ class MyHost(Statewire):
9
9
  async def lifespan(self):
10
10
  self.state = initial_state()
11
11
  self.runs = RunManager(
12
- state=self.state["runs"],
12
+ state=self.state,
13
13
  start=self._start,
14
14
  get_message_meta=self._get_message_meta,
15
15
  create_task=self.create_task,
@@ -1,11 +1,14 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.11,<4.0"
8
- dependencies = ["statewire>=0.2.0,<0.3"]
8
+ dependencies = ["statewire>=0.3.0,<0.4"]
9
+
10
+ [project.optional-dependencies]
11
+ postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
9
12
 
10
13
  [project.urls]
11
14
  Repository = "https://github.com/assistant-ui/harness-sdk"
@@ -0,0 +1,247 @@
1
+ import re
2
+ from collections.abc import AsyncIterator, Callable, Sequence
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any
5
+
6
+ try:
7
+ from langchain_core.runnables import RunnableConfig
8
+ from langgraph.checkpoint.base import SerializerProtocol
9
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver, _ainternal
10
+ from psycopg import AsyncPipeline, errors
11
+ from psycopg.pq import TransactionStatus
12
+ from psycopg.cursor_async import AsyncCursor
13
+ from psycopg.rows import DictRow, dict_row
14
+ from psycopg.sql import SQL, Identifier, Literal
15
+ except ImportError as exc:
16
+ raise ImportError(
17
+ "harness_sdk.fenced_postgres requires the 'postgres' extra: "
18
+ "pip install 'harness-sdk-python[postgres]'"
19
+ ) from exc
20
+
21
+ DEFAULT_FENCE_TABLE = "checkpoint_fence"
22
+ FENCE_LOST_SQLSTATE = "FL001"
23
+
24
+ _IDENTIFIER_RE = re.compile(r"^[a-z_][a-z0-9_]*$")
25
+
26
+
27
+ class FenceLost(RuntimeError):
28
+ def __init__(self, thread_id: str, epoch: int) -> None:
29
+ super().__init__(
30
+ f"fence lost for thread {thread_id!r}: epoch {epoch} is no longer current"
31
+ )
32
+ self.thread_id = thread_id
33
+ self.epoch = epoch
34
+
35
+
36
+ def _require_thread_id(thread_id: str) -> str:
37
+ if not isinstance(thread_id, str) or not thread_id:
38
+ raise ValueError(f"thread_id must be a non-empty string, got {thread_id!r}")
39
+ return thread_id
40
+
41
+
42
+ def _require_fence_table(fence_table: str) -> str:
43
+ if not isinstance(fence_table, str) or not _IDENTIFIER_RE.match(fence_table):
44
+ raise ValueError(
45
+ f"fence_table must be a lowercase SQL identifier, got {fence_table!r}"
46
+ )
47
+ return fence_table
48
+
49
+
50
+ def _assert_fn(fence_table: str) -> str:
51
+ return f"{fence_table}_assert"
52
+
53
+
54
+ def _create_sql(fence_table: str) -> SQL:
55
+ return SQL(
56
+ "CREATE TABLE IF NOT EXISTS {} (thread_id TEXT PRIMARY KEY, epoch BIGINT NOT NULL)"
57
+ ).format(Identifier(fence_table))
58
+
59
+
60
+ # FOR UPDATE holds the fence row against concurrent epoch bumps until the
61
+ # surrounding write transaction ends; the RAISE aborts the pipeline/transaction
62
+ def _create_fn_sql(fence_table: str) -> SQL:
63
+ return SQL(
64
+ "CREATE OR REPLACE FUNCTION {fn}(p_thread_id TEXT, p_epoch BIGINT) "
65
+ "RETURNS void LANGUAGE plpgsql AS $$\n"
66
+ "BEGIN\n"
67
+ " PERFORM 1 FROM {t} WHERE thread_id = p_thread_id AND epoch = p_epoch FOR UPDATE;\n"
68
+ " IF NOT FOUND THEN\n"
69
+ " RAISE EXCEPTION 'fence lost for thread %: epoch % is not current', "
70
+ "p_thread_id, p_epoch USING ERRCODE = {code};\n"
71
+ " END IF;\n"
72
+ "END;\n"
73
+ "$$"
74
+ ).format(
75
+ fn=Identifier(_assert_fn(fence_table)),
76
+ t=Identifier(fence_table),
77
+ code=Literal(FENCE_LOST_SQLSTATE),
78
+ )
79
+
80
+
81
+ def _bump_sql(fence_table: str) -> SQL:
82
+ return SQL(
83
+ "INSERT INTO {t} (thread_id, epoch) VALUES (%s, 1) "
84
+ "ON CONFLICT (thread_id) DO UPDATE SET epoch = {t}.epoch + 1 "
85
+ "RETURNING epoch"
86
+ ).format(t=Identifier(fence_table))
87
+
88
+
89
+ def _assert_sql(fence_table: str) -> SQL:
90
+ return SQL("SELECT {}(%s, %s)").format(Identifier(_assert_fn(fence_table)))
91
+
92
+
93
+ def _is_fence_lost(exc: BaseException | None) -> bool:
94
+ seen: set[int] = set()
95
+ while exc is not None and id(exc) not in seen:
96
+ seen.add(id(exc))
97
+ if isinstance(exc, errors.Error) and exc.sqlstate == FENCE_LOST_SQLSTATE:
98
+ return True
99
+ exc = exc.__cause__ or exc.__context__
100
+ return False
101
+
102
+
103
+ def _missing_fence_error(missing: str) -> RuntimeError:
104
+ return RuntimeError(
105
+ f"{missing} does not exist; "
106
+ "pass create_fence_table=True or create it before acquiring"
107
+ )
108
+
109
+
110
+ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
111
+ def __init__(
112
+ self,
113
+ conn: _ainternal.Conn,
114
+ thread_id: str,
115
+ epoch: int,
116
+ *,
117
+ pipe: AsyncPipeline | None = None,
118
+ serde: SerializerProtocol | None = None,
119
+ fence_table: str = DEFAULT_FENCE_TABLE,
120
+ on_fence_lost: Callable[[], None] | None = None,
121
+ ) -> None:
122
+ super().__init__(conn, pipe=pipe, serde=serde)
123
+ self.thread_id = _require_thread_id(thread_id)
124
+ self.epoch = epoch
125
+ self.fence_table = _require_fence_table(fence_table)
126
+ self.on_fence_lost = on_fence_lost
127
+ self._fence_lost_notified = False
128
+
129
+ @classmethod
130
+ async def acquire(
131
+ cls,
132
+ conn: _ainternal.Conn,
133
+ thread_id: str,
134
+ *,
135
+ create_fence_table: bool,
136
+ pipe: AsyncPipeline | None = None,
137
+ serde: SerializerProtocol | None = None,
138
+ fence_table: str = DEFAULT_FENCE_TABLE,
139
+ on_fence_lost: Callable[[], None] | None = None,
140
+ ) -> "AsyncFencedPostgresSaver":
141
+ _require_thread_id(thread_id)
142
+ _require_fence_table(fence_table)
143
+ async with _ainternal.get_connection(conn) as c:
144
+ if c.info.transaction_status != TransactionStatus.IDLE:
145
+ raise RuntimeError(
146
+ "acquire requires a connection with no transaction in progress"
147
+ )
148
+ async with (
149
+ c.transaction(),
150
+ c.cursor(binary=True, row_factory=dict_row) as cur,
151
+ ):
152
+ if create_fence_table:
153
+ await cur.execute(_create_sql(fence_table))
154
+ await cur.execute(_create_fn_sql(fence_table))
155
+ else:
156
+ await cur.execute(
157
+ "SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
158
+ (fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
159
+ )
160
+ row = await cur.fetchone()
161
+ if row["tbl"] is None:
162
+ raise _missing_fence_error(f"fence table {fence_table!r}")
163
+ if row["fn"] is None:
164
+ raise _missing_fence_error(
165
+ f"fence function {_assert_fn(fence_table)!r}"
166
+ )
167
+ await cur.execute(_bump_sql(fence_table), (thread_id,))
168
+ epoch = (await cur.fetchone())["epoch"]
169
+ if pipe is not None:
170
+ await pipe.sync()
171
+ return cls(
172
+ conn,
173
+ thread_id,
174
+ epoch,
175
+ pipe=pipe,
176
+ serde=serde,
177
+ fence_table=fence_table,
178
+ on_fence_lost=on_fence_lost,
179
+ )
180
+
181
+ def _fence_lost(self) -> FenceLost:
182
+ if self.on_fence_lost is not None and not self._fence_lost_notified:
183
+ self._fence_lost_notified = True
184
+ self.on_fence_lost()
185
+ return FenceLost(self.thread_id, self.epoch)
186
+
187
+ def _bound_to(self, thread_id: Any) -> None:
188
+ if str(thread_id) != self.thread_id:
189
+ raise ValueError(
190
+ f"saver is fenced to thread {self.thread_id!r}, got {thread_id!r}"
191
+ )
192
+
193
+ @asynccontextmanager
194
+ async def _cursor(
195
+ self, *, pipeline: bool = False
196
+ ) -> AsyncIterator[AsyncCursor[DictRow]]:
197
+ if not pipeline:
198
+ async with super()._cursor(pipeline=False) as cur:
199
+ yield cur
200
+ return
201
+ try:
202
+ async with super()._cursor(pipeline=True) as cur:
203
+ await cur.execute(_assert_sql(self.fence_table), (self.thread_id, self.epoch))
204
+ yield cur
205
+ except errors.Error as exc:
206
+ if _is_fence_lost(exc):
207
+ raise self._fence_lost() from exc
208
+ # An aborted pipeline can surface before the assert's FL001 does;
209
+ # the fence row decides which it was.
210
+ if isinstance(exc, errors.PipelineAborted) and await self._fence_stale():
211
+ raise self._fence_lost() from exc
212
+ raise
213
+
214
+ async def _fence_stale(self) -> bool:
215
+ async with super()._cursor(pipeline=False) as cur:
216
+ await cur.execute(
217
+ SQL("SELECT epoch FROM {} WHERE thread_id = %s").format(
218
+ Identifier(self.fence_table)
219
+ ),
220
+ (self.thread_id,),
221
+ )
222
+ row = await cur.fetchone()
223
+ return row is None or row["epoch"] != self.epoch
224
+
225
+ async def aput(
226
+ self,
227
+ config: RunnableConfig,
228
+ checkpoint: Any,
229
+ metadata: Any,
230
+ new_versions: Any,
231
+ ) -> RunnableConfig:
232
+ self._bound_to(config["configurable"]["thread_id"])
233
+ return await super().aput(config, checkpoint, metadata, new_versions)
234
+
235
+ async def aput_writes(
236
+ self,
237
+ config: RunnableConfig,
238
+ writes: Sequence[tuple[str, Any]],
239
+ task_id: str,
240
+ task_path: str = "",
241
+ ) -> None:
242
+ self._bound_to(config["configurable"]["thread_id"])
243
+ await super().aput_writes(config, writes, task_id, task_path)
244
+
245
+ async def adelete_thread(self, thread_id: str) -> None:
246
+ self._bound_to(thread_id)
247
+ await super().adelete_thread(thread_id)