hausfold-scruff 1.0.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.
- hausfold_scruff-1.0.0/.gitignore +6 -0
- hausfold_scruff-1.0.0/PKG-INFO +159 -0
- hausfold_scruff-1.0.0/README.md +144 -0
- hausfold_scruff-1.0.0/pyproject.toml +32 -0
- hausfold_scruff-1.0.0/src/scruff/__init__.py +47 -0
- hausfold_scruff-1.0.0/src/scruff/client.py +269 -0
- hausfold_scruff-1.0.0/src/scruff/errors.py +42 -0
- hausfold_scruff-1.0.0/src/scruff/exec.py +88 -0
- hausfold_scruff-1.0.0/src/scruff/py.typed +0 -0
- hausfold_scruff-1.0.0/src/scruff/types.py +216 -0
- hausfold_scruff-1.0.0/src/scruff/watch.py +78 -0
- hausfold_scruff-1.0.0/tests/fake-scruff.sh +95 -0
- hausfold_scruff-1.0.0/tests/test_client.py +95 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hausfold-scruff
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for scruff, the worktree-lifecycle substrate. Shells out to the scruff binary; async-first so it drops into a FastAPI/asyncio backend or a script equally.
|
|
5
|
+
Project-URL: Repository, https://github.com/hausfold/scruff
|
|
6
|
+
Author: hausfold
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# hausfold-scruff (Python SDK)
|
|
17
|
+
|
|
18
|
+
A thin Python client over the [`scruff`](../../README.md) binary — the
|
|
19
|
+
worktree-lifecycle substrate for parallel coding agents. scruff has no daemon,
|
|
20
|
+
so this SDK shells out to it (`asyncio.create_subprocess_exec` + `--json`,
|
|
21
|
+
`watch --json` for a live NDJSON stream).
|
|
22
|
+
|
|
23
|
+
Async-first: `watch()` is naturally a stream. A sync script can still call
|
|
24
|
+
every method via `asyncio.run(...)`.
|
|
25
|
+
|
|
26
|
+
Import name is `scruff`; the package on PyPI is `hausfold-scruff`.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install hausfold-scruff
|
|
32
|
+
# or: uv add hausfold-scruff
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For local development against this repo instead: `pip install -e sdk/python`.
|
|
36
|
+
|
|
37
|
+
`scruff` itself must be on `PATH`, or pass `ScruffClientOptions(bin="/path/to/scruff")`.
|
|
38
|
+
|
|
39
|
+
## Two shapes of usage
|
|
40
|
+
|
|
41
|
+
**Programmatic.** Every `ScruffClient` method except the two ending in
|
|
42
|
+
`_interactive` captures the child's stdout and returns — safe to call from
|
|
43
|
+
a server with many concurrent sessions.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
from scruff import ScruffClient
|
|
48
|
+
|
|
49
|
+
async def main() -> None:
|
|
50
|
+
scruff = ScruffClient()
|
|
51
|
+
|
|
52
|
+
envelope = await scruff.list()
|
|
53
|
+
for lane in envelope.lanes:
|
|
54
|
+
# occupied/dirty are `bool | None`: None means "not determined",
|
|
55
|
+
# never coerce it to False.
|
|
56
|
+
print(lane.name, lane.state, lane.occupied)
|
|
57
|
+
|
|
58
|
+
# Create a lane WITHOUT attaching an agent to it — the primitive an
|
|
59
|
+
# orchestrator wants. child/spawn only ever print the new path.
|
|
60
|
+
lane_dir = await scruff.child("/path/to/some-repo", "task-42")
|
|
61
|
+
# ...now launch YOUR OWN agent process against lane_dir.
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# Live updates instead of polling — created/parked/resumed/reaped/changed.
|
|
68
|
+
async for line in scruff.watch():
|
|
69
|
+
if line.kind == "created" and line.lane is not None:
|
|
70
|
+
notify_ui(line.lane)
|
|
71
|
+
|
|
72
|
+
# Or scoped to the one lane this session holds — no hello/ready framing,
|
|
73
|
+
# and nothing about anybody else's lanes.
|
|
74
|
+
async for event in scruff.watch_lane(lane_dir):
|
|
75
|
+
if event.kind == "reaped":
|
|
76
|
+
end_session()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Interactive.** `new_interactive` / `resume_interactive` inherit the
|
|
80
|
+
calling process's stdio, so when scruff execs the configured agent client
|
|
81
|
+
(`claude`, `codex`, `opencode`, `pi`) it takes over the real terminal — same as
|
|
82
|
+
running `scruff new` by hand — and control returns to you when that session
|
|
83
|
+
ends.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# A terminal app, run in an actual TTY:
|
|
87
|
+
await scruff.new_interactive("task-42")
|
|
88
|
+
# ... the agent owned the screen; you're back here when it exits.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Do not call `new_interactive` from a server** — `scruff new` execs the
|
|
92
|
+
agent client unconditionally, without checking for a TTY, so piped stdio
|
|
93
|
+
blocks forever. Use `resume()` instead: it detects piped stdout and prints
|
|
94
|
+
the reopen command as text rather than exec'ing.
|
|
95
|
+
|
|
96
|
+
## Holding a session open: leases
|
|
97
|
+
|
|
98
|
+
scruff's sweep (`reap`) needs to know a checkout is in use. On a human's
|
|
99
|
+
machine, `lsof` answers that; a server has no pane or shell cwd'd anywhere,
|
|
100
|
+
so it says so itself with a lease:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
lease = await scruff.lease(lane_dir) # refreshes on an interval, < the 90s TTL
|
|
104
|
+
# ... serve the session ...
|
|
105
|
+
await lease.release()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Pass `pid=` instead when the lease should track a real local process — the
|
|
109
|
+
OS then drops it the instant that pid dies, no refresh loop needed.
|
|
110
|
+
|
|
111
|
+
A lease can only **save** a lane from `reap`, never condemn one — "nobody
|
|
112
|
+
leased it" isn't proof nobody's there.
|
|
113
|
+
|
|
114
|
+
`scruff.lease(...)` is a coroutine, so it can await the first heartbeat before
|
|
115
|
+
returning: a failure to take the lease raises immediately instead of
|
|
116
|
+
surfacing on the next refresh or release call.
|
|
117
|
+
|
|
118
|
+
## `watch()` cleanup
|
|
119
|
+
|
|
120
|
+
`watch()` returns an async generator; stop consuming (`break`, or
|
|
121
|
+
`.aclose()`) to kill the underlying process. Async generators aren't
|
|
122
|
+
guaranteed to close promptly when they go out of scope — wrap long-lived use
|
|
123
|
+
in `contextlib.aclosing()` to tear down the subprocess deterministically:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from contextlib import aclosing
|
|
127
|
+
|
|
128
|
+
async with aclosing(scruff.watch()) as stream:
|
|
129
|
+
async for line in stream:
|
|
130
|
+
...
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Types for a frontend
|
|
134
|
+
|
|
135
|
+
`scruff.types` has no runtime dependencies beyond the standard library.
|
|
136
|
+
Import just the dataclasses if you're modeling the same wire shape
|
|
137
|
+
elsewhere:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from scruff import ScruffLane, WatchEvent
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## What's NOT here yet
|
|
144
|
+
|
|
145
|
+
`hook create`/`hook remove` have no wrapper — shell out via `run()` if you
|
|
146
|
+
need them. Types are hand-ported from the Go structs, not generated; if
|
|
147
|
+
scruff's JSON shape drifts from this file, that's a bug here.
|
|
148
|
+
|
|
149
|
+
## Testing
|
|
150
|
+
|
|
151
|
+
`tests/fake-scruff.sh` stands in for the real binary so tests don't need a Go
|
|
152
|
+
build.
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
python -m venv .venv && source .venv/bin/activate
|
|
156
|
+
pip install -e ".[dev]"
|
|
157
|
+
pytest
|
|
158
|
+
mypy src
|
|
159
|
+
```
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# hausfold-scruff (Python SDK)
|
|
2
|
+
|
|
3
|
+
A thin Python client over the [`scruff`](../../README.md) binary — the
|
|
4
|
+
worktree-lifecycle substrate for parallel coding agents. scruff has no daemon,
|
|
5
|
+
so this SDK shells out to it (`asyncio.create_subprocess_exec` + `--json`,
|
|
6
|
+
`watch --json` for a live NDJSON stream).
|
|
7
|
+
|
|
8
|
+
Async-first: `watch()` is naturally a stream. A sync script can still call
|
|
9
|
+
every method via `asyncio.run(...)`.
|
|
10
|
+
|
|
11
|
+
Import name is `scruff`; the package on PyPI is `hausfold-scruff`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pip install hausfold-scruff
|
|
17
|
+
# or: uv add hausfold-scruff
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For local development against this repo instead: `pip install -e sdk/python`.
|
|
21
|
+
|
|
22
|
+
`scruff` itself must be on `PATH`, or pass `ScruffClientOptions(bin="/path/to/scruff")`.
|
|
23
|
+
|
|
24
|
+
## Two shapes of usage
|
|
25
|
+
|
|
26
|
+
**Programmatic.** Every `ScruffClient` method except the two ending in
|
|
27
|
+
`_interactive` captures the child's stdout and returns — safe to call from
|
|
28
|
+
a server with many concurrent sessions.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from scruff import ScruffClient
|
|
33
|
+
|
|
34
|
+
async def main() -> None:
|
|
35
|
+
scruff = ScruffClient()
|
|
36
|
+
|
|
37
|
+
envelope = await scruff.list()
|
|
38
|
+
for lane in envelope.lanes:
|
|
39
|
+
# occupied/dirty are `bool | None`: None means "not determined",
|
|
40
|
+
# never coerce it to False.
|
|
41
|
+
print(lane.name, lane.state, lane.occupied)
|
|
42
|
+
|
|
43
|
+
# Create a lane WITHOUT attaching an agent to it — the primitive an
|
|
44
|
+
# orchestrator wants. child/spawn only ever print the new path.
|
|
45
|
+
lane_dir = await scruff.child("/path/to/some-repo", "task-42")
|
|
46
|
+
# ...now launch YOUR OWN agent process against lane_dir.
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# Live updates instead of polling — created/parked/resumed/reaped/changed.
|
|
53
|
+
async for line in scruff.watch():
|
|
54
|
+
if line.kind == "created" and line.lane is not None:
|
|
55
|
+
notify_ui(line.lane)
|
|
56
|
+
|
|
57
|
+
# Or scoped to the one lane this session holds — no hello/ready framing,
|
|
58
|
+
# and nothing about anybody else's lanes.
|
|
59
|
+
async for event in scruff.watch_lane(lane_dir):
|
|
60
|
+
if event.kind == "reaped":
|
|
61
|
+
end_session()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Interactive.** `new_interactive` / `resume_interactive` inherit the
|
|
65
|
+
calling process's stdio, so when scruff execs the configured agent client
|
|
66
|
+
(`claude`, `codex`, `opencode`, `pi`) it takes over the real terminal — same as
|
|
67
|
+
running `scruff new` by hand — and control returns to you when that session
|
|
68
|
+
ends.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# A terminal app, run in an actual TTY:
|
|
72
|
+
await scruff.new_interactive("task-42")
|
|
73
|
+
# ... the agent owned the screen; you're back here when it exits.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Do not call `new_interactive` from a server** — `scruff new` execs the
|
|
77
|
+
agent client unconditionally, without checking for a TTY, so piped stdio
|
|
78
|
+
blocks forever. Use `resume()` instead: it detects piped stdout and prints
|
|
79
|
+
the reopen command as text rather than exec'ing.
|
|
80
|
+
|
|
81
|
+
## Holding a session open: leases
|
|
82
|
+
|
|
83
|
+
scruff's sweep (`reap`) needs to know a checkout is in use. On a human's
|
|
84
|
+
machine, `lsof` answers that; a server has no pane or shell cwd'd anywhere,
|
|
85
|
+
so it says so itself with a lease:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
lease = await scruff.lease(lane_dir) # refreshes on an interval, < the 90s TTL
|
|
89
|
+
# ... serve the session ...
|
|
90
|
+
await lease.release()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Pass `pid=` instead when the lease should track a real local process — the
|
|
94
|
+
OS then drops it the instant that pid dies, no refresh loop needed.
|
|
95
|
+
|
|
96
|
+
A lease can only **save** a lane from `reap`, never condemn one — "nobody
|
|
97
|
+
leased it" isn't proof nobody's there.
|
|
98
|
+
|
|
99
|
+
`scruff.lease(...)` is a coroutine, so it can await the first heartbeat before
|
|
100
|
+
returning: a failure to take the lease raises immediately instead of
|
|
101
|
+
surfacing on the next refresh or release call.
|
|
102
|
+
|
|
103
|
+
## `watch()` cleanup
|
|
104
|
+
|
|
105
|
+
`watch()` returns an async generator; stop consuming (`break`, or
|
|
106
|
+
`.aclose()`) to kill the underlying process. Async generators aren't
|
|
107
|
+
guaranteed to close promptly when they go out of scope — wrap long-lived use
|
|
108
|
+
in `contextlib.aclosing()` to tear down the subprocess deterministically:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from contextlib import aclosing
|
|
112
|
+
|
|
113
|
+
async with aclosing(scruff.watch()) as stream:
|
|
114
|
+
async for line in stream:
|
|
115
|
+
...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Types for a frontend
|
|
119
|
+
|
|
120
|
+
`scruff.types` has no runtime dependencies beyond the standard library.
|
|
121
|
+
Import just the dataclasses if you're modeling the same wire shape
|
|
122
|
+
elsewhere:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from scruff import ScruffLane, WatchEvent
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## What's NOT here yet
|
|
129
|
+
|
|
130
|
+
`hook create`/`hook remove` have no wrapper — shell out via `run()` if you
|
|
131
|
+
need them. Types are hand-ported from the Go structs, not generated; if
|
|
132
|
+
scruff's JSON shape drifts from this file, that's a bug here.
|
|
133
|
+
|
|
134
|
+
## Testing
|
|
135
|
+
|
|
136
|
+
`tests/fake-scruff.sh` stands in for the real binary so tests don't need a Go
|
|
137
|
+
build.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
python -m venv .venv && source .venv/bin/activate
|
|
141
|
+
pip install -e ".[dev]"
|
|
142
|
+
pytest
|
|
143
|
+
mypy src
|
|
144
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hausfold-scruff"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Python SDK for scruff, the worktree-lifecycle substrate. Shells out to the scruff binary; async-first so it drops into a FastAPI/asyncio backend or a script equally."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "Apache-2.0" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "hausfold" }]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Typing :: Typed",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Repository = "https://github.com/hausfold/scruff"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.11"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/scruff"]
|
|
25
|
+
|
|
26
|
+
[tool.mypy]
|
|
27
|
+
strict = true
|
|
28
|
+
python_version = "3.10"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from .client import ScruffClient, ScruffClientOptions, Lease
|
|
2
|
+
from .errors import ScruffError
|
|
3
|
+
from .exec import RunOptions, RunResult, run, run_json
|
|
4
|
+
from .types import (
|
|
5
|
+
ScruffEnvelope,
|
|
6
|
+
ScruffExitCode,
|
|
7
|
+
ScruffLane,
|
|
8
|
+
LandedInfo,
|
|
9
|
+
LandedVerdict,
|
|
10
|
+
LandedVia,
|
|
11
|
+
LaneState,
|
|
12
|
+
PostMergeAhead,
|
|
13
|
+
WatchEvent,
|
|
14
|
+
WatchEventKind,
|
|
15
|
+
WatchHello,
|
|
16
|
+
WatchLine,
|
|
17
|
+
is_watch_hello,
|
|
18
|
+
parse_watch_line,
|
|
19
|
+
)
|
|
20
|
+
from .watch import watch_all, watch_lane
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ScruffClient",
|
|
24
|
+
"ScruffClientOptions",
|
|
25
|
+
"Lease",
|
|
26
|
+
"ScruffError",
|
|
27
|
+
"RunOptions",
|
|
28
|
+
"RunResult",
|
|
29
|
+
"run",
|
|
30
|
+
"run_json",
|
|
31
|
+
"ScruffEnvelope",
|
|
32
|
+
"ScruffExitCode",
|
|
33
|
+
"ScruffLane",
|
|
34
|
+
"LandedInfo",
|
|
35
|
+
"LandedVerdict",
|
|
36
|
+
"LandedVia",
|
|
37
|
+
"LaneState",
|
|
38
|
+
"PostMergeAhead",
|
|
39
|
+
"WatchEvent",
|
|
40
|
+
"WatchEventKind",
|
|
41
|
+
"WatchHello",
|
|
42
|
+
"WatchLine",
|
|
43
|
+
"is_watch_hello",
|
|
44
|
+
"parse_watch_line",
|
|
45
|
+
"watch_all",
|
|
46
|
+
"watch_lane",
|
|
47
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, AsyncGenerator, Optional
|
|
6
|
+
|
|
7
|
+
from .errors import ScruffError
|
|
8
|
+
from .exec import RunOptions, merged_env, run, run_json
|
|
9
|
+
from .types import ScruffEnvelope, WatchEvent, WatchLine
|
|
10
|
+
from .watch import watch_all, watch_lane
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ScruffClientOptions:
|
|
15
|
+
# Path to the scruff binary, or a bare name resolved on PATH. Defaults to
|
|
16
|
+
# "scruff".
|
|
17
|
+
bin: Optional[str] = None
|
|
18
|
+
# Working directory every command runs from — most of scruff's commands
|
|
19
|
+
# are cwd-sensitive (`new`, `park`, a bare `scruff <name>`). Defaults to
|
|
20
|
+
# this process's own cwd.
|
|
21
|
+
cwd: Optional[str] = None
|
|
22
|
+
# Extra environment variables, merged over the current process's env.
|
|
23
|
+
# Useful for SCRUFF_AGENT, SCRUFF_OCCUPANCY=lease.
|
|
24
|
+
env: Optional[dict[str, Optional[str]]] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScruffClient:
|
|
28
|
+
"""A thin client over the `scruff` binary. Every method shells out —
|
|
29
|
+
there is no daemon, no port, no socket (SPEC.md §14.1) — so this class
|
|
30
|
+
holds nothing but the options each call needs, and is cheap to
|
|
31
|
+
construct as often as you like.
|
|
32
|
+
|
|
33
|
+
Two methods (`new_interactive`, `resume_interactive`) inherit the
|
|
34
|
+
calling process's stdio and can hand off the terminal to a coding
|
|
35
|
+
agent; every other method captures output and returns. Mixing them up
|
|
36
|
+
matters: see each method's docstring.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, options: Optional[ScruffClientOptions] = None) -> None:
|
|
40
|
+
options = options or ScruffClientOptions()
|
|
41
|
+
self._opts = RunOptions(bin=options.bin, cwd=options.cwd, env=options.env)
|
|
42
|
+
|
|
43
|
+
async def list(self) -> ScruffEnvelope:
|
|
44
|
+
"""`scruff --json` / `scruff list --json` — byte-identical (SPEC.md
|
|
45
|
+
§2.2). The full snapshot: every live/parked lane, across every repo
|
|
46
|
+
scruff knows about. Poll this for landedness and PR state; use
|
|
47
|
+
{watch} for everything else, since it's push rather than poll."""
|
|
48
|
+
data = await run_json(["--json"], self._opts)
|
|
49
|
+
return ScruffEnvelope._from_json(data)
|
|
50
|
+
|
|
51
|
+
def watch(self) -> AsyncGenerator[WatchLine, None]:
|
|
52
|
+
"""`scruff watch --json` as an async iterator of typed lines — a
|
|
53
|
+
`hello`, then a `sync` burst for every lane already alive, `ready`,
|
|
54
|
+
then live changes for as long as you keep iterating. Stop
|
|
55
|
+
iterating (`break`, or `.aclose()`) to kill the underlying
|
|
56
|
+
process.
|
|
57
|
+
|
|
58
|
+
This is the primitive onOpen/onParked/... callback-style APIs are
|
|
59
|
+
built from (SPEC.md §14.2) — see {watch_lane} for a version scoped
|
|
60
|
+
to one lane's `path`.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
async for line in scruff.watch():
|
|
64
|
+
if line.kind == "created":
|
|
65
|
+
print("new lane:", line.lane.name if line.lane else None)
|
|
66
|
+
```
|
|
67
|
+
"""
|
|
68
|
+
return watch_all(self._opts)
|
|
69
|
+
|
|
70
|
+
def watch_lane(self, path: str) -> AsyncGenerator[WatchEvent, None]:
|
|
71
|
+
"""{watch}, filtered to events about ONE lane (`event.lane.path`)
|
|
72
|
+
and stripped of the `hello`/`ready` framing that names no lane —
|
|
73
|
+
the shape an embedder holding one session per lane usually wants:
|
|
74
|
+
"tell me when THIS lane's state changes." A `sync` event for the
|
|
75
|
+
lane still passes through: it's how a caller that started watching
|
|
76
|
+
after the lane went live learns it exists at all.
|
|
77
|
+
|
|
78
|
+
Compare full paths, not names: names aren't unique across repos,
|
|
79
|
+
but a checkout path is the registry's own primary key (SPEC.md
|
|
80
|
+
§2.1).
|
|
81
|
+
|
|
82
|
+
The module-level `scruff.watch_lane` does the same thing but takes
|
|
83
|
+
its own `RunOptions`; this one carries the client's bin/cwd/env.
|
|
84
|
+
"""
|
|
85
|
+
return watch_lane(path, self._opts)
|
|
86
|
+
|
|
87
|
+
async def child(self, repo_path: str, name: Optional[str] = None) -> str:
|
|
88
|
+
"""`scruff child <repo> [name]` — a lane on ANOTHER repo, registered
|
|
89
|
+
as a child of cwd. Prints only the new checkout's path on stdout
|
|
90
|
+
(SPEC.md §2.3's "only the path" discipline extends here too) and
|
|
91
|
+
never execs a client, which is what makes it the right primitive
|
|
92
|
+
for an orchestrator: create the lane, then run your OWN agent
|
|
93
|
+
process against the path it returns."""
|
|
94
|
+
args = ["child", repo_path, *([name] if name else [])]
|
|
95
|
+
result = await run(args, self._opts)
|
|
96
|
+
return result.stdout.strip()
|
|
97
|
+
|
|
98
|
+
async def spawn(self, repo_path: str, name: str, agent: Optional[str] = None) -> str:
|
|
99
|
+
"""`scruff spawn <repo> <name> [agent]` — a named lane for a caller
|
|
100
|
+
with no pane of its own (a scheduler, a web backend). Like
|
|
101
|
+
{child}, only ever creates the lane and prints its path; never
|
|
102
|
+
execs."""
|
|
103
|
+
args = ["spawn", repo_path, name, *([agent] if agent else [])]
|
|
104
|
+
result = await run(args, self._opts)
|
|
105
|
+
return result.stdout.strip()
|
|
106
|
+
|
|
107
|
+
async def resume(self, name: str) -> str:
|
|
108
|
+
"""`scruff <name>` / `scruff resume <name>` with stdout captured
|
|
109
|
+
rather than a terminal — which means the Go binary's own TTY check
|
|
110
|
+
(`ui.IsTTY`) sees a pipe and, by design, never execs a client. It
|
|
111
|
+
rebuilds the checkout if needed and returns the human-readable
|
|
112
|
+
result: either confirmation it's ready, or the exact command to
|
|
113
|
+
reopen the agent's chat by hand. Safe to call from a server
|
|
114
|
+
process. For a TUI that wants to actually hand off the terminal,
|
|
115
|
+
use {resume_interactive} instead."""
|
|
116
|
+
result = await run(["resume", name], self._opts)
|
|
117
|
+
return result.stdout
|
|
118
|
+
|
|
119
|
+
async def park(self, label: Optional[str] = None) -> None:
|
|
120
|
+
"""`scruff park [label]` — commits the working tree as one `wip:`
|
|
121
|
+
commit on the current branch. Never touches the shared stash stack
|
|
122
|
+
(README's "park, not git stash" section) — this is the one safe
|
|
123
|
+
way for concurrent lanes to set work aside."""
|
|
124
|
+
await run(["park", *([label] if label else [])], self._opts)
|
|
125
|
+
|
|
126
|
+
async def unpark(self) -> None:
|
|
127
|
+
"""`scruff unpark` — reverses the most recent `park`, putting its
|
|
128
|
+
changes back uncommitted. Raises {ScruffError} with `.refused ==
|
|
129
|
+
True` if that commit is already pushed (scruff will not rewrite
|
|
130
|
+
published history) or HEAD isn't a parked commit."""
|
|
131
|
+
await run(["unpark"], self._opts)
|
|
132
|
+
|
|
133
|
+
async def reap(self) -> None:
|
|
134
|
+
"""`scruff reap` — sweeps every LANDED lane nobody is standing in
|
|
135
|
+
(occupied, per {heartbeat}/`lsof`, always wins). Never removes the
|
|
136
|
+
checkout scruff is being run from, and never removes a stray."""
|
|
137
|
+
await run(["reap"], self._opts)
|
|
138
|
+
|
|
139
|
+
async def reship(self, name: Optional[str] = None) -> None:
|
|
140
|
+
"""`scruff reship [name]` — pushes a branch that outran its already-
|
|
141
|
+
merged PR, and opens the follow-up. Raises with `.degraded ==
|
|
142
|
+
True` if `gh` itself is unavailable."""
|
|
143
|
+
await run(["reship", *([name] if name else [])], self._opts)
|
|
144
|
+
|
|
145
|
+
async def heartbeat(self, path: Optional[str] = None, *, pid: Optional[int] = None) -> None:
|
|
146
|
+
"""`scruff heartbeat [path] [--pid N | --release]` — takes or
|
|
147
|
+
refreshes the occupancy lease on a checkout (SPEC.md §9.1, §14.2).
|
|
148
|
+
This is the seam built for exactly this SDK: a program embedding
|
|
149
|
+
scruff has no pane and no shell cwd'd anywhere, so the lease is the
|
|
150
|
+
only way `reap` learns a checkout is in use. A lease can only SAVE
|
|
151
|
+
a lane from the sweep, never condemn one — see {lease} for a
|
|
152
|
+
self-refreshing wrapper instead of calling this on a timer
|
|
153
|
+
yourself."""
|
|
154
|
+
args = ["heartbeat", *([path] if path else [])]
|
|
155
|
+
if pid is not None:
|
|
156
|
+
args += ["--pid", str(pid)]
|
|
157
|
+
await run(args, self._opts)
|
|
158
|
+
|
|
159
|
+
async def release_heartbeat(self, path: Optional[str] = None) -> None:
|
|
160
|
+
"""Drops the lease taken by {heartbeat}."""
|
|
161
|
+
await run(["heartbeat", *([path] if path else []), "--release"], self._opts)
|
|
162
|
+
|
|
163
|
+
async def lease(
|
|
164
|
+
self,
|
|
165
|
+
path: str,
|
|
166
|
+
*,
|
|
167
|
+
pid: Optional[int] = None,
|
|
168
|
+
refresh_seconds: float = 60.0,
|
|
169
|
+
) -> "Lease":
|
|
170
|
+
"""Takes an occupancy lease and holds it for as long as the
|
|
171
|
+
returned handle is open, refreshing on an interval comfortably
|
|
172
|
+
under the 90s TTL (`internal/occupancy.TTL`) that applies when
|
|
173
|
+
there's no pid to watch. This is the primitive an embedder's
|
|
174
|
+
"session" (a connection, not a cwd — SPEC.md §14.2) should hold
|
|
175
|
+
from connect to disconnect:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
lease = await scruff.lease(lane_dir)
|
|
179
|
+
# ... serve the session ...
|
|
180
|
+
await lease.release()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Pass `pid=` instead when the lease should track a real local
|
|
184
|
+
process — the kernel then releases it the instant that pid dies,
|
|
185
|
+
with no refresh loop needed at all, and `refresh_seconds` is
|
|
186
|
+
ignored.
|
|
187
|
+
|
|
188
|
+
Unlike the TS SDK's constructor-based `lease()`, this is a
|
|
189
|
+
coroutine: Python can await the first heartbeat before returning,
|
|
190
|
+
so a failure to take the lease raises here rather than silently
|
|
191
|
+
surfacing on the next refresh or release.
|
|
192
|
+
"""
|
|
193
|
+
kwargs: dict[str, Any] = {"pid": pid} if pid is not None else {}
|
|
194
|
+
await self.heartbeat(path, **kwargs)
|
|
195
|
+
return Lease(self, path, pid=pid, refresh_seconds=refresh_seconds)
|
|
196
|
+
|
|
197
|
+
async def new_interactive(self, name: Optional[str] = None, agent: Optional[str] = None) -> None:
|
|
198
|
+
"""`scruff new [name] --open [agent]` with stdio INHERITED from the calling
|
|
199
|
+
process. scruff execs the configured agent client unconditionally
|
|
200
|
+
here (unlike `resume`, `new` doesn't check for a TTY) —
|
|
201
|
+
appropriate for a real terminal app (a TUI) that wants to hand off
|
|
202
|
+
the screen and get control back when the agent session ends, and
|
|
203
|
+
WRONG for a server: it will block until the agent process exits,
|
|
204
|
+
with your stdio attached to whatever the agent expects."""
|
|
205
|
+
# --open is explicit: bare `scruff new` only prints the lane's path.
|
|
206
|
+
args = ["new", *([name] if name else []), "--open", *([agent] if agent else [])]
|
|
207
|
+
await _run_interactive(args, self._opts)
|
|
208
|
+
|
|
209
|
+
async def resume_interactive(self, name: str) -> None:
|
|
210
|
+
"""`scruff resume <name>` / `scruff <name>` with stdio INHERITED, so a
|
|
211
|
+
real terminal's TTY check passes and scruff hands off the screen to
|
|
212
|
+
the agent client. Same caveat as {new_interactive}: blocks until
|
|
213
|
+
that session ends."""
|
|
214
|
+
await _run_interactive(["resume", name], self._opts)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def _run_interactive(args: list[str], opts: RunOptions) -> None:
|
|
218
|
+
bin_ = opts.bin or "scruff"
|
|
219
|
+
proc = await asyncio.create_subprocess_exec(
|
|
220
|
+
bin_,
|
|
221
|
+
*args,
|
|
222
|
+
cwd=opts.cwd,
|
|
223
|
+
env=merged_env(opts.env),
|
|
224
|
+
# stdin/stdout/stderr default to None, i.e. inherited from this
|
|
225
|
+
# process — the async equivalent of Node's stdio: "inherit".
|
|
226
|
+
)
|
|
227
|
+
code = await proc.wait()
|
|
228
|
+
if code != 0:
|
|
229
|
+
raise ScruffError(code, "", [bin_, *args])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Lease:
|
|
233
|
+
"""A held occupancy lease. See {ScruffClient.lease}."""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
client: ScruffClient,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
pid: Optional[int],
|
|
241
|
+
refresh_seconds: float,
|
|
242
|
+
) -> None:
|
|
243
|
+
self._client = client
|
|
244
|
+
self._path = path
|
|
245
|
+
self._released = False
|
|
246
|
+
self._task: Optional["asyncio.Task[None]"] = None
|
|
247
|
+
if pid is None:
|
|
248
|
+
self._task = asyncio.create_task(self._refresh_loop(refresh_seconds))
|
|
249
|
+
|
|
250
|
+
async def _refresh_loop(self, refresh_seconds: float) -> None:
|
|
251
|
+
try:
|
|
252
|
+
while True:
|
|
253
|
+
await asyncio.sleep(refresh_seconds)
|
|
254
|
+
try:
|
|
255
|
+
await self._client.heartbeat(self._path)
|
|
256
|
+
except ScruffError:
|
|
257
|
+
pass # best-effort refresh; a miss self-heals on the next tick
|
|
258
|
+
except asyncio.CancelledError:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
async def release(self) -> None:
|
|
262
|
+
"""Drops the lease and stops refreshing it. Safe to call more than
|
|
263
|
+
once."""
|
|
264
|
+
if self._released:
|
|
265
|
+
return
|
|
266
|
+
self._released = True
|
|
267
|
+
if self._task is not None:
|
|
268
|
+
self._task.cancel()
|
|
269
|
+
await self._client.release_heartbeat(self._path)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .types import ScruffExitCode
|
|
4
|
+
|
|
5
|
+
_LABELS: dict[int, str] = {
|
|
6
|
+
ScruffExitCode.USAGE: "usage",
|
|
7
|
+
ScruffExitCode.REFUSED: "refused",
|
|
8
|
+
ScruffExitCode.DEGRADED: "degraded",
|
|
9
|
+
ScruffExitCode.CONFLICT: "conflict",
|
|
10
|
+
ScruffExitCode.LOCKED: "locked",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScruffError(Exception):
|
|
15
|
+
"""Raised by every SDK call that shells out and gets back a non-zero
|
|
16
|
+
exit. Carries scruff's actual exit code (SPEC.md §2.4) rather than
|
|
17
|
+
collapsing it to a generic failure — `err.refused` is how a caller tells
|
|
18
|
+
"scruff declined to destroy something" from "you asked wrong" (`USAGE`) or
|
|
19
|
+
"registry locked" (`LOCKED`), and each deserves different handling
|
|
20
|
+
(retry, surface to a human, or just don't retry).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: int, stderr: str, command: list[str]) -> None:
|
|
24
|
+
label = _LABELS.get(code, f"exit {code}")
|
|
25
|
+
suffix = f" — {stderr.strip()}" if stderr else ""
|
|
26
|
+
super().__init__(f"scruff {' '.join(command)}: {label}{suffix}")
|
|
27
|
+
self.code = code
|
|
28
|
+
self.stderr = stderr
|
|
29
|
+
self.command = command
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def refused(self) -> bool:
|
|
33
|
+
"""`True` when scruff declined for safety (occupied, dirty, or not
|
|
34
|
+
provably landed) rather than because the call itself was wrong."""
|
|
35
|
+
return self.code == ScruffExitCode.REFUSED
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def degraded(self) -> bool:
|
|
39
|
+
"""`True` when the operation completed but a signal was unavailable
|
|
40
|
+
(forge down, no `lsof`) — check `warnings` on the envelope for
|
|
41
|
+
why."""
|
|
42
|
+
return self.code == ScruffExitCode.DEGRADED
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .errors import ScruffError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RunOptions:
|
|
14
|
+
"""Options threaded through to every subprocess call."""
|
|
15
|
+
|
|
16
|
+
# Path to the scruff binary, or a bare name resolved on PATH. Defaults to
|
|
17
|
+
# "scruff".
|
|
18
|
+
bin: Optional[str] = None
|
|
19
|
+
# Working directory to run scruff from — most commands are cwd-sensitive
|
|
20
|
+
# (`scruff new`, `scruff park`, a bare `scruff <name>`).
|
|
21
|
+
cwd: Optional[str] = None
|
|
22
|
+
# Extra environment variables, merged over the current process's env —
|
|
23
|
+
# e.g. SCRUFF_AGENT, SCRUFF_OCCUPANCY. A value of None unsets the key rather
|
|
24
|
+
# than passing the literal string "None" to the child.
|
|
25
|
+
env: Optional[dict[str, Optional[str]]] = None
|
|
26
|
+
# Piped to the child's stdin, then the stream is closed. Used by
|
|
27
|
+
# `scruff hook create`/`remove`, which read JSON off stdin (SPEC.md §2.3).
|
|
28
|
+
stdin: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RunResult:
|
|
33
|
+
stdout: str
|
|
34
|
+
stderr: str
|
|
35
|
+
code: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def merged_env(env: Optional[dict[str, Optional[str]]]) -> Optional[dict[str, str]]:
|
|
39
|
+
if env is None:
|
|
40
|
+
return None
|
|
41
|
+
merged = dict(os.environ)
|
|
42
|
+
for key, value in env.items():
|
|
43
|
+
if value is None:
|
|
44
|
+
merged.pop(key, None)
|
|
45
|
+
else:
|
|
46
|
+
merged[key] = value
|
|
47
|
+
return merged
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def run(args: list[str], opts: Optional[RunOptions] = None) -> RunResult:
|
|
51
|
+
"""Runs one scruff invocation to completion and collects its output. Every
|
|
52
|
+
non-`--json` scruff command writes human text to stdout on success — this
|
|
53
|
+
is the primitive `list()`/`watch()` build their typed parsing on top of,
|
|
54
|
+
and the one lifecycle commands (`new`, `park`, `reap`, ...) use directly,
|
|
55
|
+
surfacing stdout as a plain string.
|
|
56
|
+
|
|
57
|
+
Raises {ScruffError} on a non-zero exit, carrying scruff's exit code
|
|
58
|
+
(SPEC.md §2.4) rather than collapsing every failure into one shape.
|
|
59
|
+
"""
|
|
60
|
+
opts = opts or RunOptions()
|
|
61
|
+
bin_ = opts.bin or "scruff"
|
|
62
|
+
proc = await asyncio.create_subprocess_exec(
|
|
63
|
+
bin_,
|
|
64
|
+
*args,
|
|
65
|
+
cwd=opts.cwd,
|
|
66
|
+
env=merged_env(opts.env),
|
|
67
|
+
stdin=asyncio.subprocess.PIPE,
|
|
68
|
+
stdout=asyncio.subprocess.PIPE,
|
|
69
|
+
stderr=asyncio.subprocess.PIPE,
|
|
70
|
+
)
|
|
71
|
+
stdin_bytes = opts.stdin.encode() if opts.stdin is not None else b""
|
|
72
|
+
stdout_bytes, stderr_bytes = await proc.communicate(stdin_bytes)
|
|
73
|
+
stdout = stdout_bytes.decode()
|
|
74
|
+
stderr = stderr_bytes.decode()
|
|
75
|
+
code = proc.returncode if proc.returncode is not None else 1
|
|
76
|
+
|
|
77
|
+
if code != 0:
|
|
78
|
+
raise ScruffError(code, stderr, [bin_, *args])
|
|
79
|
+
return RunResult(stdout=stdout, stderr=stderr, code=code)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def run_json(args: list[str], opts: Optional[RunOptions] = None) -> Any:
|
|
83
|
+
"""Same as {run}, but parses stdout as JSON — for `--json` commands
|
|
84
|
+
only. scruff's own contract (README, internal/ui) is "stdout carries the
|
|
85
|
+
payload, every diagnostic goes to stderr", so this never has to guess
|
|
86
|
+
which lines are data."""
|
|
87
|
+
result = await run(args, opts)
|
|
88
|
+
return json.loads(result.stdout)
|
|
File without changes
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Wire types for scruff's frozen public contracts — SPEC.md §2.2 (`--json`) and
|
|
2
|
+
# §14.3 step 2 (`watch --json`). Hand-ported from the Go source of truth
|
|
3
|
+
# (internal/commands/json.go, internal/commands/watch.go) rather than
|
|
4
|
+
# generated, because scruff has no `go generate` step yet for this — see the
|
|
5
|
+
# SDK README for the drift risk that implies.
|
|
6
|
+
#
|
|
7
|
+
# schema 1 is what's actually implemented today. SPEC.md §2.2's example
|
|
8
|
+
# envelope also shows `pr`, `overlap`, `ahead`, `behind` — those are §0.2/
|
|
9
|
+
# later milestones (`overlap`, forge polling) and are NOT on the wire yet.
|
|
10
|
+
# Do not add them here until json.go does; a field that exists in the type
|
|
11
|
+
# but never arrives on the wire is worse than one that's simply missing.
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import IntEnum
|
|
17
|
+
from typing import Any, Literal, Optional, Union
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScruffExitCode(IntEnum):
|
|
21
|
+
"""scruff's exit-code contract (SPEC.md §2.4). REFUSED vs USAGE is the one
|
|
22
|
+
that matters to a caller: "you asked wrong" vs "I declined to destroy
|
|
23
|
+
something"."""
|
|
24
|
+
|
|
25
|
+
OK = 0
|
|
26
|
+
USAGE = 1
|
|
27
|
+
REFUSED = 2
|
|
28
|
+
DEGRADED = 3
|
|
29
|
+
CONFLICT = 4
|
|
30
|
+
LOCKED = 5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Closed set — SPEC.md §2.2: "additions are minor, removals major." Treat an
|
|
34
|
+
# unknown value as opaque, not an error.
|
|
35
|
+
LaneState = Literal["live", "parked", "stray"]
|
|
36
|
+
|
|
37
|
+
LandedVerdict = Literal["yes", "no", "fresh", "contained"]
|
|
38
|
+
|
|
39
|
+
LandedVia = Optional[
|
|
40
|
+
Literal[
|
|
41
|
+
"never-diverged",
|
|
42
|
+
"ancestry",
|
|
43
|
+
"pr-head-oid",
|
|
44
|
+
"patch-equivalence",
|
|
45
|
+
"merge-tree-empty",
|
|
46
|
+
]
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class LandedInfo:
|
|
52
|
+
verdict: LandedVerdict
|
|
53
|
+
via: LandedVia
|
|
54
|
+
confidence: str
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _from_json(cls, d: dict[str, Any]) -> "LandedInfo":
|
|
58
|
+
return cls(verdict=d["verdict"], via=d.get("via"), confidence=d["confidence"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class PostMergeAhead:
|
|
63
|
+
commits: int
|
|
64
|
+
# PR number, or 0 when there isn't one — scruff doesn't null this field
|
|
65
|
+
# today (internal/commands/json.go's jsonPostMerge), unlike `pr` at the
|
|
66
|
+
# envelope level. Treat 0 as "none" here, not as PR #0.
|
|
67
|
+
pr: int
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def _from_json(cls, d: dict[str, Any]) -> "PostMergeAhead":
|
|
71
|
+
return cls(commits=d["commits"], pr=d["pr"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class ScruffLane:
|
|
76
|
+
"""One lane, in the exact shape `--json` uses for `lanes[]` — the same
|
|
77
|
+
shape `watch --json` puts on `event.lane`. One schema whether you're
|
|
78
|
+
reading a snapshot or a stream (SPEC.md §14.1).
|
|
79
|
+
|
|
80
|
+
`occupied` and `dirty` are three-state on purpose: `None` means "not
|
|
81
|
+
determined" (no lsof, no forge, cache miss), which is categorically
|
|
82
|
+
different from `False`. Every consumer bug in scruff's bash-era statusline
|
|
83
|
+
came from collapsing that `None` into `False` — do not do that here
|
|
84
|
+
either.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
repo: str
|
|
89
|
+
main: str
|
|
90
|
+
branch: str
|
|
91
|
+
path: str
|
|
92
|
+
parent: str
|
|
93
|
+
# The client this lane opens (claude | codex | opencode | pi, or whatever
|
|
94
|
+
# adapters are configured) — never the lane's own identity.
|
|
95
|
+
agent: str
|
|
96
|
+
state: LaneState
|
|
97
|
+
occupied: Optional[bool]
|
|
98
|
+
dirty: Optional[bool]
|
|
99
|
+
landed: LandedInfo
|
|
100
|
+
post_merge_ahead: PostMergeAhead
|
|
101
|
+
last_commit: str
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _from_json(cls, d: dict[str, Any]) -> "ScruffLane":
|
|
105
|
+
return cls(
|
|
106
|
+
name=d["name"],
|
|
107
|
+
repo=d["repo"],
|
|
108
|
+
main=d["main"],
|
|
109
|
+
branch=d["branch"],
|
|
110
|
+
path=d["path"],
|
|
111
|
+
parent=d["parent"],
|
|
112
|
+
agent=d["agent"],
|
|
113
|
+
state=d["state"],
|
|
114
|
+
occupied=d["occupied"],
|
|
115
|
+
dirty=d["dirty"],
|
|
116
|
+
landed=LandedInfo._from_json(d["landed"]),
|
|
117
|
+
post_merge_ahead=PostMergeAhead._from_json(d["post_merge_ahead"]),
|
|
118
|
+
last_commit=d["last_commit"],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class ScruffEnvelope:
|
|
124
|
+
"""The `scruff --json` / `scruff list --json` envelope — byte-identical
|
|
125
|
+
between the two spellings (SPEC.md §2.2)."""
|
|
126
|
+
|
|
127
|
+
scruff: str
|
|
128
|
+
schema: int
|
|
129
|
+
lanes: list[ScruffLane]
|
|
130
|
+
warnings: list[str]
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def _from_json(cls, d: dict[str, Any]) -> "ScruffEnvelope":
|
|
134
|
+
return cls(
|
|
135
|
+
scruff=d["scruff"],
|
|
136
|
+
schema=d["schema"],
|
|
137
|
+
lanes=[ScruffLane._from_json(lane) for lane in d["lanes"]],
|
|
138
|
+
warnings=list(d["warnings"]),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# `scruff watch --json` — SPEC.md §14.3 step 2, §14.4.
|
|
144
|
+
|
|
145
|
+
# Closed set, same discipline as LaneState/LandedVerdict: additions are
|
|
146
|
+
# minor, removals major. An unknown kind is noise to ignore, not an error to
|
|
147
|
+
# raise on — that's what lets scruff add `landed`/`source: "forge"` later
|
|
148
|
+
# without breaking every SDK pinned to v1 (SPEC.md §14.4).
|
|
149
|
+
WatchEventKind = Literal[
|
|
150
|
+
"sync", "ready", "created", "parked", "resumed", "reaped", "changed", "warning"
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class WatchHello:
|
|
156
|
+
"""First line of every `watch` stream. A version header, not an event —
|
|
157
|
+
see `capabilities` for why it carries more than `{scruff, schema}`."""
|
|
158
|
+
|
|
159
|
+
kind: Literal["hello"]
|
|
160
|
+
seq: int
|
|
161
|
+
scruff: str
|
|
162
|
+
schema: int
|
|
163
|
+
# What families of event this scruff build can ever send on this stream.
|
|
164
|
+
# v1 always sends exactly ["registry"]; a future "forge" entry is how a
|
|
165
|
+
# consumer learns a landed/post_merge_ahead event kind might show up
|
|
166
|
+
# without guessing from which kinds happen to have arrived yet.
|
|
167
|
+
capabilities: list[str]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class WatchEvent:
|
|
172
|
+
"""Every line after `hello`. One line, at most one lane — never a
|
|
173
|
+
batch."""
|
|
174
|
+
|
|
175
|
+
kind: WatchEventKind
|
|
176
|
+
# Monotonic across the WHOLE stream, hello included — lets a consumer
|
|
177
|
+
# fanning this out over its own transport (e.g. a websocket) detect a
|
|
178
|
+
# dropped line without scruff knowing anything about that transport.
|
|
179
|
+
seq: int
|
|
180
|
+
# RFC3339 UTC. When THIS scruff process observed the change, not
|
|
181
|
+
# necessarily when it happened at the source. Absent on `hello`.
|
|
182
|
+
ts: Optional[str] = None
|
|
183
|
+
# Which provider produced the event. v1 only ever writes "registry";
|
|
184
|
+
# absent on `ready`, which names no lane and no provider.
|
|
185
|
+
source: Optional[str] = None
|
|
186
|
+
# Present on every kind except `ready` and `warning`.
|
|
187
|
+
lane: Optional[ScruffLane] = None
|
|
188
|
+
# Present only on `warning` — the same text `warnings[]` carries under
|
|
189
|
+
# `--json`, pushed here because a stream reader has no envelope to poll.
|
|
190
|
+
message: Optional[str] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
WatchLine = Union[WatchHello, WatchEvent]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_watch_line(d: dict[str, Any]) -> WatchLine:
|
|
197
|
+
if d["kind"] == "hello":
|
|
198
|
+
return WatchHello(
|
|
199
|
+
kind="hello",
|
|
200
|
+
seq=d["seq"],
|
|
201
|
+
scruff=d["scruff"],
|
|
202
|
+
schema=d["schema"],
|
|
203
|
+
capabilities=list(d["capabilities"]),
|
|
204
|
+
)
|
|
205
|
+
return WatchEvent(
|
|
206
|
+
kind=d["kind"],
|
|
207
|
+
seq=d["seq"],
|
|
208
|
+
ts=d.get("ts"),
|
|
209
|
+
source=d.get("source"),
|
|
210
|
+
lane=ScruffLane._from_json(d["lane"]) if d.get("lane") is not None else None,
|
|
211
|
+
message=d.get("message"),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_watch_hello(line: WatchLine) -> bool:
|
|
216
|
+
return isinstance(line, WatchHello)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import AsyncGenerator, Optional
|
|
6
|
+
|
|
7
|
+
from .exec import RunOptions, merged_env
|
|
8
|
+
from .types import WatchEvent, WatchLine, parse_watch_line
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def watch_all(opts: Optional[RunOptions] = None) -> AsyncGenerator[WatchLine, None]:
|
|
12
|
+
"""`scruff watch --json` as an async iterator of typed lines. One object
|
|
13
|
+
per NDJSON line on stdout, in order: `hello`, a `sync` burst for every
|
|
14
|
+
lane already alive, `ready`, then live changes for as long as the
|
|
15
|
+
process runs (SPEC.md §14.3 step 2).
|
|
16
|
+
|
|
17
|
+
The child process is killed when you stop consuming — `break` out of an
|
|
18
|
+
`async for`, or call `.aclose()` on the generator. Async generators are
|
|
19
|
+
not guaranteed to be finalized promptly by garbage collection the way
|
|
20
|
+
CPython's refcounting closes sync generators, so wrap long-lived use in
|
|
21
|
+
`contextlib.aclosing()` if you want the subprocess torn down
|
|
22
|
+
deterministically rather than on the next GC pass:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from contextlib import aclosing
|
|
26
|
+
|
|
27
|
+
async with aclosing(scruff.watch()) as stream:
|
|
28
|
+
async for line in stream:
|
|
29
|
+
...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
There is no other way to stop it short: `watch` has no built-in end
|
|
33
|
+
condition, by design (SPEC.md §14).
|
|
34
|
+
"""
|
|
35
|
+
opts = opts or RunOptions()
|
|
36
|
+
bin_ = opts.bin or "scruff"
|
|
37
|
+
proc = await asyncio.create_subprocess_exec(
|
|
38
|
+
bin_,
|
|
39
|
+
"watch",
|
|
40
|
+
"--json",
|
|
41
|
+
cwd=opts.cwd,
|
|
42
|
+
env=merged_env(opts.env),
|
|
43
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
44
|
+
stdout=asyncio.subprocess.PIPE,
|
|
45
|
+
stderr=asyncio.subprocess.PIPE,
|
|
46
|
+
)
|
|
47
|
+
assert proc.stdout is not None
|
|
48
|
+
try:
|
|
49
|
+
while True:
|
|
50
|
+
raw = await proc.stdout.readline()
|
|
51
|
+
if not raw:
|
|
52
|
+
break
|
|
53
|
+
line = raw.decode().strip()
|
|
54
|
+
if not line:
|
|
55
|
+
continue
|
|
56
|
+
yield parse_watch_line(json.loads(line))
|
|
57
|
+
finally:
|
|
58
|
+
if proc.returncode is None:
|
|
59
|
+
proc.kill()
|
|
60
|
+
await proc.wait()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def watch_lane(path: str, opts: Optional[RunOptions] = None) -> AsyncGenerator[WatchEvent, None]:
|
|
64
|
+
"""{watch_all}, filtered to events about one lane (`event.lane.path`)
|
|
65
|
+
and stripped of the `hello`/`ready` framing that names no lane — the
|
|
66
|
+
shape an embedder holding one session per lane usually wants: "tell me
|
|
67
|
+
when THIS lane's state changes."
|
|
68
|
+
|
|
69
|
+
A `sync` event for the lane still passes through — it is NOT framing.
|
|
70
|
+
It's how a caller that started watching after the lane went live learns
|
|
71
|
+
the lane exists at all, so a match over `event.kind` needs a `sync` arm.
|
|
72
|
+
|
|
73
|
+
Compare full paths, not names: names aren't unique across repos, but a
|
|
74
|
+
checkout path is the registry's own primary key (SPEC.md §2.1).
|
|
75
|
+
"""
|
|
76
|
+
async for line in watch_all(opts):
|
|
77
|
+
if isinstance(line, WatchEvent) and line.lane is not None and line.lane.path == path:
|
|
78
|
+
yield line
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# A stand-in for the real `scruff` binary, used only by this SDK's own tests.
|
|
3
|
+
# Emits fixed --json / watch --json payloads so the SDK's parsing, process
|
|
4
|
+
# lifecycle and error mapping can be exercised without a Go build.
|
|
5
|
+
#
|
|
6
|
+
# Kept in sync by hand with sdk/ts/test/fake-scruff.sh — same fixture data, so
|
|
7
|
+
# a wire-shape bug shows up identically in both SDKs' test suites.
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
case "${1:-}" in
|
|
11
|
+
--json|list)
|
|
12
|
+
cat <<'JSON'
|
|
13
|
+
{
|
|
14
|
+
"scruff": "0.1.0-dev",
|
|
15
|
+
"schema": 2,
|
|
16
|
+
"lanes": [
|
|
17
|
+
{
|
|
18
|
+
"name": "sparkle",
|
|
19
|
+
"repo": "hausfold/haus",
|
|
20
|
+
"main": "/repo/haus",
|
|
21
|
+
"branch": "worktree-sparkle",
|
|
22
|
+
"path": "/repo/.scruff/haus/sparkle",
|
|
23
|
+
"parent": "/repo/haus",
|
|
24
|
+
"agent": "claude",
|
|
25
|
+
"state": "live",
|
|
26
|
+
"occupied": true,
|
|
27
|
+
"dirty": false,
|
|
28
|
+
"landed": { "verdict": "no", "via": null, "confidence": "certain" },
|
|
29
|
+
"post_merge_ahead": { "commits": 0, "pr": 0 },
|
|
30
|
+
"last_commit": "spec: SDK smoke test fixture"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "frost",
|
|
34
|
+
"repo": "hausfold/haus",
|
|
35
|
+
"main": "/repo/haus",
|
|
36
|
+
"branch": "worktree-frost",
|
|
37
|
+
"path": "/repo/.scruff/haus/frost",
|
|
38
|
+
"parent": "/repo/haus",
|
|
39
|
+
"agent": "codex",
|
|
40
|
+
"state": "parked",
|
|
41
|
+
"occupied": null,
|
|
42
|
+
"dirty": null,
|
|
43
|
+
"landed": { "verdict": "contained", "via": "merge-tree-empty", "confidence": "advisory" },
|
|
44
|
+
"post_merge_ahead": { "commits": 0, "pr": 0 },
|
|
45
|
+
"last_commit": "park: set aside"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"warnings": []
|
|
49
|
+
}
|
|
50
|
+
JSON
|
|
51
|
+
;;
|
|
52
|
+
|
|
53
|
+
watch)
|
|
54
|
+
echo '{"kind":"hello","seq":0,"scruff":"0.1.0-dev","schema":2,"capabilities":["registry"]}'
|
|
55
|
+
echo '{"kind":"sync","seq":1,"ts":"2026-08-07T02:11:04Z","source":"registry","lane":{"name":"sparkle","repo":"hausfold/haus","main":"/repo/haus","branch":"worktree-sparkle","path":"/repo/.scruff/haus/sparkle","parent":"/repo/haus","agent":"claude","state":"live","occupied":true,"dirty":false,"landed":{"verdict":"no","via":null,"confidence":"certain"},"post_merge_ahead":{"commits":0,"pr":0},"last_commit":"c1"}}'
|
|
56
|
+
echo '{"kind":"ready","seq":2,"ts":"2026-08-07T02:11:04Z"}'
|
|
57
|
+
sleep 0.05
|
|
58
|
+
echo '{"kind":"created","seq":3,"ts":"2026-08-07T02:11:05Z","source":"registry","lane":{"name":"fresh","repo":"hausfold/haus","main":"/repo/haus","branch":"worktree-fresh","path":"/repo/.scruff/haus/fresh","parent":"/repo/haus","agent":"claude","state":"live","occupied":true,"dirty":true,"landed":{"verdict":"no","via":null,"confidence":"certain"},"post_merge_ahead":{"commits":0,"pr":0},"last_commit":"c2"}}'
|
|
59
|
+
# Stay alive until killed, same as the real `watch`.
|
|
60
|
+
trap 'exit 0' TERM INT
|
|
61
|
+
while true; do sleep 0.05; done
|
|
62
|
+
;;
|
|
63
|
+
|
|
64
|
+
child)
|
|
65
|
+
echo "/repo/.scruff/other/new-lane"
|
|
66
|
+
;;
|
|
67
|
+
|
|
68
|
+
park)
|
|
69
|
+
exit 0
|
|
70
|
+
;;
|
|
71
|
+
|
|
72
|
+
heartbeat)
|
|
73
|
+
if [[ " $* " == *" --release "* ]]; then
|
|
74
|
+
echo "released $2" >&2
|
|
75
|
+
fi
|
|
76
|
+
exit 0
|
|
77
|
+
;;
|
|
78
|
+
|
|
79
|
+
reap-refused)
|
|
80
|
+
echo "refused: occupied" >&2
|
|
81
|
+
exit 2
|
|
82
|
+
;;
|
|
83
|
+
|
|
84
|
+
resume)
|
|
85
|
+
# Mirrors the real resume's non-TTY behaviour: print the reopen command
|
|
86
|
+
# instead of exec'ing anything, since stdout is a pipe under test.
|
|
87
|
+
echo "checkout ready. Reopen the claude chat with:"
|
|
88
|
+
echo " cd /repo/.scruff/haus/sparkle && claude --resume"
|
|
89
|
+
;;
|
|
90
|
+
|
|
91
|
+
*)
|
|
92
|
+
echo "fake-scruff: unhandled args: $*" >&2
|
|
93
|
+
exit 1
|
|
94
|
+
;;
|
|
95
|
+
esac
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from contextlib import aclosing
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from scruff import ScruffClient, ScruffClientOptions, ScruffError
|
|
7
|
+
from scruff.exec import RunOptions, run
|
|
8
|
+
from scruff.watch import watch_lane
|
|
9
|
+
|
|
10
|
+
FAKE_SCRUFF = os.path.join(os.path.dirname(__file__), "fake-scruff.sh")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def client() -> ScruffClient:
|
|
14
|
+
return ScruffClient(ScruffClientOptions(bin=FAKE_SCRUFF))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def test_list_parses_the_json_envelope_with_nullable_discipline_intact() -> None:
|
|
18
|
+
envelope = await client().list()
|
|
19
|
+
assert envelope.schema == 2
|
|
20
|
+
assert len(envelope.lanes) == 2
|
|
21
|
+
|
|
22
|
+
sparkle = envelope.lanes[0]
|
|
23
|
+
assert sparkle.occupied is True # True, not falsy-coerced
|
|
24
|
+
assert sparkle.dirty is False # False, distinct from None
|
|
25
|
+
|
|
26
|
+
frost = envelope.lanes[1]
|
|
27
|
+
assert frost.occupied is None # None means "not determined"
|
|
28
|
+
assert frost.dirty is None
|
|
29
|
+
assert frost.landed.verdict == "contained"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def test_watch_yields_hello_sync_ready_then_live_changes_and_stops_on_break() -> None:
|
|
33
|
+
kinds = []
|
|
34
|
+
async with aclosing(client().watch()) as stream:
|
|
35
|
+
async for line in stream:
|
|
36
|
+
kinds.append(line.kind)
|
|
37
|
+
if line.kind == "created":
|
|
38
|
+
break
|
|
39
|
+
assert kinds == ["hello", "sync", "ready", "created"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_watch_lane_filters_to_one_lanes_events_only() -> None:
|
|
43
|
+
seen = []
|
|
44
|
+
async with aclosing(watch_lane("/repo/.scruff/haus/fresh", RunOptions(bin=FAKE_SCRUFF))) as stream:
|
|
45
|
+
async for ev in stream:
|
|
46
|
+
seen.append(ev.kind)
|
|
47
|
+
break
|
|
48
|
+
assert seen == ["created"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_client_watch_lane_filters_the_same_way_on_its_own_options() -> None:
|
|
52
|
+
seen = []
|
|
53
|
+
async with aclosing(client().watch_lane("/repo/.scruff/haus/fresh")) as stream:
|
|
54
|
+
async for ev in stream:
|
|
55
|
+
seen.append(ev.kind)
|
|
56
|
+
break
|
|
57
|
+
assert seen == ["created"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# `sync` names a lane, so it is data, not framing — it's the only way a caller
|
|
61
|
+
# that attached AFTER the lane went live learns the lane exists. Pinned because
|
|
62
|
+
# three docstrings used to claim the opposite.
|
|
63
|
+
async def test_watch_lane_passes_a_lanes_sync_through() -> None:
|
|
64
|
+
seen = []
|
|
65
|
+
async with aclosing(client().watch_lane("/repo/.scruff/haus/sparkle")) as stream:
|
|
66
|
+
async for ev in stream:
|
|
67
|
+
seen.append(ev.kind)
|
|
68
|
+
break
|
|
69
|
+
assert seen == ["sync"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_child_returns_only_the_new_checkout_path() -> None:
|
|
73
|
+
directory = await client().child("/repo/other")
|
|
74
|
+
assert directory == "/repo/.scruff/other/new-lane"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_resume_captured_stdout_never_execs() -> None:
|
|
78
|
+
out = await client().resume("sparkle")
|
|
79
|
+
assert "claude --resume" in out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_error_mapping_nonzero_exit_raises_scruff_error_carrying_the_real_exit_code() -> None:
|
|
83
|
+
with pytest.raises(ScruffError) as exc_info:
|
|
84
|
+
await run(["reap-refused"], RunOptions(bin=FAKE_SCRUFF))
|
|
85
|
+
err = exc_info.value
|
|
86
|
+
assert err.code == 2
|
|
87
|
+
assert err.refused is True
|
|
88
|
+
assert "occupied" in err.stderr
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_lease_release_calls_heartbeat_release() -> None:
|
|
92
|
+
c = client()
|
|
93
|
+
lease = await c.lease("/repo/.scruff/haus/sparkle", pid=12345)
|
|
94
|
+
await lease.release()
|
|
95
|
+
# No raise: fake-scruff's heartbeat branch accepts --release silently.
|