Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.0] - 2026-07-11

Aligns exec and checkpoints with the real Sprites API surface reverse-engineered
from `superfly/sprites-go` (websocket.go, checkpoint.go). Exec is now a control
WebSocket speaking the framed stream protocol, and checkpoint create/restore
stream NDJSON progress.

### Changed

- `exec` is now a control WebSocket at `GET /v1/sprites/{name}/exec`, replacing
the JSON `POST` endpoint. The command is reconstructed from the query string
(`cmd` repeated per argv element, or a single `cmd` as the whole command line;
`path` is the argv[0] fallback). Every message is a binary frame
`[streamID][payload]`: StreamStdin=0, StreamStdout=1, StreamStderr=2,
StreamExit=3 (payload[0] is the exit code), StreamStdinEOF=4. The server writes
stdout as `[1]<bytes>`, stderr as `[2]<bytes>`, then `[3]<exitCodeByte>` and
closes, matching the real Sprites SDK's non-PTY framing. The handshake response
advertises `sprite-capabilities: control-ws`.
- Checkpoint create moved to the singular `POST /v1/sprites/{name}/checkpoint`
and now streams line-delimited NDJSON progress events (`application/x-ndjson`):
an `info` event then a terminal `{"event":"complete","id":"v<N>"}` carrying the
server-assigned version id. The old plural create and its `{id}` JSON body are
removed.
- `GET /v1/sprites/{name}/checkpoints` now returns a bare JSON array
`[{id, comment, create_time, is_auto}]` (creation order), not a
`{checkpoints: [...]}` wrapper. Each checkpoint gains a `create_time` timestamp
and an `is_auto` flag (false for manual checkpoints).
- Restore (`POST /v1/sprites/{name}/checkpoints/{id}/restore`) now streams NDJSON
progress events; an unknown sprite or checkpoint id is still a `404` before the
stream starts.
- `GET /v1/sprites/{name}`'s `checkpoints` projection carries the richer
`{id, comment, create_time, is_auto}` shape.
- `/_spritzer/health`'s implemented-path list reflects the new surface (WS exec,
singular checkpoint create, individual checkpoint GET, checkpoints list, and
restore).

### Added

- `GET /v1/sprites/{name}/checkpoints/{id}` returns a single checkpoint's
metadata (`{id, comment, create_time, is_auto}`); an unknown id is a `404`.
- `github.com/coder/websocket` as the WebSocket dependency for the control-exec
endpoint.

## [0.2.0] - 2026-07-11

Corrects the checkpoint/restore surface to the confirmed real Sprites API. The
Expand Down Expand Up @@ -64,6 +107,7 @@ real API assigns the id and the caller controls only a comment.
- Distroless container image, GoReleaser configuration, mkdocs-material doc site,
and CI.

[Unreleased]: https://github.com/intentius/spritzer/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/intentius/spritzer/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/intentius/spritzer/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/intentius/spritzer/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/intentius/spritzer/releases/tag/v0.1.0
56 changes: 33 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ it, so the same integration suite passes against the spritzer container image.

- Stateful in-memory store of sprites keyed by name, each with a filesystem
(path → contents) and an ordered list of checkpoints.
- `exec` runs a small scripted interpreter (`echo > path`, `echo`, `cat`, `rm`,
`true`/`false`, `./risky.sh`, and an echo-back default) so a command can write,
overwrite, or fail a filesystem key and the result is observable.
- Checkpoint / restore: a checkpoint deep-copies the filesystem under a
server-assigned version id (`v1`, `v2`, …) with an optional caller comment; a
restore takes a checkpoint id in the path, replaces the filesystem with that
copy, and returns the sprite to `running`. This is the
- `exec` is a control WebSocket at `GET /v1/sprites/{id}/exec` speaking the real
Sprites SDK's framed protocol: each binary message is `[streamID][payload]`
(StreamStdin=0, StreamStdout=1, StreamStderr=2, StreamExit=3, StreamStdinEOF=4).
Behind the frames a small scripted interpreter (`echo > path`, `echo`, `cat`,
`rm`, `true`/`false`, `./risky.sh`, and an echo-back default) writes,
overwrites, or fails a filesystem key so the result is observable.
- Checkpoint / restore: create is `POST /v1/sprites/{id}/checkpoint` (singular),
streaming NDJSON progress and assigning a server version id (`v1`, `v2`, …) with
an optional caller comment; the list is a bare array with `create_time` and
`is_auto`; restore takes a checkpoint id in the path, streams NDJSON, replaces
the filesystem with that copy, and returns the sprite to `running`. This is the
checkpoint-as-compensation primitive.
- A destroyed or missing sprite returns `404` on any subsequent operation.
- A `/_spritzer/health` endpoint reporting version and implemented paths.
Expand Down Expand Up @@ -72,25 +76,30 @@ BASE=http://localhost:4290
curl -s -X POST "$BASE/v1/sprites" -d '{"name":"demo"}'
# => {"id":"demo","url":"http://localhost:4290/s/demo"}

