Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Boolean env values: `1`, `true`, `yes`, or `on` (case-insensitive). CSV lists ar
| ---------- | --------- | ------- |
| `PORTER_STATE_DIR` | `~/.local/state/porter` | Runtime state, Pi sessions, cron logs |
| `PORTER_CONFIG_DIR` | `~/.config/porter` | Reserved for future config files |
| `PORTER_SOCKET` | `$XDG_RUNTIME_DIR/porter/porter.sock` | Control plane UNIX socket path (CLI only) |
| `DATABASE_URL` | — | PostgreSQL connection URL (required) |

## Agent
Expand Down
251 changes: 251 additions & 0 deletions docs/control-plane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# Control Plane

Porter exposes a REST API over a UNIX domain socket for runtime management:
scheduled task CRUD, daemon health, and worker pool observability. The socket
uses systemd socket activation in production and direct binding in development.

## Architecture

```
porter CLI ──fetch(unix)──> porter.sock ──> Bun.serve (ControlServer)
┌───────────┼───────────┐
│ │ │
SchedulerRegistry TaskStore WorkerPool
```

The control server runs inside the daemon process alongside channels, the
inbound/outbound workers, and the scheduler registry. No separate process, no
TCP port.

## Socket lifecycle

Systemd manages the socket directory via `RuntimeDirectory=porter-%i` in the
service unit. The daemon binds the socket itself -- systemd socket activation
(`Bun.serve({ fd })`) is not used because the feature never shipped in Bun
(see [oven-sh/bun#2852](https://github.com/oven-sh/bun/pull/2852)).

```ini
# porter@.service
Environment=PORTER_SOCKET=%t/porter-%i/porter.sock
RuntimeDirectory=porter-%i
RuntimeDirectoryMode=0700
```

Each template instance gets its own directory:

```
porter@projects-me-pi-porter → /run/user/1000/porter-projects-me-pi-porter/porter.sock
porter-dev → /run/user/1000/porter-dev/porter.sock
```

In development (`bun run`, no systemd):

```bash
bun run runtime/src/index.ts --serve
# Falls back to $XDG_RUNTIME_DIR/porter/porter.sock
# or ~/.local/state/porter/porter.sock
```

The daemon prefers `PORTER_SOCKET` from the environment; the CLI uses the
same variable with the same fallback.

## API Reference

All endpoints return JSON. Error responses include an `error` field.

### Health

```
GET /api/health
```

```json
{
"status": "ok",
"uptime": 12345.678,
"pid": 12345,
"workers": 2
}
```

### Scheduled Tasks

#### List all tasks

```
GET /api/scheduled-tasks
```

Returns an array of task objects including `paused` and `completed` tasks.

#### Get one task

```
GET /api/scheduled-tasks/:id
```

Returns a single task object, or `404`.

#### Create a task

```
POST /api/scheduled-tasks
Content-Type: application/json

{
"id": "morning-brief",
"name": "Morning Briefing",
"prompt": "Summarize today's calendar and weather.",
"agentSessionKey": "main:telegram:default:dm:123456",
"scheduleType": "cron",
"scheduleValue": "0 9 * * *",
"reportSessionKey": "main:telegram:default:dm:123456",
"workdir": null,
"preHook": null,
"postHook": null
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `id` | yes | Unique task identifier (slug) |
| `prompt` | yes | Prompt sent to the agent |
| `agentSessionKey` | yes | Session key for the agent run |
| `scheduleType` | yes | `cron`, `interval`, or `once` |
| `scheduleValue` | yes | Cron expression, milliseconds, or `"0"` for once |
| `name` | no | Human-readable label |
| `reportSessionKey` | no | Where to deliver results (defaults to agent session) |
| `workdir` | no | Working directory for the agent |
| `preHook` | no | Shell command run before the agent |
| `postHook` | no | Shell command run after the agent |

Session keys for `agentSessionKey` and `reportSessionKey` are auto-created if
they don't already exist.

Returns `201` with the created task object. Returns `409` if the id already
exists.

#### Delete a task

```
DELETE /api/scheduled-tasks/:id
```

Pauses the task (disarms the timer) then removes it from the database.
Returns `204`.

#### Pause a task

```
POST /api/scheduled-tasks/:id/pause
```

Disarms the timer. The task stays in the database with `status: "paused"`.
Returns `409` if already paused or completed.

```json
{ "id": "morning-brief", "status": "paused" }
```

#### Resume a task

```
POST /api/scheduled-tasks/:id/resume
```

Re-arms the timer via the scheduler registry. Returns `409` if not paused.

```json
{ "id": "morning-brief", "status": "active" }
```

#### Fire a task immediately

```
POST /api/scheduled-tasks/:id/fire
```

Publishes an inbound event for the task without waiting for the schedule.
Does not affect the existing timer. Returns `409` if the task is not active.

```json
{ "id": "morning-brief", "fired": true }
```

#### Get run history

```
GET /api/scheduled-tasks/:id/runs?limit=50
```

Returns recent run records (newest first), up to `limit` (default 50).

### Workers

```
GET /api/workers
```

```json
{
"count": 2,
"snapshot": [
{ "sessionKey": "main:telegram:default:dm:123456", "state": "ready" },
{ "sessionKey": "main:telegram:default:dm:789012", "state": "busy" }
]
}
```

Worker states: `booting` (initializing Pi session), `ready` (idle), `busy`
(handling a prompt).

## CLI

```bash
# Socket path is set by the systemd service unit; export manually for dev:
export PORTER_SOCKET=$XDG_RUNTIME_DIR/porter-projects-me-pi-porter/porter.sock

porter task list
porter task get morning-brief
porter task create --id nightly-report --cron "0 2 * * *" \
--prompt "Generate daily summary" \
--session-key "main:telegram:default:dm:123456"
porter task delete morning-brief
porter task pause morning-brief
porter task resume morning-brief
porter task fire morning-brief
porter task runs morning-brief
porter status
porter help
```

### Create flags

| Flag | Notes |
|------|-------|
| `--id <slug>` | Required |
| `--prompt <text>` | Required |
| `--session-key <key>` | Required |
| `--cron <expr>` | e.g. `"0 9 * * *"` |
| `--interval <ms>` | Milliseconds |
| `--once` | One-shot, fires immediately on create |
| `--name <name>` | Optional label |
| `--report-key <key>` | Where to deliver results |
| `--workdir <path>` | Agent working directory |
| `--pre-hook <cmd>` | Shell command before agent |
| `--post-hook <cmd>` | Shell command after agent |

One of `--cron`, `--interval`, or `--once` is required.

## curl

```bash
curl --unix-socket "$PORTER_SOCKET" http://localhost/api/health
curl --unix-socket "$PORTER_SOCKET" http://localhost/api/scheduled-tasks

curl --unix-socket "$PORTER_SOCKET" \
-X POST http://localhost/api/scheduled-tasks \
-H 'Content-Type: application/json' \
-d '{"id":"test","prompt":"hello","agentSessionKey":"main:telegram:default:dm:123456","scheduleType":"once","scheduleValue":"0"}'
```
3 changes: 3 additions & 0 deletions resources/systemd/porter-dev.service
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ EnvironmentFile=%h/workspace/.env
# Dev unit: run the locally built binary. PATH is for agent subprocesses (bun, tools), not ExecStart.
ExecStart=%h/.local/bin/porter --serve
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
Environment=PORTER_SOCKET=%t/porter-dev/porter.sock
RuntimeDirectory=porter-dev
RuntimeDirectoryMode=0700
Restart=on-failure
RestartSec=10s
RestartSteps=4
Expand Down
11 changes: 11 additions & 0 deletions resources/systemd/porter-dev.socket
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[Unit]
Description=Porter control socket (dev)

[Socket]
ListenStream=%t/porter/porter-dev.sock
SocketMode=0600
DirectoryMode=0700
RemoveOnStop=yes

[Install]
WantedBy=sockets.target
3 changes: 3 additions & 0 deletions resources/systemd/porter@.service
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ EnvironmentFile=%h/%i/.env
# Prod unit: packaged install at a fixed path. PATH only affects subprocesses, not ExecStart.
ExecStart=/usr/bin/porter --serve
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
Environment=PORTER_SOCKET=%t/porter-%i/porter.sock
RuntimeDirectory=porter-%i
RuntimeDirectoryMode=0700
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
Expand Down
21 changes: 21 additions & 0 deletions resources/systemd/porter@.socket
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Template socket unit paired with porter@.service.
#
# Enable alongside the matching service instance:
# systemctl --user enable 'porter@projects-me-pi-porter.socket'
# systemctl --user enable 'porter@projects-me-pi-porter.service'
#
# Systemd creates the socket before the service starts and passes it as fd 3.
# The daemon detects LISTEN_FDS and uses Bun.serve({ fd: 3 }).
# RemoveOnStop=yes ensures the socket file is cleaned up on deactivation.

[Unit]
Description=Porter control socket (%i)

[Socket]
ListenStream=%t/porter/porter-%i.sock
SocketMode=0600
DirectoryMode=0700
RemoveOnStop=yes

[Install]
WantedBy=sockets.target
4 changes: 3 additions & 1 deletion runtime/src/agent/pi-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { SessionWorkerPool } from './worker-pool.js';
export class PiAgentRunner implements AgentRunner {
private cwd: string;
private promptTimeoutMs: number;
private pool: SessionWorkerPool;
private locks: Map<string, Promise<void>> = new Map();

/** Exposed for control plane observability (/api/workers). */
readonly pool: SessionWorkerPool;

constructor(config: PorterConfig) {
this.cwd = process.cwd();
this.promptTimeoutMs = config.agentPromptTimeoutMs;
Expand Down
9 changes: 9 additions & 0 deletions runtime/src/agent/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ export class SessionWorkerPool {
return this.workers.size;
}

/** Read-only snapshot for observability (control plane /api/workers). */
snapshot(): Array<{ sessionKey: string; state: string }> {
const result: Array<{ sessionKey: string; state: string }> = [];
for (const [key, entry] of this.workers) {
result.push({ sessionKey: key, state: entry.state });
}
return result;
}

// ---- Internal ----

#spawn(key: string, cwd: string): WorkerEntry {
Expand Down
Loading