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.
- statewire-0.1.1/.gitignore +9 -0
- statewire-0.1.1/PKG-INFO +129 -0
- statewire-0.1.1/README.md +117 -0
- statewire-0.1.1/examples/__init__.py +0 -0
- statewire-0.1.1/examples/demo_app.py +60 -0
- statewire-0.1.1/pyproject.toml +25 -0
- statewire-0.1.1/src/statewire/__init__.py +20 -0
- statewire-0.1.1/src/statewire/api.py +974 -0
- statewire-0.1.1/src/statewire/assistant_transport.py +392 -0
- statewire-0.1.1/src/statewire/assistant_transport_client.py +321 -0
- statewire-0.1.1/src/statewire/client.py +1052 -0
- statewire-0.1.1/src/statewire/ops.py +110 -0
- statewire-0.1.1/src/statewire/state.py +298 -0
- statewire-0.1.1/tests/client_helpers.py +201 -0
- statewire-0.1.1/tests/statewire_helpers.py +335 -0
- statewire-0.1.1/tests/test_assistant_transport.py +604 -0
- statewire-0.1.1/tests/test_assistant_transport_client.py +221 -0
- statewire-0.1.1/tests/test_authorize.py +56 -0
- statewire-0.1.1/tests/test_client.py +254 -0
- statewire-0.1.1/tests/test_client_ws.py +55 -0
- statewire-0.1.1/tests/test_commands.py +975 -0
- statewire-0.1.1/tests/test_lifespan.py +59 -0
- statewire-0.1.1/tests/test_meta.py +198 -0
- statewire-0.1.1/tests/test_state_proxy.py +233 -0
- statewire-0.1.1/tests/test_statewire_hostable.py +42 -0
- statewire-0.1.1/tests/test_stream.py +291 -0
- statewire-0.1.1/tests/test_writer_lease.py +108 -0
- statewire-0.1.1/tests/test_ws.py +377 -0
- statewire-0.0.0.dev0/PKG-INFO +0 -13
- statewire-0.0.0.dev0/README.md +0 -3
- statewire-0.0.0.dev0/pyproject.toml +0 -18
- statewire-0.0.0.dev0/src/statewire/__init__.py +0 -1
statewire-0.1.1/PKG-INFO
ADDED
|
@@ -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
|
+
]
|