# Seed state, then checkpoint it. The server assigns the version id.
curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"echo good > /state"}'
curl -s -X POST "$BASE/v1/sprites/demo/checkpoints" -d '{"comment":"pre-run"}'
# => {"id":"v1"}
# Checkpoint the current state. The server assigns the version id and streams
# NDJSON progress; the id is on the terminal complete event.
curl -s -X POST "$BASE/v1/sprites/demo/checkpoint" -d '{"comment":"pre-run"}'
# => {"event":"info","message":"creating checkpoint"}
# {"event":"complete","message":"checkpoint created","id":"v1"}

# List the checkpoints (creation order).
# List the checkpoints (creation order) as a bare array.
curl -s "$BASE/v1/sprites/demo/checkpoints"
# => {"checkpoints":[{"id":"v1","comment":"pre-run"}]}
# => [{"id":"v1","comment":"pre-run","create_time":"2026-07-11T...Z","is_auto":false}]

# Run a risky step that corrupts state and fails.
curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"./risky.sh"}'
# => {"stdout":"","stderr":"risky.sh: failed\n","exitCode":1}

# Restore rewinds the filesystem to the checkpoint, addressed by id in the path.
# Restore rewinds the filesystem to the checkpoint, addressed by id in the path,
# streaming NDJSON progress.
curl -s -X POST "$BASE/v1/sprites/demo/checkpoints/v1/restore"
curl -s "$BASE/v1/sprites/demo"
# => {"id":"demo","status":"running","url":"...","fs":{"/state":"good"},"checkpoints":[{"id":"v1","comment":"pre-run"}]}
# => {"event":"info","message":"restoring checkpoint v1"}
# {"event":"complete","message":"checkpoint restored","id":"v1"}
```

`exec` is a control WebSocket at `ws://<host>/v1/sprites/{id}/exec`. Pass the
command as `cmd` query params (`?cmd=echo&cmd=hi`, or a single `?cmd=echo hi`).
Every message is a binary frame `[streamID][payload]`: the server writes stdout
as `[1]<bytes>`, stderr as `[2]<bytes>`, then `[3]<exitCodeByte>`. So
`echo hi` yields `[1]"hi\n"` then `[3]\x00` (exit 0), and `./risky.sh` yields
`[2]"risky.sh: failed\n"` then `[3]\x01` (exit 1).

## Comparison

| Capability | spritzer | Schema mock | Real Sprites |
Expand All @@ -104,9 +113,10 @@ curl -s "$BASE/v1/sprites/demo"

## API coverage

