98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
205 lines
8.9 KiB
Markdown
205 lines
8.9 KiB
Markdown
# Protocol
|
|
|
|
This document describes the Happy wire protocol as implemented in `packages/happy-server`. The protocol is intentionally small: JSON over HTTP for reads/actions and Socket.IO for real-time sync. Most payloads are end-to-end encrypted client-side; see `encryption.md` for the encryption boundaries and encoding details. For the full HTTP surface and auth flows, see `api.md`.
|
|
|
|
## Transport and versioning
|
|
- HTTP API: JSON requests/responses on `/v1` and `/v2` routes.
|
|
- WebSocket: Socket.IO server at path `/v1/updates` (transports: websocket, polling).
|
|
- CORS: `*` (server-side).
|
|
|
|
## Protocol design motivations
|
|
The protocol is designed to stay minimal, explicit, and resilient under intermittent connectivity. A few guiding principles shape naming, payloads, and versioning:
|
|
|
|
- **Small surface area over completeness.** Routes and events exist only when they provide a clear sync primitive (e.g., sessions, artifacts, KV). If a capability can be expressed as data within an existing primitive, it should be.
|
|
- **Explicit event types and short keys.** Update payloads use `t` for the event type and concise field names (`sid`, `id`, `seq`) to keep message size down without hiding meaning. These names are stable because they are used across clients.
|
|
- **Separation of persistent vs. ephemeral.** Anything that must be recoverable after reconnect is an `update` event with a sequence number. Presence and usage are `ephemeral` to avoid state confusion and minimize storage.
|
|
- **Monotonic ordering at the user level.** `UpdatePayload.seq` is a single per-user counter. This makes client reconciliation simple: apply updates in order and you are consistent for that user.
|
|
- **Optimistic concurrency by default.** Versioned fields (metadata, agent state, artifact parts, access keys, KV) require `expectedVersion`. This prevents silent overwrites and keeps conflict resolution client-driven.
|
|
- **Client-side encryption boundaries.** The server never needs to understand plaintext. The protocol therefore treats most payloads as opaque strings or base64 blobs, which keeps server logic simple and privacy guarantees strong.
|
|
- **Backward compatibility over breaking changes.** New routes/events are added rather than mutating existing shapes in incompatible ways. When dual behavior is needed (e.g., machines), the server emits both old and new updates.
|
|
- **Avoid full REST verbs.** Reads are primarily `GET`, while writes/actions are primarily `POST`, with `DELETE` used when the intent is unambiguous. We avoid the full REST palette because many mutations are not cleanly tied to a single entity or involve more than CRUD logic. Keeping to `GET` + `POST` (plus occasional `DELETE`) makes the client simpler and the protocol clearer.
|
|
|
|
If a new protocol field or event is proposed, it should answer: does this create a durable sync primitive, or can it be encoded inside existing encrypted payloads without expanding the API surface?
|
|
|
|
## Authentication
|
|
Most endpoints require `Authorization: Bearer <token>`. The same token is also used in the Socket.IO handshake. Full auth flows and endpoints are documented in `api.md`.
|
|
|
|
## WebSocket connection
|
|
### Handshake
|
|
Connect with Socket.IO using:
|
|
|
|
```
|
|
path: "/v1/updates"
|
|
auth: {
|
|
token: "<bearer token>",
|
|
clientType: "user-scoped" | "session-scoped" | "machine-scoped",
|
|
sessionId?: "<session id>",
|
|
machineId?: "<machine id>"
|
|
}
|
|
```
|
|
|
|
Rules enforced server-side:
|
|
- `token` is required.
|
|
- `session-scoped` requires `sessionId`.
|
|
- `machine-scoped` requires `machineId`.
|
|
|
|
### Connection types
|
|
- `user-scoped`: receives account-wide updates.
|
|
- `session-scoped`: receives updates for a specific session only.
|
|
- `machine-scoped`: used by daemons; receives machine updates and emits machine state.
|
|
|
|
### Server -> client events
|
|
The server emits two event types:
|
|
|
|
#### `update`
|
|
Persistent sync events. Payload shape:
|
|
```
|
|
{
|
|
id: string,
|
|
seq: number,
|
|
body: { t: string, ... },
|
|
createdAt: number
|
|
}
|
|
```
|
|
|
|
#### `ephemeral`
|
|
Transient presence/usage events. Payload shape:
|
|
```
|
|
{
|
|
type: string,
|
|
...
|
|
}
|
|
```
|
|
|
|
### Update event types
|
|
Field names below match on-wire payloads.
|
|
|
|
- `new-session`
|
|
- `body`: `{ t: "new-session", id, seq, metadata, metadataVersion, agentState, agentStateVersion, dataEncryptionKey, active, activeAt, createdAt, updatedAt }`
|
|
|
|
- `update-session`
|
|
- `body`: `{ t: "update-session", id, metadata?, agentState? }`
|
|
- `metadata`: `{ value, version }` or null
|
|
- `agentState`: `{ value, version }` or null
|
|
|
|
- `delete-session`
|
|
- `body`: `{ t: "delete-session", sid }`
|
|
|
|
- `new-message`
|
|
- `body`: `{ t: "new-message", sid, message: { id, seq, content, localId, createdAt, updatedAt } }`
|
|
|
|
- `update-account`
|
|
- `body`: `{ t: "update-account", id, settings?, github? }`
|
|
|
|
- `new-machine`
|
|
- `body`: `{ t: "new-machine", machineId, seq, metadata, metadataVersion, daemonState, daemonStateVersion, dataEncryptionKey, active, activeAt, createdAt, updatedAt }`
|
|
|
|
- `update-machine`
|
|
- `body`: `{ t: "update-machine", machineId, metadata?, daemonState?, activeAt? }`
|
|
|
|
- `new-artifact`
|
|
- `body`: `{ t: "new-artifact", artifactId, seq, header, headerVersion, body, bodyVersion, dataEncryptionKey, createdAt, updatedAt }`
|
|
|
|
- `update-artifact`
|
|
- `body`: `{ t: "update-artifact", artifactId, header?, body? }`
|
|
|
|
- `delete-artifact`
|
|
- `body`: `{ t: "delete-artifact", artifactId }`
|
|
|
|
- `relationship-updated`
|
|
- `body`: `{ t: "relationship-updated", uid, status, timestamp }`
|
|
|
|
- `new-feed-post`
|
|
- `body`: `{ t: "new-feed-post", id, body, cursor, createdAt }`
|
|
|
|
- `kv-batch-update`
|
|
- `body`: `{ t: "kv-batch-update", changes: [{ key, value, version }] }`
|
|
|
|
### Ephemeral event types
|
|
- `activity`: `{ type: "activity", id: sessionId, active, activeAt, thinking? }`
|
|
- `machine-activity`: `{ type: "machine-activity", id: machineId, active, activeAt }`
|
|
- `usage`: `{ type: "usage", id: sessionId, key, tokens, cost, timestamp }`
|
|
- `machine-status`: `{ type: "machine-status", machineId, online, timestamp }`
|
|
|
|
### Client -> server WebSocket events
|
|
- `ping` -> callback `{}`
|
|
|
|
- `update-metadata`
|
|
- `{ sid, metadata, expectedVersion }`
|
|
- Response: `{ result: "success", version, metadata }` or `{ result: "version-mismatch", version, metadata }`
|
|
|
|
- `update-state`
|
|
- `{ sid, agentState, expectedVersion }`
|
|
- Response: `{ result: "success", version, agentState }` or `{ result: "version-mismatch", version, agentState }`
|
|
|
|
- `message`
|
|
- `{ sid, message, localId? }`
|
|
- Creates a new session message (encrypted payload) and emits `new-message` update to other connections.
|
|
|
|
- `session-alive`
|
|
- `{ sid, time, thinking? }`
|
|
- Emits `ephemeral` activity to user-scoped connections.
|
|
|
|
- `session-end`
|
|
- `{ sid, time }`
|
|
- Marks session inactive and emits `ephemeral` activity.
|
|
|
|
- `usage-report`
|
|
- `{ key, sessionId?, tokens, cost }`
|
|
- Stores usage report and optionally emits `ephemeral` usage for the session.
|
|
|
|
- `machine-alive`
|
|
- `{ machineId, time }`
|
|
- Emits `ephemeral` machine-activity.
|
|
|
|
- `machine-update-metadata`
|
|
- `{ machineId, metadata, expectedVersion }`
|
|
- Response: `{ result: "success", version, metadata }` or `{ result: "version-mismatch", version, metadata }`
|
|
|
|
- `machine-update-state`
|
|
- `{ machineId, daemonState, expectedVersion }`
|
|
- Response: `{ result: "success", version, daemonState }` or `{ result: "version-mismatch", version, daemonState }`
|
|
|
|
- `artifact-read`
|
|
- `{ artifactId }`
|
|
- Response: `{ result: "success", artifact }` or `{ result: "error", message }`
|
|
|
|
- `artifact-create`
|
|
- `{ id, header, body, dataEncryptionKey }`
|
|
- Response: `{ result: "success", artifact }` or `{ result: "error", message }`
|
|
|
|
- `artifact-update`
|
|
- `{ artifactId, header?, body? }` where `header` and `body` include `data` + `expectedVersion`
|
|
- Response: `{ result: "success", header?, body? }` or `{ result: "version-mismatch", header?, body? }`
|
|
|
|
- `artifact-delete`
|
|
- `{ artifactId }`
|
|
- Response: `{ result: "success" }` or `{ result: "error", message }`
|
|
|
|
- `access-key-get`
|
|
- `{ sessionId, machineId }`
|
|
- Response: `{ ok: true, accessKey? }` or `{ ok: false, error }`
|
|
|
|
- `rpc-register`
|
|
- `{ method }` -> server emits `rpc-registered`
|
|
|
|
- `rpc-unregister`
|
|
- `{ method }` -> server emits `rpc-unregistered`
|
|
|
|
- `rpc-call`
|
|
- `{ method, params }` -> callback `{ ok, result? | error? }`
|
|
- Server forwards to the registered socket via `rpc-request` (ack-based).
|
|
|
|
## HTTP endpoints by area
|
|
See `api.md` for the full HTTP endpoint catalog and auth flows.
|
|
|
|
## Sequencing and concurrency
|
|
- `UpdatePayload.seq` is the per-user update sequence (monotonic) used for sync ordering.
|
|
- Sessions, machines, and artifacts have their own `seq` fields used by clients for ordering.
|
|
- Versioned fields (metadata, agentState, daemonState, artifact header/body, access keys, KV) use optimistic concurrency with `expectedVersion` and return a version-mismatch response containing the current version/data.
|
|
|
|
## Implementation references
|
|
- API routes: `packages/happy-server/sources/app/api/routes`
|
|
- Socket handlers: `packages/happy-server/sources/app/api/socket`
|
|
- Event routing: `packages/happy-server/sources/app/events/eventRouter.ts`
|