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.
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/PKG-INFO +5 -3
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/README.md +1 -1
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/pyproject.toml +5 -2
- harness_sdk_python-0.3.0/src/harness_sdk/fenced_postgres.py +247 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/src/harness_sdk/run_manager.py +497 -156
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/run_helpers.py +37 -23
- harness_sdk_python-0.3.0/tests/test_batches.py +93 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_caller.py +5 -4
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_edit_dispatched.py +2 -2
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_edit_reload.py +1 -1
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_enqueue.py +12 -12
- harness_sdk_python-0.3.0/tests/test_fenced_postgres.py +362 -0
- harness_sdk_python-0.3.0/tests/test_input_required.py +277 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_outcomes.py +11 -10
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_placement.py +10 -10
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_rewind_during_run.py +6 -5
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_steer.py +7 -7
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/tests/test_stop_continue.py +45 -3
- harness_sdk_python-0.1.0/tests/test_input_required.py +0 -65
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.3.0}/.gitignore +0 -0
- {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.
|
|
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.
|
|
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
|
|
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,
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "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.
|
|
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)
|