Implemented: create, exec, checkpoint, list checkpoints, restore-by-id, destroy,
and an inspection `GET`, plus a `/_spritzer/health` report. The full table is in the
[API coverage docs](https://intentius.github.io/spritzer/api-coverage/).
Implemented: create, the exec control WebSocket, checkpoint (NDJSON), list
checkpoints (bare array), get one checkpoint, restore-by-id (NDJSON), destroy,
and an inspection `GET`, plus a `/_spritzer/health` report. The full table is in
the [API coverage docs](https://intentius.github.io/spritzer/api-coverage/).

## Development

Expand Down
56 changes: 44 additions & 12 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,50 @@ clear JSON error.
| Method | Path | Notes |
| --- | --- | --- |
| POST | `/v1/sprites` | Create a sprite; `name` is required and becomes the id. Returns `{id, url}`. |
| POST | `/v1/sprites/{id}/exec` | Run a command; returns `{stdout, stderr, exitCode}`. The REST exec response shape is provisional (`TODO(confirm)`); real exec is WebSocket-primary. |
| POST | `/v1/sprites/{id}/checkpoints` | Deep-copy the filesystem under a server-assigned version id (`v1`, `v2`, …). Body is `{comment?}`; returns `{id}`. |
| GET | `/v1/sprites/{id}/checkpoints` | List the checkpoints in creation order: `{checkpoints: [{id, comment}]}`. |
| POST | `/v1/sprites/{id}/checkpoints/{cid}/restore` | Replace the filesystem with checkpoint `{cid}` and return the sprite to `running`; `404` if the id is unknown. |
| GET (WS) | `/v1/sprites/{id}/exec` | Control WebSocket. Reconstructs the command from the query string and streams framed `[streamID][payload]` messages. See [the exec control WebSocket](#the-exec-control-websocket). |
| POST | `/v1/sprites/{id}/checkpoint` | Deep-copy the filesystem under a server-assigned version id (`v1`, `v2`, …). Body is `{comment?}`; streams NDJSON progress ending in `{"event":"complete","id":"v<N>"}`. |
| GET | `/v1/sprites/{id}/checkpoints` | List the checkpoints in creation order as a bare JSON array `[{id, comment, create_time, is_auto}]`. |
| GET | `/v1/sprites/{id}/checkpoints/{cid}` | A single checkpoint's metadata: `{id, comment, create_time, is_auto}`; `404` if the id is unknown. |
| POST | `/v1/sprites/{id}/checkpoints/{cid}/restore` | Replace the filesystem with checkpoint `{cid}` and return the sprite to `running`; streams NDJSON progress. `404` if the id is unknown. |
| DELETE | `/v1/sprites/{id}` | Destroy a sprite. Subsequent operations return `404`. |
| GET | `/v1/sprites/{id}` | Inspect a sprite: `{id, status, url, fs, checkpoints}` (checkpoints as `[{id, comment}]`). |
| GET | `/v1/sprites/{id}` | Inspect a sprite: `{id, status, url, fs, checkpoints}` (checkpoints as `[{id, comment, create_time, is_auto}]`). |
| GET | `/_spritzer/health` | Version and coverage report (spritzer-only). |

Checkpoints are addressed by a server-assigned version id, not a caller label.
The caller supplies only an optional `comment`; the store assigns `v1`, `v2`, …
in creation order per sprite. A compensation workflow can therefore use the
in creation order per sprite, stamping a `create_time` and an `is_auto` flag
(false for manual checkpoints). A compensation workflow can therefore use the
`comment` as a stable handle — list the checkpoints and restore the newest one
whose comment matches — while restore itself always takes an explicit id in the
path.
path. Create and restore reply with streaming NDJSON progress
(`application/x-ndjson`): one or more `{"event":"info",...}` lines then a
terminal `{"event":"complete","id":"v<N>"}`.

## The exec control WebSocket

`exec` is a control WebSocket at `GET /v1/sprites/{id}/exec`, matching the real
Sprites SDK (`superfly/sprites-go`, websocket.go). The command is reconstructed
from the query string: each repeated `cmd` param is one argv element (joined with
spaces), or a single `cmd` param is taken as the whole command line; `path`
(argv[0]) is the fallback when no `cmd` is present. `stdin=false` skips stdin
draining.

Non-PTY framing: every WebSocket message is a binary frame whose first byte is a
stream id and whose remaining bytes are the payload.

| Stream | Id | Direction | Payload |
| --- | --- | --- | --- |
| StreamStdin | 0 | client → server | stdin bytes |
| StreamStdout | 1 | server → client | stdout bytes |
| StreamStderr | 2 | server → client | stderr bytes |
| StreamExit | 3 | server → client | one byte: the exit code |
| StreamStdinEOF | 4 | client → server | end of stdin |

The server runs the exec interpreter, writes stdout as `[1]<bytes>`, stderr as
`[2]<bytes>`, then a final `[3]<exitCodeByte>` and closes the connection. The
handshake response advertises `sprite-capabilities: control-ws`. A client with no
stdin passes `stdin=false` (or sends a single `[4]` frame); the interpreter does
not read stdin, so any stdin frames are drained and discarded.

## The exec interpreter

Expand All @@ -46,8 +76,10 @@ small set of forms:

## Wire fidelity

spritzer is wire-compatible with chant's in-process Sprites fake
(`sprites-fake.ts`). The JSON field names — `id`, `url`, the checkpoint `id`,
`stdout`/`stderr`/`exitCode`, and the `GET` shape's `fs` and `checkpoints` — and
the exec interpreter's behavior match it exactly, so chant's integration suite
passes against the spritzer container image unchanged.
spritzer mirrors the real Sprites API surface reverse-engineered from
`superfly/sprites-go`: the control-WebSocket exec framing (`[streamID][payload]`
with StreamStdin/Stdout/Stderr/Exit/StdinEOF), the singular NDJSON checkpoint
create, and the bare-array checkpoint list with `create_time` and `is_auto`. The
exec interpreter's behavior still matches chant's in-process Sprites fake
(`sprites-fake.ts`) so a client's checkpoint-as-compensation logic can be
exercised end to end offline.
34 changes: 21 additions & 13 deletions docs/fidelity.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,30 @@ spritzer creates sprites `running`, and a restore returns a sprite to `running`.

## The filesystem and exec

A sprite's filesystem is a `path -> contents` map. `exec` runs a small scripted
interpreter (not a real shell) so a test can write a key, then overwrite or fail
it, and prove that a later restore rewinds. See
[API coverage](api-coverage.md#the-exec-interpreter) for the recognized forms.
Segments split on `;` run in order and the last segment's exit code wins,
matching shell `;` semantics.
A sprite's filesystem is a `path -> contents` map. `exec` is a control
WebSocket at `GET /v1/sprites/{id}/exec` that speaks the real Sprites SDK's
framed protocol (`[streamID][payload]`; see
[the exec control WebSocket](api-coverage.md#the-exec-control-websocket)). Behind
the frames it runs a small scripted interpreter (not a real shell) so a test can
write a key, then overwrite or fail it, and prove that a later restore rewinds.
See [API coverage](api-coverage.md#the-exec-interpreter) for the recognized
forms. Segments split on `;` run in order and the last segment's exit code wins,
matching shell `;` semantics. The framing is faithful; the command execution
behind it is a deliberate limitation (a scripted interpreter, not a sandbox).

## Checkpoint and restore

A checkpoint deep-copies the filesystem under a server-assigned version id
(`v1`, `v2`, …, one past the current count); the caller supplies only an
optional comment. A restore addresses a checkpoint by its id in the path,
replaces the filesystem with that copy, and returns the sprite to `running`;
restoring an unknown id is a `404`. `GET .../checkpoints` lists the checkpoints
as `{id, comment}` in creation order, so a compensation workflow can use the
comment as a stable handle and restore the newest matching one. Because the
Create is `POST /v1/sprites/{id}/checkpoint` (singular) and streams NDJSON
progress, ending in `{"event":"complete","id":"v<N>"}`. A checkpoint deep-copies
the filesystem under a server-assigned version id (`v1`, `v2`, …, one past the
current count), stamping a `create_time` and an `is_auto` flag (false for manual
checkpoints); the caller supplies only an optional comment. A restore addresses a
checkpoint by its id in the path, streams NDJSON progress, replaces the
filesystem with that copy, and returns the sprite to `running`; restoring an
unknown id is a `404` before the stream starts. `GET .../checkpoints` lists the
checkpoints as a bare array of `{id, comment, create_time, is_auto}` in creation
order, so a compensation workflow can use the comment as a stable handle and
restore the newest matching one. Because the
checkpoint is a deep copy, mutating the filesystem after a checkpoint does not
change what a later restore rewinds to — this is the checkpoint-as-compensation
guarantee a guarded workflow relies on.
Expand Down
19 changes: 14 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,25 @@ BASE=http://localhost:4290
# Create a sprite (its name is its id).
curl -s -X POST "$BASE/v1/sprites" -d '{"name":"demo"}'

# Seed state, checkpoint it (the server assigns id v1), then corrupt it and fail.
curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"echo good > /state"}'
curl -s -X POST "$BASE/v1/sprites/demo/checkpoints" -d '{"comment":"pre-run"}'
curl -s -X POST "$BASE/v1/sprites/demo/exec" -d '{"cmd":"./risky.sh"}'
# Checkpoint the current state; the server assigns id v1 and streams NDJSON
# progress ending in {"event":"complete","id":"v1"}.
curl -s -X POST "$BASE/v1/sprites/demo/checkpoint" -d '{"comment":"pre-run"}'

# Restore rewinds the filesystem to the checkpoint, by id in the path.
# List the checkpoints as a bare array.
curl -s "$BASE/v1/sprites/demo/checkpoints" | jq

# Restore rewinds the filesystem to the checkpoint, by id in the path (NDJSON).
curl -s -X POST "$BASE/v1/sprites/demo/checkpoints/v1/restore"
curl -s "$BASE/v1/sprites/demo" | jq '{id, status, fs, checkpoints}'
```

`exec` is a control WebSocket, so it is not a plain `curl` call. Connect
`ws://localhost:4290/v1/sprites/demo/exec?cmd=<command>` and read the binary
`[streamID][payload]` frames — the server writes stdout as `[1]<bytes>`, stderr
as `[2]<bytes>`, then a final `[3]<exitCodeByte>`. See the
[API coverage](api-coverage.md#the-exec-control-websocket) for the frame
protocol.

## Check what is implemented

```sh
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
module github.com/intentius/spritzer

go 1.25

require github.com/coder/websocket v1.8.15
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
Loading