statewire 0.0.0.dev0__tar.gz → 0.1.1__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 (32) hide show
  1. statewire-0.1.1/.gitignore +9 -0
  2. statewire-0.1.1/PKG-INFO +129 -0
  3. statewire-0.1.1/README.md +117 -0
  4. statewire-0.1.1/examples/__init__.py +0 -0
  5. statewire-0.1.1/examples/demo_app.py +60 -0
  6. statewire-0.1.1/pyproject.toml +25 -0
  7. statewire-0.1.1/src/statewire/__init__.py +20 -0
  8. statewire-0.1.1/src/statewire/api.py +974 -0
  9. statewire-0.1.1/src/statewire/assistant_transport.py +392 -0
  10. statewire-0.1.1/src/statewire/assistant_transport_client.py +321 -0
  11. statewire-0.1.1/src/statewire/client.py +1052 -0
  12. statewire-0.1.1/src/statewire/ops.py +110 -0
  13. statewire-0.1.1/src/statewire/state.py +298 -0
  14. statewire-0.1.1/tests/client_helpers.py +201 -0
  15. statewire-0.1.1/tests/statewire_helpers.py +335 -0
  16. statewire-0.1.1/tests/test_assistant_transport.py +604 -0
  17. statewire-0.1.1/tests/test_assistant_transport_client.py +221 -0
  18. statewire-0.1.1/tests/test_authorize.py +56 -0
  19. statewire-0.1.1/tests/test_client.py +254 -0
  20. statewire-0.1.1/tests/test_client_ws.py +55 -0
  21. statewire-0.1.1/tests/test_commands.py +975 -0
  22. statewire-0.1.1/tests/test_lifespan.py +59 -0
  23. statewire-0.1.1/tests/test_meta.py +198 -0
  24. statewire-0.1.1/tests/test_state_proxy.py +233 -0
  25. statewire-0.1.1/tests/test_statewire_hostable.py +42 -0
  26. statewire-0.1.1/tests/test_stream.py +291 -0
  27. statewire-0.1.1/tests/test_writer_lease.py +108 -0
  28. statewire-0.1.1/tests/test_ws.py +377 -0
  29. statewire-0.0.0.dev0/PKG-INFO +0 -13
  30. statewire-0.0.0.dev0/README.md +0 -3
  31. statewire-0.0.0.dev0/pyproject.toml +0 -18
  32. statewire-0.0.0.dev0/src/statewire/__init__.py +0 -1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ uv.lock
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: statewire
3
+ Version: 0.1.1
4
+ Summary: Replicate one JSON object over an SSE op stream + a command endpoint
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: fastapi>=0.115
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pinned<0.2,>=0.1.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # statewire
14
+
15
+ The Statewire protocol for Python, on top of [pinned](https://github.com/Yonom/pinned).
16
+
17
+ `Statewire` is a `PinnedAPI` (one live instance per id cluster-wide) that replicates
18
+ one JSON object — any State — over envelope streams:
19
+
20
+ - `GET /stream` (SSE) and `/ws` (WebSocket twin) — every message is an envelope
21
+ `{"ops"?, "res"?, "ack"?, "syn"?, "fin"?}`. The first envelope on attach is a
22
+ full snapshot (`ops: [{"op": "replace", "path": [], "value": <state>}]`) with
23
+ the `syn` handshake `{"lastSeq", "lease"?}`: the client's last admitted
24
+ command seq (`-1` unknown client) and the attach's writer-lease token.
25
+ Sessions are single-writer per client id: attaching takes the lease, and any
26
+ other live stream of that client id ends with `fin {"reason": "superseded"}`.
27
+ - `POST /commands` — one command at a time, a method call
28
+ `{"method": <name>, "params": [...]}` routed to the `@command` handler
29
+ registered under that name. Commands carry `Statewire-Client-Id`, a
30
+ monotonic `Statewire-Command-Seq`, and the current `Statewire-Lease`. The HTTP
31
+ response is receipt only (`200 {}` | `400` malformed | `409` seq gap | `412`
32
+ unknown client | `423` stale lease); the protocol statuses carry a
33
+ discriminator body (`{"error": "seq-gap" | "unknown-client" |
34
+ "stale-lease", "message"?}`) so clients can tell statewire's verdict from a
35
+ middleware-minted bare status; verdicts ride the stream. Over WS the same
36
+ commands arrive as `{"method": <name>, "params": [...], "seq": <int>}` frames —
37
+ no lease header, holding the connection is the lease.
38
+
39
+ `ops` are Immer-style deltas with array paths (object keys as strings, list
40
+ indices as ints): `replace` sets a value, `add` splices — its final int segment
41
+ indexes into the parent, so a list parent gains an element and a string parent
42
+ gains text at that offset — `remove` deletes, and `event` is transient (never in
43
+ snapshots). `ack`
44
+ is the cumulative watermark for the issuing client, emitted after the command's
45
+ effect ops. `res` carries responses (`{"seq", "type": "accepted" | "rejected" |
46
+ "pending" | "crashed" | "result-unavailable", "message"?, "payload"?}`): the envelope
47
+ that first covers a seq states the command's fate — a terminal response, a
48
+ `pending` response (result follows in a later envelope), or nothing = void. `fin`
49
+ (`{"reason": "evicted" | "error" | "gone" | "superseded", "message"?}`) is always
50
+ the last envelope.
51
+
52
+ Handler outcomes: the return value becomes the terminal `accepted` payload;
53
+ `raise StatewireReject` becomes `rejected`; returning a `CommandExecution` keeps the
54
+ command open past the handler's return — its `ack()` flushes the covering ack
55
+ with a `pending` response, its `resolve`/`reject` produce the late terminal
56
+ response (post-ack failures are terminal `rejected`, not stream faults).
57
+ Duplicate seqs never re-run: they are answered
58
+ from a ~30s result cache / in-flight registry — in the POST body over HTTP
59
+ (`200 {"res": ...}`), as a `res` on the stream over WS.
60
+
61
+ The protocol is generic: it says nothing about messages, queues, or agents — it
62
+ only replicates whatever `self.state` dict you assign and dispatches whatever
63
+ commands you declare. Domain-specific layers (see the `harness-sdk` package) sit
64
+ on top.
65
+
66
+ ```python
67
+ from statewire import Statewire, command
68
+
69
+
70
+ class Thread(Statewire):
71
+ async def lifespan(self):
72
+ self.state = {"messages": []} # yielding without setting self.state throws
73
+ yield
74
+
75
+ @command
76
+ async def addMessage(self, message_id: str, content: str):
77
+ self.state["messages"].append({"id": message_id, "content": content})
78
+ self.create_task(self.run()) # long work outside the inbox; returning here => ack
79
+ ```
80
+
81
+ `@command` registers the handler under the method's own name; `@command("name")`
82
+ registers it under an explicit wire name. Params are positional. An unknown
83
+ method or a params/signature mismatch is a `rejected` response on the stream,
84
+ not an HTTP error.
85
+
86
+ `self.state` is a change-tracking proxy: mutate it plainly and the ops replicate
87
+ to every attached stream. `+=` on a string becomes an end-offset text-insert
88
+ `add` op of the suffix; other mutations become narrow `replace` / `add` /
89
+ `remove` ops. Mutations within
90
+ one synchronous segment coalesce into a single envelope.
91
+
92
+ ## Extra routes
93
+
94
+ Need an endpoint beyond the protocol trio (a health check, a file upload)?
95
+ Decorate a method with pinned's `route` escape hatch:
96
+
97
+ ```python
98
+ from pinned import route
99
+ from statewire import Statewire
100
+
101
+
102
+ class Thread(Statewire):
103
+ @route.get("/health")
104
+ async def health(self, request):
105
+ return {"ok": True}
106
+ ```
107
+
108
+ The reserved protocol paths `/statewire`, `/stream`, `/commands`, and `/ws`
109
+ are Statewire's own; a subclass that decorates a `@route` onto any of them
110
+ raises at class-definition time rather than silently shadowing the protocol.
111
+
112
+ `GET /statewire` is the meta endpoint: `{"protocol": 1, "commands": [<names>]}`.
113
+ Setting `envelope_ts = True` on the subclass stamps every non-empty envelope
114
+ (snapshot included) with `"ts"` (epoch ms) for debuggability.
115
+
116
+ ## Layering
117
+
118
+ ```
119
+ pinned one live instance per id, with an HTTP surface
120
+ └─ statewire the Statewire protocol: /stream + /ws (envelopes) + /commands
121
+ └─ harness-sdk HarnessState types, queueing, deepagents
122
+ ```
123
+
124
+ ## Develop
125
+
126
+ ```sh
127
+ uv sync
128
+ uv run pytest
129
+ ```
@@ -0,0 +1,117 @@
1
+ # statewire
2
+
3
+ The Statewire protocol for Python, on top of [pinned](https://github.com/Yonom/pinned).
4
+
5
+ `Statewire` is a `PinnedAPI` (one live instance per id cluster-wide) that replicates
6
+ one JSON object — any State — over envelope streams:
7
+
8
+ - `GET /stream` (SSE) and `/ws` (WebSocket twin) — every message is an envelope
9
+ `{"ops"?, "res"?, "ack"?, "syn"?, "fin"?}`. The first envelope on attach is a
10
+ full snapshot (`ops: [{"op": "replace", "path": [], "value": <state>}]`) with
11
+ the `syn` handshake `{"lastSeq", "lease"?}`: the client's last admitted
12
+ command seq (`-1` unknown client) and the attach's writer-lease token.
13
+ Sessions are single-writer per client id: attaching takes the lease, and any
14
+ other live stream of that client id ends with `fin {"reason": "superseded"}`.
15
+ - `POST /commands` — one command at a time, a method call
16
+ `{"method": <name>, "params": [...]}` routed to the `@command` handler
17
+ registered under that name. Commands carry `Statewire-Client-Id`, a
18
+ monotonic `Statewire-Command-Seq`, and the current `Statewire-Lease`. The HTTP
19
+ response is receipt only (`200 {}` | `400` malformed | `409` seq gap | `412`
20
+ unknown client | `423` stale lease); the protocol statuses carry a
21
+ discriminator body (`{"error": "seq-gap" | "unknown-client" |
22
+ "stale-lease", "message"?}`) so clients can tell statewire's verdict from a
23
+ middleware-minted bare status; verdicts ride the stream. Over WS the same
24
+ commands arrive as `{"method": <name>, "params": [...], "seq": <int>}` frames —
25
+ no lease header, holding the connection is the lease.
26
+
27
+ `ops` are Immer-style deltas with array paths (object keys as strings, list
28
+ indices as ints): `replace` sets a value, `add` splices — its final int segment
29
+ indexes into the parent, so a list parent gains an element and a string parent
30
+ gains text at that offset — `remove` deletes, and `event` is transient (never in
31
+ snapshots). `ack`
32
+ is the cumulative watermark for the issuing client, emitted after the command's
33
+ effect ops. `res` carries responses (`{"seq", "type": "accepted" | "rejected" |
34
+ "pending" | "crashed" | "result-unavailable", "message"?, "payload"?}`): the envelope
35
+ that first covers a seq states the command's fate — a terminal response, a
36
+ `pending` response (result follows in a later envelope), or nothing = void. `fin`
37
+ (`{"reason": "evicted" | "error" | "gone" | "superseded", "message"?}`) is always
38
+ the last envelope.
39
+
40
+ Handler outcomes: the return value becomes the terminal `accepted` payload;
41
+ `raise StatewireReject` becomes `rejected`; returning a `CommandExecution` keeps the
42
+ command open past the handler's return — its `ack()` flushes the covering ack
43
+ with a `pending` response, its `resolve`/`reject` produce the late terminal
44
+ response (post-ack failures are terminal `rejected`, not stream faults).
45
+ Duplicate seqs never re-run: they are answered
46
+ from a ~30s result cache / in-flight registry — in the POST body over HTTP
47
+ (`200 {"res": ...}`), as a `res` on the stream over WS.
48
+
49
+ The protocol is generic: it says nothing about messages, queues, or agents — it
50
+ only replicates whatever `self.state` dict you assign and dispatches whatever
51
+ commands you declare. Domain-specific layers (see the `harness-sdk` package) sit
52
+ on top.
53
+
54
+ ```python
55
+ from statewire import Statewire, command
56
+
57
+
58
+ class Thread(Statewire):
59
+ async def lifespan(self):
60
+ self.state = {"messages": []} # yielding without setting self.state throws
61
+ yield
62
+
63
+ @command
64
+ async def addMessage(self, message_id: str, content: str):
65
+ self.state["messages"].append({"id": message_id, "content": content})
66
+ self.create_task(self.run()) # long work outside the inbox; returning here => ack
67
+ ```
68
+
69
+ `@command` registers the handler under the method's own name; `@command("name")`
70
+ registers it under an explicit wire name. Params are positional. An unknown
71
+ method or a params/signature mismatch is a `rejected` response on the stream,
72
+ not an HTTP error.
73
+
74
+ `self.state` is a change-tracking proxy: mutate it plainly and the ops replicate
75
+ to every attached stream. `+=` on a string becomes an end-offset text-insert
76
+ `add` op of the suffix; other mutations become narrow `replace` / `add` /
77
+ `remove` ops. Mutations within
78
+ one synchronous segment coalesce into a single envelope.
79
+
80
+ ## Extra routes
81
+
82
+ Need an endpoint beyond the protocol trio (a health check, a file upload)?
83
+ Decorate a method with pinned's `route` escape hatch:
84
+
85
+ ```python
86
+ from pinned import route
87
+ from statewire import Statewire
88
+
89
+
90
+ class Thread(Statewire):
91
+ @route.get("/health")
92
+ async def health(self, request):
93
+ return {"ok": True}
94
+ ```
95
+
96
+ The reserved protocol paths `/statewire`, `/stream`, `/commands`, and `/ws`
97
+ are Statewire's own; a subclass that decorates a `@route` onto any of them
98
+ raises at class-definition time rather than silently shadowing the protocol.
99
+
100
+ `GET /statewire` is the meta endpoint: `{"protocol": 1, "commands": [<names>]}`.
101
+ Setting `envelope_ts = True` on the subclass stamps every non-empty envelope
102
+ (snapshot included) with `"ts"` (epoch ms) for debuggability.
103
+
104
+ ## Layering
105
+
106
+ ```
107
+ pinned one live instance per id, with an HTTP surface
108
+ └─ statewire the Statewire protocol: /stream + /ws (envelopes) + /commands
109
+ └─ harness-sdk HarnessState types, queueing, deepagents
110
+ ```
111
+
112
+ ## Develop
113
+
114
+ ```sh
115
+ uv sync
116
+ uv run pytest
117
+ ```
File without changes
@@ -0,0 +1,60 @@
1
+ """Runnable Statewire demo backend for examples/vite-statewire-example.
2
+
3
+ Run from python/statewire/statewire:
4
+
5
+ uv run --with 'uvicorn[standard]' uvicorn examples.demo_app:app --port 8000
6
+
7
+ Instances are served under /threads/{id}: GET /threads/{id}/stream for the
8
+ state stream, POST /threads/{id}/commands for commands. The demo
9
+ exercises the whole frontend surface: a slow `increment` (visible optimistic
10
+ window), a hard limit that rejects with a message (StatewireSendError), a
11
+ `write_haiku` that streams text via text-insert `add` ops, and a transient
12
+ `event` op.
13
+ """
14
+
15
+ import asyncio
16
+
17
+ from fastapi import FastAPI
18
+ from pinned import PinnedHost
19
+ from statewire import StatewireReject, Statewire, command
20
+
21
+ MAX_COUNT = 10
22
+
23
+ HAIKU = "state flows as one stream —\nevery client, every tab,\nthe same truth appears\n"
24
+
25
+
26
+ class Counter(Statewire):
27
+ async def lifespan(self):
28
+ self.state = {"count": 0, "haiku": ""}
29
+ yield
30
+
31
+ @command
32
+ async def increment(self):
33
+ if self.state["count"] >= MAX_COUNT:
34
+ raise StatewireReject(
35
+ f"the counter is full (max {MAX_COUNT})", payload={"max": MAX_COUNT}
36
+ )
37
+ await asyncio.sleep(0.4) # a slow handler makes the optimistic window visible
38
+ self.state["count"] += 1
39
+ if self.state["count"] == MAX_COUNT:
40
+ self.emit({"kind": "toast", "text": "the counter is full — reset to keep going"})
41
+
42
+ @command
43
+ async def reset(self):
44
+ self.state["count"] = 0
45
+ self.state["haiku"] = ""
46
+
47
+ @command
48
+ async def write_haiku(self):
49
+ self.state["haiku"] = ""
50
+ self.create_task(self._stream_haiku())
51
+
52
+ async def _stream_haiku(self):
53
+ for char in HAIKU:
54
+ self.state["haiku"] += char
55
+ await asyncio.sleep(0.03)
56
+
57
+
58
+ router = PinnedHost(Counter)
59
+ app = FastAPI(lifespan=router.lifespan)
60
+ app.include_router(router, prefix="/threads")
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "statewire"
3
+ version = "0.1.1"
4
+ description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11,<4.0"
8
+ dependencies = [
9
+ "pinned>=0.1.0,<0.2",
10
+ "fastapi>=0.115",
11
+ "httpx>=0.27",
12
+ ]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/assistant-ui/harness-sdk"
16
+
17
+ [tool.uv.sources]
18
+ pinned = { workspace = true }
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/statewire"]
@@ -0,0 +1,20 @@
1
+ from .api import (
2
+ CommandExecution,
3
+ CommandExecutionResolvers,
4
+ Statewire,
5
+ StatewireReject,
6
+ command,
7
+ )
8
+ from .assistant_transport import AssistantTransport
9
+ from .state import StateProxy, plain
10
+
11
+ __all__ = [
12
+ "Statewire",
13
+ "AssistantTransport",
14
+ "command",
15
+ "CommandExecution",
16
+ "CommandExecutionResolvers",
17
+ "StatewireReject",
18
+ "StateProxy",
19
+ "plain",
20
+ ]