harness-sdk-python 0.1.0__tar.gz → 0.2.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 (19) hide show
  1. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/PKG-INFO +3 -1
  2. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/pyproject.toml +4 -1
  3. harness_sdk_python-0.2.0/src/harness_sdk/fenced_postgres.py +220 -0
  4. harness_sdk_python-0.2.0/tests/test_fenced_postgres.py +255 -0
  5. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/.gitignore +0 -0
  6. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/README.md +0 -0
  7. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/src/harness_sdk/__init__.py +0 -0
  8. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/src/harness_sdk/run_manager.py +0 -0
  9. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/run_helpers.py +0 -0
  10. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_caller.py +0 -0
  11. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_edit_dispatched.py +0 -0
  12. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_edit_reload.py +0 -0
  13. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_enqueue.py +0 -0
  14. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_input_required.py +0 -0
  15. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_outcomes.py +0 -0
  16. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_placement.py +0 -0
  17. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_rewind_during_run.py +0 -0
  18. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_steer.py +0 -0
  19. {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_stop_continue.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.2.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
8
  Requires-Dist: statewire<0.3,>=0.2.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
@@ -1,12 +1,15 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.1.0"
3
+ version = "0.2.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
8
  dependencies = ["statewire>=0.2.0,<0.3"]
9
9
 
10
+ [project.optional-dependencies]
11
+ postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
12
+
10
13
  [project.urls]
11
14
  Repository = "https://github.com/assistant-ui/harness-sdk"
12
15
 
@@ -0,0 +1,220 @@
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.cursor_async import AsyncCursor
12
+ from psycopg.rows import DictRow, dict_row
13
+ from psycopg.sql import SQL, Identifier, Literal
14
+ except ImportError as exc:
15
+ raise ImportError(
16
+ "harness_sdk.fenced_postgres requires the 'postgres' extra: "
17
+ "pip install 'harness-sdk-python[postgres]'"
18
+ ) from exc
19
+
20
+ DEFAULT_FENCE_TABLE = "checkpoint_fence"
21
+ FENCE_LOST_SQLSTATE = "FL001"
22
+
23
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
24
+
25
+
26
+ class FenceLost(RuntimeError):
27
+ def __init__(self, thread_id: str, epoch: int) -> None:
28
+ super().__init__(
29
+ f"fence lost for thread {thread_id!r}: epoch {epoch} is no longer current"
30
+ )
31
+ self.thread_id = thread_id
32
+ self.epoch = epoch
33
+
34
+
35
+ def _require_thread_id(thread_id: str) -> str:
36
+ if not isinstance(thread_id, str) or not thread_id:
37
+ raise ValueError(f"thread_id must be a non-empty string, got {thread_id!r}")
38
+ return thread_id
39
+
40
+
41
+ def _require_fence_table(fence_table: str) -> str:
42
+ if not isinstance(fence_table, str) or not _IDENTIFIER_RE.match(fence_table):
43
+ raise ValueError(
44
+ f"fence_table must be an unquoted SQL identifier, got {fence_table!r}"
45
+ )
46
+ return fence_table
47
+
48
+
49
+ def _assert_fn(fence_table: str) -> str:
50
+ return f"{fence_table}_assert"
51
+
52
+
53
+ def _create_sql(fence_table: str) -> SQL:
54
+ return SQL(
55
+ "CREATE TABLE IF NOT EXISTS {} (thread_id TEXT PRIMARY KEY, epoch BIGINT NOT NULL)"
56
+ ).format(Identifier(fence_table))
57
+
58
+
59
+ # FOR UPDATE holds the fence row against concurrent epoch bumps until the
60
+ # surrounding write transaction ends; the RAISE aborts the pipeline/transaction
61
+ def _create_fn_sql(fence_table: str) -> SQL:
62
+ return SQL(
63
+ "CREATE OR REPLACE FUNCTION {fn}(p_thread_id TEXT, p_epoch BIGINT) "
64
+ "RETURNS void LANGUAGE plpgsql AS $$\n"
65
+ "BEGIN\n"
66
+ " PERFORM 1 FROM {t} WHERE thread_id = p_thread_id AND epoch = p_epoch FOR UPDATE;\n"
67
+ " IF NOT FOUND THEN\n"
68
+ " RAISE EXCEPTION 'fence lost for thread %: epoch % is not current', "
69
+ "p_thread_id, p_epoch USING ERRCODE = {code};\n"
70
+ " END IF;\n"
71
+ "END;\n"
72
+ "$$"
73
+ ).format(
74
+ fn=Identifier(_assert_fn(fence_table)),
75
+ t=Identifier(fence_table),
76
+ code=Literal(FENCE_LOST_SQLSTATE),
77
+ )
78
+
79
+
80
+ def _bump_sql(fence_table: str) -> SQL:
81
+ return SQL(
82
+ "INSERT INTO {t} (thread_id, epoch) VALUES (%s, 1) "
83
+ "ON CONFLICT (thread_id) DO UPDATE SET epoch = {t}.epoch + 1 "
84
+ "RETURNING epoch"
85
+ ).format(t=Identifier(fence_table))
86
+
87
+
88
+ def _assert_sql(fence_table: str) -> SQL:
89
+ return SQL("SELECT {}(%s, %s)").format(Identifier(_assert_fn(fence_table)))
90
+
91
+
92
+ def _is_fence_lost(exc: errors.Error) -> bool:
93
+ return exc.sqlstate == FENCE_LOST_SQLSTATE
94
+
95
+
96
+ def _missing_fence_error(missing: str) -> RuntimeError:
97
+ return RuntimeError(
98
+ f"{missing} does not exist; "
99
+ "pass create_fence_table=True or create it before acquiring"
100
+ )
101
+
102
+
103
+ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
104
+ def __init__(
105
+ self,
106
+ conn: _ainternal.Conn,
107
+ thread_id: str,
108
+ epoch: int,
109
+ *,
110
+ pipe: AsyncPipeline | None = None,
111
+ serde: SerializerProtocol | None = None,
112
+ fence_table: str = DEFAULT_FENCE_TABLE,
113
+ on_fence_lost: Callable[[], None] | None = None,
114
+ ) -> None:
115
+ super().__init__(conn, pipe=pipe, serde=serde)
116
+ self.thread_id = _require_thread_id(thread_id)
117
+ self.epoch = epoch
118
+ self.fence_table = _require_fence_table(fence_table)
119
+ self.on_fence_lost = on_fence_lost
120
+ self._fence_lost_notified = False
121
+
122
+ @classmethod
123
+ async def acquire(
124
+ cls,
125
+ conn: _ainternal.Conn,
126
+ thread_id: str,
127
+ *,
128
+ create_fence_table: bool,
129
+ pipe: AsyncPipeline | None = None,
130
+ serde: SerializerProtocol | None = None,
131
+ fence_table: str = DEFAULT_FENCE_TABLE,
132
+ on_fence_lost: Callable[[], None] | None = None,
133
+ ) -> "AsyncFencedPostgresSaver":
134
+ _require_thread_id(thread_id)
135
+ _require_fence_table(fence_table)
136
+ async with (
137
+ _ainternal.get_connection(conn) as c,
138
+ c.cursor(binary=True, row_factory=dict_row) as cur,
139
+ ):
140
+ if create_fence_table:
141
+ await cur.execute(_create_sql(fence_table))
142
+ await cur.execute(_create_fn_sql(fence_table))
143
+ else:
144
+ await cur.execute(
145
+ "SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
146
+ (fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
147
+ )
148
+ row = await cur.fetchone()
149
+ if row["tbl"] is None:
150
+ raise _missing_fence_error(f"fence table {fence_table!r}")
151
+ if row["fn"] is None:
152
+ raise _missing_fence_error(
153
+ f"fence function {_assert_fn(fence_table)!r}"
154
+ )
155
+ await cur.execute(_bump_sql(fence_table), (thread_id,))
156
+ epoch = (await cur.fetchone())["epoch"]
157
+ if pipe is not None:
158
+ await pipe.sync()
159
+ return cls(
160
+ conn,
161
+ thread_id,
162
+ epoch,
163
+ pipe=pipe,
164
+ serde=serde,
165
+ fence_table=fence_table,
166
+ on_fence_lost=on_fence_lost,
167
+ )
168
+
169
+ def _fence_lost(self) -> FenceLost:
170
+ if self.on_fence_lost is not None and not self._fence_lost_notified:
171
+ self._fence_lost_notified = True
172
+ self.on_fence_lost()
173
+ return FenceLost(self.thread_id, self.epoch)
174
+
175
+ def _bound_to(self, thread_id: Any) -> None:
176
+ if str(thread_id) != self.thread_id:
177
+ raise ValueError(
178
+ f"saver is fenced to thread {self.thread_id!r}, got {thread_id!r}"
179
+ )
180
+
181
+ @asynccontextmanager
182
+ async def _cursor(
183
+ self, *, pipeline: bool = False
184
+ ) -> AsyncIterator[AsyncCursor[DictRow]]:
185
+ if not pipeline:
186
+ async with super()._cursor(pipeline=False) as cur:
187
+ yield cur
188
+ return
189
+ try:
190
+ async with super()._cursor(pipeline=True) as cur:
191
+ await cur.execute(_assert_sql(self.fence_table), (self.thread_id, self.epoch))
192
+ yield cur
193
+ except errors.Error as exc:
194
+ if not _is_fence_lost(exc):
195
+ raise
196
+ raise self._fence_lost() from exc
197
+
198
+ async def aput(
199
+ self,
200
+ config: RunnableConfig,
201
+ checkpoint: Any,
202
+ metadata: Any,
203
+ new_versions: Any,
204
+ ) -> RunnableConfig:
205
+ self._bound_to(config["configurable"]["thread_id"])
206
+ return await super().aput(config, checkpoint, metadata, new_versions)
207
+
208
+ async def aput_writes(
209
+ self,
210
+ config: RunnableConfig,
211
+ writes: Sequence[tuple[str, Any]],
212
+ task_id: str,
213
+ task_path: str = "",
214
+ ) -> None:
215
+ self._bound_to(config["configurable"]["thread_id"])
216
+ await super().aput_writes(config, writes, task_id, task_path)
217
+
218
+ async def adelete_thread(self, thread_id: str) -> None:
219
+ self._bound_to(thread_id)
220
+ await super().adelete_thread(thread_id)
@@ -0,0 +1,255 @@
1
+ import os
2
+ import shutil
3
+ import socket
4
+ import subprocess
5
+ import time
6
+ import uuid
7
+
8
+ import psycopg
9
+ import pytest
10
+ from langgraph.checkpoint.base import empty_checkpoint
11
+ from psycopg.rows import dict_row
12
+
13
+ from harness_sdk.fenced_postgres import AsyncFencedPostgresSaver, FenceLost
14
+
15
+ THREAD = "thread-1"
16
+
17
+
18
+ def _free_port():
19
+ with socket.socket() as s:
20
+ s.bind(("127.0.0.1", 0))
21
+ return s.getsockname()[1]
22
+
23
+
24
+ def _wait_ready(dsn, timeout=120.0):
25
+ deadline = time.monotonic() + timeout
26
+ while time.monotonic() < deadline:
27
+ try:
28
+ psycopg.connect(dsn, connect_timeout=2).close()
29
+ return
30
+ except psycopg.OperationalError:
31
+ time.sleep(0.5)
32
+ raise RuntimeError(f"postgres at {dsn} did not become ready within {timeout}s")
33
+
34
+
35
+ @pytest.fixture(scope="session")
36
+ def postgres_dsn():
37
+ if dsn := os.environ.get("HARNESS_TEST_POSTGRES_DSN"):
38
+ yield dsn
39
+ return
40
+ docker = shutil.which("docker")
41
+ if docker is None or subprocess.run(
42
+ [docker, "info"], capture_output=True
43
+ ).returncode:
44
+ pytest.skip(
45
+ "postgres unavailable: set HARNESS_TEST_POSTGRES_DSN or start docker"
46
+ )
47
+ port = _free_port()
48
+ container = subprocess.run(
49
+ [
50
+ docker, "run", "--rm", "-d",
51
+ "-e", "POSTGRES_PASSWORD=harness",
52
+ "-p", f"127.0.0.1:{port}:5432",
53
+ "postgres:16",
54
+ ],
55
+ capture_output=True, text=True, check=True,
56
+ ).stdout.strip()
57
+ dsn = f"postgresql://postgres:harness@127.0.0.1:{port}/postgres"
58
+ try:
59
+ _wait_ready(dsn)
60
+ yield dsn
61
+ finally:
62
+ subprocess.run([docker, "rm", "-f", container], capture_output=True)
63
+
64
+
65
+ @pytest.fixture
66
+ def dsn(postgres_dsn):
67
+ dbname = f"fence_test_{uuid.uuid4().hex[:12]}"
68
+ with psycopg.connect(postgres_dsn, autocommit=True) as admin:
69
+ admin.execute(f'CREATE DATABASE "{dbname}"')
70
+ yield postgres_dsn.rsplit("/", 1)[0] + f"/{dbname}"
71
+ with psycopg.connect(postgres_dsn, autocommit=True) as admin:
72
+ admin.execute(f'DROP DATABASE "{dbname}" WITH (FORCE)')
73
+
74
+
75
+ @pytest.fixture
76
+ async def aconnect(dsn):
77
+ conns = []
78
+
79
+ async def _connect():
80
+ conn = await psycopg.AsyncConnection.connect(
81
+ dsn, autocommit=True, prepare_threshold=0, row_factory=dict_row
82
+ )
83
+ conns.append(conn)
84
+ return conn
85
+
86
+ yield _connect
87
+ for conn in conns:
88
+ await conn.close()
89
+
90
+
91
+ def make_config(thread_id=THREAD, checkpoint_id=None):
92
+ return {
93
+ "configurable": {
94
+ "thread_id": thread_id,
95
+ "checkpoint_ns": "",
96
+ "checkpoint_id": checkpoint_id or str(uuid.uuid4()),
97
+ }
98
+ }
99
+
100
+
101
+ def thread_config(thread_id=THREAD):
102
+ return {"configurable": {"thread_id": thread_id}}
103
+
104
+
105
+ async def aput_checkpoint(saver, thread_id=THREAD):
106
+ checkpoint = empty_checkpoint()
107
+ checkpoint["id"] = str(uuid.uuid4())
108
+ return await saver.aput(
109
+ make_config(thread_id), checkpoint, {"source": "loop", "step": 1}, {}
110
+ )
111
+
112
+
113
+ async def aacquire(aconnect, thread_id=THREAD, **kwargs):
114
+ saver = await AsyncFencedPostgresSaver.acquire(
115
+ await aconnect(), thread_id, create_fence_table=True, **kwargs
116
+ )
117
+ await saver.setup()
118
+ return saver
119
+
120
+
121
+ class TestFencing:
122
+ async def test_acquire_bumps_epoch_monotonically(self, aconnect):
123
+ first = await aacquire(aconnect)
124
+ second = await aacquire(aconnect)
125
+ assert first.epoch == 1
126
+ assert second.epoch == 2
127
+
128
+ async def test_same_instance_writes_keep_working(self, aconnect):
129
+ saver = await aacquire(aconnect)
130
+ for _ in range(10):
131
+ config = await aput_checkpoint(saver)
132
+ await saver.aput_writes(config, [("channel", "value")], "task-1")
133
+ assert len([c async for c in saver.alist(thread_config())]) == 10
134
+ await saver.adelete_thread(THREAD)
135
+ assert [c async for c in saver.alist(thread_config())] == []
136
+
137
+ async def test_new_instance_fences_old_writes(self, aconnect):
138
+ old = await aacquire(aconnect)
139
+ config = await aput_checkpoint(old)
140
+ new = await aacquire(aconnect)
141
+ with pytest.raises(FenceLost):
142
+ await aput_checkpoint(old)
143
+ with pytest.raises(FenceLost):
144
+ await old.aput_writes(config, [("channel", "value")], "task-1")
145
+ with pytest.raises(FenceLost):
146
+ await old.adelete_thread(THREAD)
147
+ await aput_checkpoint(new)
148
+
149
+ async def test_fenced_write_leaves_no_trace(self, aconnect):
150
+ old = await aacquire(aconnect)
151
+ await aput_checkpoint(old)
152
+ await aacquire(aconnect)
153
+ with pytest.raises(FenceLost):
154
+ await aput_checkpoint(old)
155
+ assert len([c async for c in old.alist(thread_config())]) == 1
156
+
157
+ async def test_reacquire_kills_old_handle_of_same_worker(self, aconnect):
158
+ conn = await aconnect()
159
+ old = await AsyncFencedPostgresSaver.acquire(
160
+ conn, THREAD, create_fence_table=True
161
+ )
162
+ await old.setup()
163
+ reacquired = await AsyncFencedPostgresSaver.acquire(
164
+ conn, THREAD, create_fence_table=True
165
+ )
166
+ assert reacquired.epoch == old.epoch + 1
167
+ with pytest.raises(FenceLost):
168
+ await aput_checkpoint(old)
169
+ await aput_checkpoint(reacquired)
170
+
171
+ async def test_fences_are_per_thread(self, aconnect):
172
+ other = await aacquire(aconnect, "thread-2")
173
+ mine = await aacquire(aconnect)
174
+ await aacquire(aconnect, "thread-2")
175
+ await aput_checkpoint(mine)
176
+ with pytest.raises(FenceLost):
177
+ await aput_checkpoint(other, "thread-2")
178
+
179
+ async def test_rejects_writes_for_other_threads(self, aconnect):
180
+ saver = await aacquire(aconnect)
181
+ with pytest.raises(ValueError):
182
+ await aput_checkpoint(saver, "thread-2")
183
+ with pytest.raises(ValueError):
184
+ await saver.aput_writes(make_config("thread-2"), [("c", "v")], "task-1")
185
+ with pytest.raises(ValueError):
186
+ await saver.adelete_thread("thread-2")
187
+
188
+ async def test_acquire_rejects_invalid_thread_id(self, aconnect):
189
+ with pytest.raises(ValueError):
190
+ await AsyncFencedPostgresSaver.acquire(
191
+ await aconnect(), "", create_fence_table=True
192
+ )
193
+ with pytest.raises(ValueError):
194
+ await AsyncFencedPostgresSaver.acquire(
195
+ await aconnect(), None, create_fence_table=True
196
+ )
197
+
198
+
199
+ class TestFenceOptions:
200
+ async def test_custom_fence_table(self, aconnect):
201
+ old = await aacquire(aconnect, fence_table="my_fence")
202
+ new = await aacquire(aconnect, fence_table="my_fence")
203
+ assert new.epoch == old.epoch + 1
204
+ with pytest.raises(FenceLost):
205
+ await aput_checkpoint(old)
206
+ await aput_checkpoint(new)
207
+
208
+ async def test_acquire_without_create_requires_existing_table(self, aconnect):
209
+ with pytest.raises(RuntimeError, match="does not exist"):
210
+ await AsyncFencedPostgresSaver.acquire(
211
+ await aconnect(), THREAD, create_fence_table=False
212
+ )
213
+
214
+ async def test_acquire_without_create_requires_existing_function(self, aconnect):
215
+ await aacquire(aconnect)
216
+ conn = await aconnect()
217
+ await conn.execute("DROP FUNCTION checkpoint_fence_assert(text, bigint)")
218
+ with pytest.raises(RuntimeError, match="fence function"):
219
+ await AsyncFencedPostgresSaver.acquire(
220
+ conn, THREAD, create_fence_table=False
221
+ )
222
+
223
+ async def test_acquire_without_create_uses_existing_table(self, aconnect):
224
+ await aacquire(aconnect)
225
+ saver = await AsyncFencedPostgresSaver.acquire(
226
+ await aconnect(), THREAD, create_fence_table=False
227
+ )
228
+ assert saver.epoch == 2
229
+
230
+ async def test_acquire_rejects_invalid_fence_table(self, aconnect):
231
+ with pytest.raises(ValueError):
232
+ await AsyncFencedPostgresSaver.acquire(
233
+ await aconnect(), THREAD, create_fence_table=True, fence_table="bad table;"
234
+ )
235
+
236
+
237
+ class TestOnFenceLost:
238
+ async def test_callback_fires_once_across_write_attempts(self, aconnect):
239
+ calls = []
240
+ old = await aacquire(aconnect, on_fence_lost=lambda: calls.append("lost"))
241
+ await aacquire(aconnect)
242
+ for _ in range(3):
243
+ with pytest.raises(FenceLost):
244
+ await aput_checkpoint(old)
245
+ with pytest.raises(FenceLost):
246
+ await old.adelete_thread(THREAD)
247
+ assert calls == ["lost"]
248
+
249
+ async def test_no_callback_still_raises(self, aconnect):
250
+ old = await aacquire(aconnect)
251
+ await aacquire(aconnect)
252
+ with pytest.raises(FenceLost) as excinfo:
253
+ await aput_checkpoint(old)
254
+ assert excinfo.value.thread_id == THREAD
255
+ assert excinfo.value.epoch == 1