@agfpd/iapeer 0.4.62 → 0.4.63
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.
- package/docs/15-fleet-api.md +142 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +142 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/daemon/fleet.ts +654 -0
- package/src/daemon/index.ts +66 -14
- package/src/daemon/main.ts +6 -0
- package/src/storage/rotatelog.ts +0 -0
- package/src/tui/dashboard/data.ts +5 -21
- package/src/tui/dashboard/model.ts +12 -25
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 15 — Fleet API
|
|
2
|
+
|
|
3
|
+
The daemon's HTTP surface for **operator clients**: dashboards, menu-bar apps, scripts, future web gateways. It serves three things: a **snapshot** of the fleet state, a **live event stream**, and **commands** over the existing management verbs. Everything an `iapeer list`-style client needs — without parsing CLI output.
|
|
4
|
+
|
|
5
|
+
This is a **normative contract**: fleet clients (SwiftBar plugins, native apps, web pages) are written against this document, not against the daemon source. The MUST/MUST NOT rules in «Client obligations» below are load-bearing — a client that skips them will break on a future daemon update.
|
|
6
|
+
|
|
7
|
+
## Surface and transport
|
|
8
|
+
|
|
9
|
+
- Base path: **`/fleet/v1`**, plain HTTP + JSON (+ SSE for events). This is *not* MCP: MCP (`/mcp`) is the agent surface and deliberately carries a single tool; fleet clients are GUIs and scripts that consume HTTP natively.
|
|
10
|
+
- Served on **both** daemon listeners:
|
|
11
|
+
- the unix socket `~/.iapeer/state/iapeer/router.sock` — `curl --unix-socket ~/.iapeer/state/iapeer/router.sock http://iapeer/fleet/v1/snapshot`
|
|
12
|
+
- TCP loopback — `http://127.0.0.1:8765/fleet/v1/snapshot`
|
|
13
|
+
- **Discovery:** read `~/.iapeer/state/iapeer/router.json`. A daemon that serves this API advertises `"fleet": 1` there, next to the active `sock`/`tcp` addresses and the daemon `version`. Absent `fleet` key ⇒ pre-fleet daemon ⇒ the client must degrade (hide, or fall back to CLI).
|
|
14
|
+
- **Auth:** when the daemon is configured with a bearer token (`IAPEER_BEARER_TOKEN`), every fleet request must carry `Authorization: Bearer <token>` — the same gate as MCP, checked before dispatch. Without a configured token, the surface is open to the local user (same trust class as the CLI: loopback + same-uid socket).
|
|
15
|
+
- **No CORS headers, deliberately.** A browser page must not be able to drive loopback fleet commands cross-origin. A web gateway is a separate future decision with mandatory auth.
|
|
16
|
+
|
|
17
|
+
## Client obligations (normative)
|
|
18
|
+
|
|
19
|
+
1. **Unknown event kinds MUST be ignored.** The `ev` vocabulary grows without a version bump (e.g. future `approval-request` / `approval-resolved`). A client that throws on an unknown `ev` is broken by design.
|
|
20
|
+
2. **Unknown JSON fields MUST be ignored.** The snapshot and every response evolve **additively** within `/fleet/v1`: new fields appear, existing fields keep their meaning and type. Removals or type changes would be a `/fleet/v2`.
|
|
21
|
+
3. **Events are at-least-once.** After an SSE reconnect (and around log rotation) a client may see an event twice. The SSE `id:` is the event's epoch-ms timestamp — use it (plus field equality) for display-level dedup. There is no exactly-once replay cursor; on reconnect, re-fetch the snapshot and resume from live events.
|
|
22
|
+
4. **The snapshot is the state; events are change hints.** The intended loop: fetch `/snapshot` once, subscribe to `/events`, re-fetch `/snapshot` when an event arrives (cheap, coalesce bursts). Do not reconstruct fleet state from events alone.
|
|
23
|
+
5. **Ages are client-rendered.** `last_active_ms` is an epoch timestamp; render the ticking «4m ago» yourself instead of polling the snapshot per second.
|
|
24
|
+
|
|
25
|
+
## GET /fleet/v1/snapshot
|
|
26
|
+
|
|
27
|
+
The full fleet state. The peer rows are produced by the **same in-process function that renders `iapeer list`** — agreement with the CLI is by construction.
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"api": 1,
|
|
32
|
+
"version": "0.4.63",
|
|
33
|
+
"ts": "2026-07-05T18:00:00.000Z",
|
|
34
|
+
"host": {
|
|
35
|
+
"version": "0.4.63",
|
|
36
|
+
"pid": 12345,
|
|
37
|
+
"startedAt": "2026-07-05T09:00:00.000Z",
|
|
38
|
+
"uptimeSecs": 32400,
|
|
39
|
+
"memory": { "provider": "iapeer-memory", "version": "1.2.3", "heartbeatAgeSecs": 12 },
|
|
40
|
+
"voice": { "provider": "voice-connect", "version": "0.3.1", "heartbeatAgeSecs": null },
|
|
41
|
+
"fda": true
|
|
42
|
+
},
|
|
43
|
+
"peers": [
|
|
44
|
+
{
|
|
45
|
+
"personality": "boris",
|
|
46
|
+
"description": "Напарник Артура по мультиагентной системе",
|
|
47
|
+
"intelligence": "artificial",
|
|
48
|
+
"default_runtime": "claude",
|
|
49
|
+
"cwd": "/Users/me/.iapeer/peers/boris",
|
|
50
|
+
"runtimes": [
|
|
51
|
+
{ "runtime": "claude", "status": "live", "attached": true },
|
|
52
|
+
{ "runtime": "codex", "status": "asleep" }
|
|
53
|
+
],
|
|
54
|
+
"last_active_runtime": "claude",
|
|
55
|
+
"last_active_ms": 1751731100000,
|
|
56
|
+
"attached": true,
|
|
57
|
+
"launchd_managed": false,
|
|
58
|
+
"wake_policy": "warm"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Field notes:
|
|
65
|
+
|
|
66
|
+
- `runtimes[].status` — `live` (session up) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `○` / `✕`.
|
|
67
|
+
- `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
|
|
68
|
+
- `attached` — any runtime attached (peer-level convenience).
|
|
69
|
+
- `launchd_managed` — launchd owns this peer's lifecycle (H4): the daemon never wakes/reaps it, and wake/stop/start commands are guarded accordingly.
|
|
70
|
+
- `wake_policy` — `warm` (default: wake-on-message, idle-reap, resume) or `ephemeral` (stateless worker: serial task queue, die-after-reply).
|
|
71
|
+
- `queue_depth` — ephemeral peers only: pending tasks across the peer's serial queues.
|
|
72
|
+
- `host.memory` / `host.voice` — the provider slots (`null` = empty slot); `heartbeatAgeSecs: null` with a non-null slot = provider declared but its daemon isn't heartbeating.
|
|
73
|
+
- `host.fda` — Full Disk Access of the binary: `true` / `false` / `null` (undeterminable).
|
|
74
|
+
- The daemon answering **is** the health signal: there is no `healthy` boolean. A dashboard that cannot reach any advertised address should render the daemon as down (this is also why a purely API-fed dashboard is insufficient when the daemon itself is broken — the built-in TUI keeps its direct in-process read path for exactly that case).
|
|
75
|
+
- The peer's **LLM model is deliberately absent**: the effective model of a live session is not an observable registry fact (static launch pins exist, but reporting a pin as «the model» would lie under runtime auto-switching). If it ever becomes observable, it will appear as an additive field.
|
|
76
|
+
|
|
77
|
+
## GET /fleet/v1/peers/<personality>
|
|
78
|
+
|
|
79
|
+
One peer's card: the same object as the snapshot row, plus its recent history.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "api": 1, "version": "0.4.63", "ts": "…", "peer": { …snapshot row… },
|
|
83
|
+
"events": [ { "src": "lifecycle", "ev": "wake", "ts": "…", "personality": "boris", "mode": "resume" } ] }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`events` — the newest ≤50 events concerning this peer (matched on personality / `<runtime>-<personality>` identity fields), merged across the logs by timestamp, oldest first. `404` for an unregistered peer.
|
|
87
|
+
|
|
88
|
+
## GET /fleet/v1/events — SSE stream
|
|
89
|
+
|
|
90
|
+
`text/event-stream` of fleet events, sourced by tail-following the daemon's **durable logs** (`lifecycle.log`, `delivery.log`, `exits.log`). Those files are written by *every* participating process — the daemon, CLI verbs (attach/stop/wake), the pty supervisors — so the stream covers events an in-daemon bus would miss.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
GET /fleet/v1/events?replay=50
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `replay=N` (0…500, default 0) — emit the newest N historical events before going live.
|
|
97
|
+
- Each event:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
event: wake
|
|
101
|
+
id: 1751731100123
|
|
102
|
+
data: {"src":"lifecycle","ev":"wake","ts":"2026-07-05T18:00:00.123Z","personality":"boris","mode":"resume"}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`data` is the full parsed log line: `src` (`lifecycle` | `delivery` | `exits`) + `ev` + every logfmt field with quoted values decoded. `id` = epoch-ms of the event.
|
|
106
|
+
- A comment `: connected` marks the transition from replay to live; `: hb` comments every ~15 s keep the connection alive.
|
|
107
|
+
- Latency: live events surface within ~0.5 s (log-tail poll).
|
|
108
|
+
|
|
109
|
+
Current `ev` vocabulary (grows over time — see obligation 1): `wake`, `supervise` (with `action`: `reaped-idle` = parked, `reaped-ephemeral`, `dead`→fresh/resume classification, `eager-orphan-fresh`, `skipped-error`…), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (from `exits.log`, with `dead_status`/`dead_signal`).
|
|
110
|
+
|
|
111
|
+
What the headline states look like on the stream: a peer **waking** is `ev=wake`; a peer **parked to sleep** is `ev=supervise action=reaped-idle`; a **death** is `ev=session-exit` (immediately, from the supervisor) followed by the daemon's classification on its next pass.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
`POST /fleet/v1/peers/<personality>/<command>` with an optional JSON body `{ "runtime": "claude", "topic": "…" }` (both optional; `topic` is used by `wake` only). The commands are the **existing management verbs called in-process** — every guard (H4 launchd read-only, the foreign-plist fleet guard, the crash-loop guard, wake serialization) lives inside those verbs and cannot be bypassed via the API.
|
|
116
|
+
|
|
117
|
+
| Command | Verb semantics | Success |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `wake` | bring the peer up (no message; fresh-vs-resume decided by the daemon) | `200` + wake outcome, `502` + `status:"FAILED"` |
|
|
120
|
+
| `stop` | set the stop flag / bootout an always-on peer | `200` + `outcomes[]`, `502` if a bootout failed or a foreign plist was refused |
|
|
121
|
+
| `start` | clear the stop flag (warm peers become wakeable; no session is launched) / bootstrap always-on | `200` / `502` mirror of `stop` |
|
|
122
|
+
| `new` | **unconditional** fresh restart (the emergency lever) | `200` + `action:"fresh"` |
|
|
123
|
+
| `refresh` | lazy soft-reload: fresh on next natural wake, no kill | `200` + `outcomes[]` |
|
|
124
|
+
| `interrupt` | interrupt a stuck/raving turn (Escape into the live session) | `200` / `502` |
|
|
125
|
+
| `compact` | compact the resumable dialogue (honest failure when none) | `200` + `action:"compacted"` / `502` |
|
|
126
|
+
|
|
127
|
+
`POST /fleet/v1/send` — deliver a message through the same routing path as the `send_to_peer` tool (wake-on-miss, queueing, delivery log — identical semantics):
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "from": "nova" }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`.
|
|
134
|
+
|
|
135
|
+
Not part of the API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them), **approve** (no approval broker exists yet; it will arrive as new endpoints + `ev` kinds under this same contract).
|
|
136
|
+
|
|
137
|
+
Errors are always `{"error": "…"}` with an honest status: `400` malformed request, `401` bearer required, `404` unknown peer/endpoint, `405` wrong method, `502` the verb itself failed, `500` internal.
|
|
138
|
+
|
|
139
|
+
## Compatibility
|
|
140
|
+
|
|
141
|
+
- `/fleet/v1` evolves **additively**; `api: 1` in every snapshot. Breaking changes mean `/fleet/v2` served alongside.
|
|
142
|
+
- The capability marker in `router.json` (`fleet: 1`) is the feature-detect: clients must treat its absence as «no fleet API» rather than probing endpoints.
|
package/docs/README.md
CHANGED
|
@@ -76,6 +76,7 @@ The sections are layered: from "install and use" to "understand the architecture
|
|
|
76
76
|
- [12 — CLI](12-cli-reference.md) — all commands with flags
|
|
77
77
|
- [13 — Architecture and contract](13-architecture-and-contract.md) — for plugin and runtime-package developers
|
|
78
78
|
- [14 — Configuration and logs](14-configuration-and-logs.md) — environment variables, logs, diagnostics
|
|
79
|
+
- [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
|
|
79
80
|
|
|
80
81
|
## Status
|
|
81
82
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 15 — Fleet API
|
|
2
|
+
|
|
3
|
+
HTTP-поверхность демона для **операторских клиентов**: дашбордов, menu-bar-приложений, скриптов, будущих веб-гейтвеев. Отдаёт три вещи: **снапшот** состояния флота, **живой поток событий** и **команды** поверх существующих управляющих verb'ов. Всё, что нужно клиенту класса `iapeer list`, — без парсинга CLI-вывода.
|
|
4
|
+
|
|
5
|
+
Это **нормативный контракт**: клиенты fleet API (SwiftBar-плагины, нативные приложения, веб-страницы) пишутся по этому документу, а не по исходникам демона. Правила «Обязанности клиента» ниже — несущие: клиент, который их пропустит, сломается на будущем обновлении демона.
|
|
6
|
+
|
|
7
|
+
## Поверхность и транспорт
|
|
8
|
+
|
|
9
|
+
- Базовый путь: **`/fleet/v1`**, обычный HTTP + JSON (+ SSE для событий). Это *не* MCP: MCP (`/mcp`) — агентская поверхность и намеренно несёт единственный инструмент; fleet-клиенты — GUI и скрипты, потребляющие HTTP нативно.
|
|
10
|
+
- Обслуживается на **обоих** слушателях демона:
|
|
11
|
+
- unix-сокет `~/.iapeer/state/iapeer/router.sock` — `curl --unix-socket ~/.iapeer/state/iapeer/router.sock http://iapeer/fleet/v1/snapshot`
|
|
12
|
+
- TCP loopback — `http://127.0.0.1:8765/fleet/v1/snapshot`
|
|
13
|
+
- **Обнаружение:** читайте `~/.iapeer/state/iapeer/router.json`. Демон, отдающий этот API, объявляет там `"fleet": 1` — рядом с активными адресами `sock`/`tcp` и версией демона. Нет ключа `fleet` ⇒ демон без fleet API ⇒ клиент обязан деградировать (скрыться или уйти на CLI).
|
|
14
|
+
- **Аутентификация:** если демон настроен с bearer-токеном (`IAPEER_BEARER_TOKEN`), каждый fleet-запрос обязан нести `Authorization: Bearer <токен>` — тот же гейт, что у MCP, проверяется до диспатча. Без настроенного токена поверхность открыта локальному пользователю (тот же класс доверия, что CLI: loopback + same-uid сокет).
|
|
15
|
+
- **Без CORS-заголовков, намеренно.** Страница в браузере не должна уметь дёргать loopback-команды флота кросс-доменно. Веб-гейтвей — отдельное будущее решение с обязательной аутентификацией.
|
|
16
|
+
|
|
17
|
+
## Обязанности клиента (нормативно)
|
|
18
|
+
|
|
19
|
+
1. **Неизвестные виды событий ОБЯЗАНЫ игнорироваться.** Словарь `ev` растёт без смены версии (например, будущие `approval-request` / `approval-resolved`). Клиент, падающий на неизвестном `ev`, сломан по построению.
|
|
20
|
+
2. **Неизвестные JSON-поля ОБЯЗАНЫ игнорироваться.** Снапшот и каждый ответ развиваются **аддитивно** внутри `/fleet/v1`: новые поля появляются, существующие сохраняют смысл и тип. Удаление или смена типа означали бы `/fleet/v2`.
|
|
21
|
+
3. **События — at-least-once.** После SSE-переподключения (и вокруг ротации логов) клиент может увидеть событие дважды. SSE `id:` — epoch-ms события; используйте его (плюс равенство полей) для дедупликации на уровне отображения. Точного replay-курсора нет; после reconnect перечитайте снапшот и продолжайте с живых событий.
|
|
22
|
+
4. **Снапшот — состояние; события — сигналы изменений.** Целевой цикл: получить `/snapshot` один раз, подписаться на `/events`, перечитывать `/snapshot` по приходу события (дёшево; всплески коалесцируйте). Не восстанавливайте состояние флота из одних событий.
|
|
23
|
+
5. **Возрасты рендерит клиент.** `last_active_ms` — epoch-метка; тикающее «4m ago» рисуйте сами, а не поллингом снапшота каждую секунду.
|
|
24
|
+
|
|
25
|
+
## GET /fleet/v1/snapshot
|
|
26
|
+
|
|
27
|
+
Полное состояние флота. Строки пиров производит **та же in-process функция, что рендерит `iapeer list`** — совпадение с CLI обеспечено по построению.
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"api": 1,
|
|
32
|
+
"version": "0.4.63",
|
|
33
|
+
"ts": "2026-07-05T18:00:00.000Z",
|
|
34
|
+
"host": {
|
|
35
|
+
"version": "0.4.63",
|
|
36
|
+
"pid": 12345,
|
|
37
|
+
"startedAt": "2026-07-05T09:00:00.000Z",
|
|
38
|
+
"uptimeSecs": 32400,
|
|
39
|
+
"memory": { "provider": "iapeer-memory", "version": "1.2.3", "heartbeatAgeSecs": 12 },
|
|
40
|
+
"voice": { "provider": "voice-connect", "version": "0.3.1", "heartbeatAgeSecs": null },
|
|
41
|
+
"fda": true
|
|
42
|
+
},
|
|
43
|
+
"peers": [
|
|
44
|
+
{
|
|
45
|
+
"personality": "boris",
|
|
46
|
+
"description": "Напарник Артура по мультиагентной системе",
|
|
47
|
+
"intelligence": "artificial",
|
|
48
|
+
"default_runtime": "claude",
|
|
49
|
+
"cwd": "/Users/me/.iapeer/peers/boris",
|
|
50
|
+
"runtimes": [
|
|
51
|
+
{ "runtime": "claude", "status": "live", "attached": true },
|
|
52
|
+
{ "runtime": "codex", "status": "asleep" }
|
|
53
|
+
],
|
|
54
|
+
"last_active_runtime": "claude",
|
|
55
|
+
"last_active_ms": 1751731100000,
|
|
56
|
+
"attached": true,
|
|
57
|
+
"launchd_managed": false,
|
|
58
|
+
"wake_policy": "warm"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Заметки по полям:
|
|
65
|
+
|
|
66
|
+
- `runtimes[].status` — `live` (сессия поднята) · `asleep` (разбудится по требованию) · `stopped` (операторский стоп-флаг; демон будить не станет). Та же семантика глифов, что в CLI: `●` / `○` / `✕`.
|
|
67
|
+
- `runtimes[].attached` — присутствует (`true`) только когда к этой живой hosted-сессии подключён **человек-оператор**.
|
|
68
|
+
- `attached` — любой рантайм с подключённым человеком (удобство уровня пира).
|
|
69
|
+
- `launchd_managed` — жизненным циклом пира владеет launchd (H4): демон его не будит/не усыпляет, а команды wake/stop/start соответствующе защищены.
|
|
70
|
+
- `wake_policy` — `warm` (по умолчанию: пробуждение по сообщению, idle-reap, resume) или `ephemeral` (stateless-воркер: серийная очередь задач, die-after-reply).
|
|
71
|
+
- `queue_depth` — только у ephemeral-пиров: ожидающие задачи в серийных очередях пира.
|
|
72
|
+
- `host.memory` / `host.voice` — провайдерские слоты (`null` = слот пуст); `heartbeatAgeSecs: null` при непустом слоте = провайдер объявлен, но его демон не шлёт heartbeat.
|
|
73
|
+
- `host.fda` — Full Disk Access бинаря: `true` / `false` / `null` (неопределимо).
|
|
74
|
+
- Сам факт ответа демона **и есть** сигнал здоровья: булева `healthy` нет. Дашборд, не достучавшийся ни до одного объявленного адреса, обязан рисовать демона лежачим (поэтому же дашборд, питающийся только API, недостаточен, когда сломан сам демон — встроенный TUI сохраняет прямой in-process путь чтения ровно на этот случай).
|
|
75
|
+
- **LLM-модель пира отсутствует намеренно**: эффективная модель живой сессии — не наблюдаемый факт реестра (статические пины запуска существуют, но выдавать пин за «модель» — врать при рантаймном автопереключении). Станет наблюдаемой — появится аддитивным полем.
|
|
76
|
+
|
|
77
|
+
## GET /fleet/v1/peers/<personality>
|
|
78
|
+
|
|
79
|
+
Карточка одного пира: тот же объект, что строка снапшота, плюс его недавняя история.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "api": 1, "version": "0.4.63", "ts": "…", "peer": { …строка снапшота… },
|
|
83
|
+
"events": [ { "src": "lifecycle", "ev": "wake", "ts": "…", "personality": "boris", "mode": "resume" } ] }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`events` — новейшие ≤50 событий, касающихся пира (матч по personality / полям идентичности `<runtime>-<personality>`), слитые по времени из всех логов, старые первыми. `404` для незарегистрированного пира.
|
|
87
|
+
|
|
88
|
+
## GET /fleet/v1/events — SSE-поток
|
|
89
|
+
|
|
90
|
+
`text/event-stream` событий флота, источник — tail-follow **долговременных логов** демона (`lifecycle.log`, `delivery.log`, `exits.log`). Эти файлы пишут *все* участвующие процессы — демон, CLI-verb'ы (attach/stop/wake), pty-супервизоры, — поэтому поток покрывает события, которые внутридемонная шина бы пропустила.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
GET /fleet/v1/events?replay=50
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `replay=N` (0…500, по умолчанию 0) — выдать новейшие N исторических событий перед переходом в live.
|
|
97
|
+
- Каждое событие:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
event: wake
|
|
101
|
+
id: 1751731100123
|
|
102
|
+
data: {"src":"lifecycle","ev":"wake","ts":"2026-07-05T18:00:00.123Z","personality":"boris","mode":"resume"}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`data` — полностью разобранная строка лога: `src` (`lifecycle` | `delivery` | `exits`) + `ev` + все logfmt-поля с раскодированными кавычками. `id` = epoch-ms события.
|
|
106
|
+
- Комментарий `: connected` отмечает переход от replay к live; комментарии `: hb` каждые ~15 с держат соединение.
|
|
107
|
+
- Латентность: живые события всплывают в пределах ~0.5 с (поллинг хвоста лога).
|
|
108
|
+
|
|
109
|
+
Текущий словарь `ev` (растёт со временем — см. обязанность 1): `wake`, `supervise` (с `action`: `reaped-idle` = парковка, `reaped-ephemeral`, классификация `dead`→fresh/resume, `eager-orphan-fresh`, `skipped-error`…), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (из `exits.log`, с `dead_status`/`dead_signal`).
|
|
110
|
+
|
|
111
|
+
Как выглядят главные состояния на потоке: **пробуждение** пира — `ev=wake`; **парковка в сон** — `ev=supervise action=reaped-idle`; **смерть** — `ev=session-exit` (немедленно, от супервизора), затем классификация демона на его следующем проходе.
|
|
112
|
+
|
|
113
|
+
## Команды
|
|
114
|
+
|
|
115
|
+
`POST /fleet/v1/peers/<personality>/<команда>` с необязательным JSON-телом `{ "runtime": "claude", "topic": "…" }` (оба опциональны; `topic` использует только `wake`). Команды — это **существующие управляющие verb'ы, вызванные in-process**: каждый гвард (H4 read-only для launchd-пиров, fleet-guard чужих плистов, защита от crash-loop, сериализация пробуждений) живёт внутри самих verb'ов и через API не обходится.
|
|
116
|
+
|
|
117
|
+
| Команда | Семантика verb'а | Успех |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `wake` | поднять пира (без сообщения; fresh-vs-resume решает демон) | `200` + исход, `502` + `status:"FAILED"` |
|
|
120
|
+
| `stop` | поставить стоп-флаг / bootout always-on пира | `200` + `outcomes[]`, `502` при неудачном bootout или отказе на чужом плисте |
|
|
121
|
+
| `start` | снять стоп-флаг (warm-пиры становятся пробуждаемыми; сессия не запускается) / bootstrap always-on | `200` / `502`, зеркально `stop` |
|
|
122
|
+
| `new` | **безусловный** свежий рестарт (аварийный рычаг) | `200` + `action:"fresh"` |
|
|
123
|
+
| `refresh` | ленивая мягкая перезагрузка: fresh на следующем естественном пробуждении, без kill | `200` + `outcomes[]` |
|
|
124
|
+
| `interrupt` | прервать зависший/зациклившийся turn (Escape в живую сессию) | `200` / `502` |
|
|
125
|
+
| `compact` | компактифицировать резюмируемый диалог (честный отказ, когда его нет) | `200` + `action:"compacted"` / `502` |
|
|
126
|
+
|
|
127
|
+
`POST /fleet/v1/send` — доставить сообщение тем же маршрутом, что инструмент `send_to_peer` (wake-on-miss, очереди, delivery-лог — семантика идентична):
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "from": "nova" }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`.
|
|
134
|
+
|
|
135
|
+
Не входит в API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью), **approve** (брокера подтверждений ещё не существует; он придёт новыми эндпоинтами + видами `ev` в рамках этого же контракта).
|
|
136
|
+
|
|
137
|
+
Ошибки — всегда `{"error": "…"}` с честным статусом: `400` некорректный запрос, `401` требуется bearer, `404` неизвестный пир/эндпоинт, `405` не тот метод, `502` сам verb упал, `500` внутренняя.
|
|
138
|
+
|
|
139
|
+
## Совместимость
|
|
140
|
+
|
|
141
|
+
- `/fleet/v1` развивается **аддитивно**; `api: 1` в каждом снапшоте. Ломающие изменения означают `/fleet/v2`, поднятый рядом.
|
|
142
|
+
- Маркер capability в `router.json` (`fleet: 1`) — способ feature-detect: его отсутствие клиент обязан трактовать как «fleet API нет», а не прощупывать эндпоинты.
|
package/docs/ru/README.md
CHANGED
|
@@ -76,6 +76,7 @@ iapeer send assistant --message "привет, ты на связи?"
|
|
|
76
76
|
- [12 — CLI](12-cli-reference.md) — все команды с флагами
|
|
77
77
|
- [13 — Архитектура и контракт](13-архитектура-и-контракт.md) — для разработчиков плагинов и рантайм-пакетов
|
|
78
78
|
- [14 — Конфигурация и журналы](14-конфигурация-и-журналы.md) — переменные окружения, журналы, диагностика
|
|
79
|
+
- [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
|
|
79
80
|
|
|
80
81
|
## Статус
|
|
81
82
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// Fleet-API — the daemon's OPERATOR-CLIENT HTTP surface (Ф0 of iapeer-tray: one
|
|
2
|
+
// fleet-API in the daemon, many thin faces on top — SwiftBar / native tray / web
|
|
3
|
+
// gateway / telegram approval buttons). Plain HTTP+JSON + SSE, DELIBERATELY NOT an
|
|
4
|
+
// MCP tool: MCP is the AGENT surface and the contract keeps it minimal
|
|
5
|
+
// (send_to_peer only — an extra tool occupies every session's context window);
|
|
6
|
+
// fleet clients are GUIs/scripts that consume HTTP natively.
|
|
7
|
+
//
|
|
8
|
+
// Design pillars (spec accepted by the PM 05.07.2026; normative client contract in
|
|
9
|
+
// docs/15-fleet-api.md):
|
|
10
|
+
// • SNAPSHOT truth = the SAME in-process primitives `iapeer list` uses
|
|
11
|
+
// (cli.listPeers) — agreement with the CLI is by construction, not by parsing.
|
|
12
|
+
// • EVENTS = tail-follow of the THREE durable logs (lifecycle.log / delivery.log /
|
|
13
|
+
// exits.log under cfg.eventLogDir). Those logs are written by EVERY process
|
|
14
|
+
// (daemon + CLI attach/stop/wake + supervisor deaths) — an in-memory daemon bus
|
|
15
|
+
// would silently miss the CLI-originated events. At-least-once semantics;
|
|
16
|
+
// clients MUST ignore unknown `ev` kinds (the approval-request growth seam).
|
|
17
|
+
// • COMMANDS = the EXISTING verb functions called in-process (stopPeer/startPeer/
|
|
18
|
+
// newPeer/refreshPeer/compactPeer/wakeOrSpawn/routeControl) — H4, the fleet
|
|
19
|
+
// guard and the crash-loop guard all live INSIDE those functions, so the API
|
|
20
|
+
// cannot bypass them. send routes through daemon.callTool — the SAME path as
|
|
21
|
+
// the MCP tool (delivery.log line + all post-delivery hooks included).
|
|
22
|
+
//
|
|
23
|
+
// Security class: unchanged. The endpoints listen on the SAME two listeners
|
|
24
|
+
// (0600 unix socket + TCP loopback) behind the SAME optional H8 bearer gate
|
|
25
|
+
// (checked in daemon/index.ts BEFORE dispatch); command power ≤ what the same-uid
|
|
26
|
+
// CLI already has. NO CORS headers — a browser page must NOT be able to drive
|
|
27
|
+
// loopback fleet commands cross-origin; the future web gateway is its own decision
|
|
28
|
+
// with bearer mandatory (see the frozen TCP open-auth decision + its triggers).
|
|
29
|
+
//
|
|
30
|
+
// Layering: this module is a COMPOSITION-SIDE sibling of daemon/main.ts (wired via
|
|
31
|
+
// StartDaemonOptions.fleet — the same injected-seam pattern as wake/supervise), so
|
|
32
|
+
// the daemon library stays lifecycle-free. The CLI verb functions are loaded via a
|
|
33
|
+
// LAZY dynamic import: cli/index.ts statically imports daemon/main.ts, so a static
|
|
34
|
+
// import back would create the repo's first import cycle.
|
|
35
|
+
|
|
36
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
37
|
+
import { closeSync, openSync, readSync, statSync } from 'fs'
|
|
38
|
+
import { buildProcessAddress } from '../core/socket.ts'
|
|
39
|
+
import { IAPEER_VERSION } from '../core/version.ts'
|
|
40
|
+
import {
|
|
41
|
+
ephemeralQueueDepth,
|
|
42
|
+
isEphemeralPeer,
|
|
43
|
+
isLaunchdManaged,
|
|
44
|
+
loadLifecycleConfig,
|
|
45
|
+
wakeOrSpawn,
|
|
46
|
+
type LifecycleConfig,
|
|
47
|
+
} from '../lifecycle/index.ts'
|
|
48
|
+
import { lifecycleLogPath } from '../lifecycle/eventlog.ts'
|
|
49
|
+
import { exitLogPath } from '../launch/index.ts'
|
|
50
|
+
import { hasAttachedSupervisorClient } from '../launch/ptyHost.ts'
|
|
51
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
52
|
+
import { resolveCallerIdentity, type ResolvedCaller } from '../identity/index.ts'
|
|
53
|
+
import { routeControl } from '../transport/index.ts'
|
|
54
|
+
import {
|
|
55
|
+
eventConcernsPeer,
|
|
56
|
+
parseEventLine,
|
|
57
|
+
readLogTail,
|
|
58
|
+
type ParsedEventLine,
|
|
59
|
+
} from '../storage/rotatelog.ts'
|
|
60
|
+
import { heartbeatAgeSecs, probeFullDiskAccess, readMemoryProvider, readVoiceProvider } from '../status/index.ts'
|
|
61
|
+
import { deliveryLogPath } from './deliverylog.ts'
|
|
62
|
+
import { callTool, type CallToolDeps } from './index.ts'
|
|
63
|
+
|
|
64
|
+
/** The API generation baked into every snapshot (additive evolution within v1 —
|
|
65
|
+
* see docs/15-fleet-api.md «Compatibility»). */
|
|
66
|
+
export const FLEET_API_VERSION = 1
|
|
67
|
+
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Snapshot
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export interface FleetRuntimeStatus {
|
|
73
|
+
runtime: string
|
|
74
|
+
status: 'live' | 'asleep' | 'stopped'
|
|
75
|
+
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
76
|
+
attached?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FleetPeer {
|
|
80
|
+
personality: string
|
|
81
|
+
description: string
|
|
82
|
+
intelligence: string
|
|
83
|
+
default_runtime: string
|
|
84
|
+
cwd: string
|
|
85
|
+
runtimes: FleetRuntimeStatus[]
|
|
86
|
+
last_active_runtime?: string
|
|
87
|
+
last_active_ms?: number
|
|
88
|
+
/** Any runtime session with a human attached. */
|
|
89
|
+
attached: boolean
|
|
90
|
+
/** H4 — launchd owns this peer's lifecycle; the daemon (and the wake/stop/start
|
|
91
|
+
* commands) treat it read-only / guarded. */
|
|
92
|
+
launchd_managed: boolean
|
|
93
|
+
wake_policy: 'warm' | 'ephemeral'
|
|
94
|
+
/** Ephemeral peers only: pending serial-queue tasks across declared runtimes. */
|
|
95
|
+
queue_depth?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FleetHost {
|
|
99
|
+
version: string
|
|
100
|
+
pid: number
|
|
101
|
+
startedAt: string
|
|
102
|
+
uptimeSecs: number
|
|
103
|
+
memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
104
|
+
voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
105
|
+
fda: boolean | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FleetSnapshot {
|
|
109
|
+
api: typeof FLEET_API_VERSION
|
|
110
|
+
version: string
|
|
111
|
+
ts: string
|
|
112
|
+
host: FleetHost
|
|
113
|
+
peers: FleetPeer[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The CLI op functions, injectable for tests. Defaults LAZY-load cli/index.ts at
|
|
117
|
+
// call time (see the layering note in the module header).
|
|
118
|
+
export interface FleetOps {
|
|
119
|
+
listPeers: (opts: { env?: NodeJS.ProcessEnv }) => import('../cli/index.ts').PeerListing[]
|
|
120
|
+
stopPeer: typeof import('../cli/index.ts').stopPeer
|
|
121
|
+
startPeer: typeof import('../cli/index.ts').startPeer
|
|
122
|
+
refreshPeer: typeof import('../cli/index.ts').refreshPeer
|
|
123
|
+
newPeer: typeof import('../cli/index.ts').newPeer
|
|
124
|
+
compactPeer: typeof import('../cli/index.ts').compactPeer
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function defaultOps(): Promise<FleetOps> {
|
|
128
|
+
const cli = await import('../cli/index.ts')
|
|
129
|
+
return {
|
|
130
|
+
listPeers: cli.listPeers,
|
|
131
|
+
stopPeer: cli.stopPeer,
|
|
132
|
+
startPeer: cli.startPeer,
|
|
133
|
+
refreshPeer: cli.refreshPeer,
|
|
134
|
+
newPeer: cli.newPeer,
|
|
135
|
+
compactPeer: cli.compactPeer,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Assemble the full fleet snapshot. Peer rows come from the SAME listPeers the CLI
|
|
140
|
+
* `list` verb renders — agreement with `iapeer list` is by construction. On top:
|
|
141
|
+
* the occupancy/ownership facts the registry row alone does not carry (attached
|
|
142
|
+
* human, H4 launchd ownership, wake_policy, ephemeral queue depth). */
|
|
143
|
+
export function buildFleetSnapshot(
|
|
144
|
+
env: NodeJS.ProcessEnv,
|
|
145
|
+
cfg: LifecycleConfig,
|
|
146
|
+
ops: Pick<FleetOps, 'listPeers'>,
|
|
147
|
+
startedAtMs: number,
|
|
148
|
+
nowMs: number = Date.now(),
|
|
149
|
+
): FleetSnapshot {
|
|
150
|
+
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
151
|
+
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
152
|
+
const attached =
|
|
153
|
+
s.status === 'live' && hasAttachedSupervisorClient(buildProcessAddress(s.runtime, r.personality), env)
|
|
154
|
+
return { runtime: s.runtime, status: s.status, ...(attached ? { attached: true } : {}) }
|
|
155
|
+
})
|
|
156
|
+
let ephemeral = false
|
|
157
|
+
try {
|
|
158
|
+
ephemeral = isEphemeralPeer(r.cwd)
|
|
159
|
+
} catch {
|
|
160
|
+
/* unreadable profile → treated as warm */
|
|
161
|
+
}
|
|
162
|
+
let queueDepth = 0
|
|
163
|
+
if (ephemeral) {
|
|
164
|
+
for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
personality: r.personality,
|
|
168
|
+
description: r.description,
|
|
169
|
+
intelligence: r.intelligence,
|
|
170
|
+
default_runtime: r.default_runtime,
|
|
171
|
+
cwd: r.cwd,
|
|
172
|
+
runtimes,
|
|
173
|
+
...(r.last_active_runtime ? { last_active_runtime: r.last_active_runtime } : {}),
|
|
174
|
+
...(r.last_active_ms !== undefined ? { last_active_ms: r.last_active_ms } : {}),
|
|
175
|
+
attached: runtimes.some(s => s.attached === true),
|
|
176
|
+
launchd_managed: isLaunchdManaged(r.personality, env),
|
|
177
|
+
wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
|
|
178
|
+
...(ephemeral ? { queue_depth: queueDepth } : {}),
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
const mem = readMemoryProvider(env)
|
|
182
|
+
const voice = readVoiceProvider(env)
|
|
183
|
+
return {
|
|
184
|
+
api: FLEET_API_VERSION,
|
|
185
|
+
version: IAPEER_VERSION,
|
|
186
|
+
ts: new Date(nowMs).toISOString(),
|
|
187
|
+
host: {
|
|
188
|
+
version: IAPEER_VERSION,
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
191
|
+
uptimeSecs: Math.max(0, Math.floor((nowMs - startedAtMs) / 1000)),
|
|
192
|
+
memory: mem
|
|
193
|
+
? { provider: mem.provider, version: mem.version, heartbeatAgeSecs: heartbeatAgeSecs(mem, nowMs) }
|
|
194
|
+
: null,
|
|
195
|
+
voice: voice
|
|
196
|
+
? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
|
|
197
|
+
: null,
|
|
198
|
+
fda: probeFullDiskAccess(env),
|
|
199
|
+
},
|
|
200
|
+
peers,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// Events — tail-follow of the durable logs → SSE
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export interface FleetEvent extends ParsedEventLine {
|
|
209
|
+
/** Which log the event came from: lifecycle | delivery | exits. */
|
|
210
|
+
src: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The three durable event logs under cfg.eventLogDir (exits.log shares the dir —
|
|
214
|
+
* lifecycle/index.ts passes exitLogDir: cfg.eventLogDir). */
|
|
215
|
+
export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src: string }> {
|
|
216
|
+
return [
|
|
217
|
+
{ path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
|
|
218
|
+
{ path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
|
|
219
|
+
{ path: exitLogPath(cfg.eventLogDir), src: 'exits' },
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The newest `limit` events across the given logs (merged by timestamp) — the SSE
|
|
224
|
+
* `replay=N` primer and the peer-detail recent-events reader. Reads only the last
|
|
225
|
+
* 64 KiB of each file. */
|
|
226
|
+
export function readRecentEvents(
|
|
227
|
+
files: Array<{ path: string; src: string }>,
|
|
228
|
+
limit: number,
|
|
229
|
+
concerning?: string,
|
|
230
|
+
): FleetEvent[] {
|
|
231
|
+
const events: FleetEvent[] = []
|
|
232
|
+
for (const f of files) {
|
|
233
|
+
for (const line of readLogTail(f.path).split('\n')) {
|
|
234
|
+
const e = parseEventLine(line)
|
|
235
|
+
if (!e) continue
|
|
236
|
+
if (concerning && !eventConcernsPeer(e, concerning)) continue
|
|
237
|
+
events.push({ ...e, src: f.src })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
events.sort((a, b) => a.tsMs - b.tsMs)
|
|
241
|
+
return events.slice(-limit)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface TailState {
|
|
245
|
+
path: string
|
|
246
|
+
src: string
|
|
247
|
+
offset: number
|
|
248
|
+
partial: string
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Shared poll-based tail-follower over the durable logs: ONE timer regardless of
|
|
253
|
+
* how many SSE subscribers are connected (starts with the first, stops with the
|
|
254
|
+
* last). Rotation-aware: a size SMALLER than the held offset means the base file
|
|
255
|
+
* was rotated away (base → .1) — reading restarts at 0 on the fresh file. Poll is
|
|
256
|
+
* deliberately chosen over fs.watch: rotation-robust, no platform edge cases, and
|
|
257
|
+
* a few-hundred-ms latency is invisible on a dashboard.
|
|
258
|
+
*/
|
|
259
|
+
export class FleetEventTailer {
|
|
260
|
+
private readonly files: TailState[]
|
|
261
|
+
private readonly subs = new Set<(e: FleetEvent) => void>()
|
|
262
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
263
|
+
constructor(
|
|
264
|
+
files: Array<{ path: string; src: string }>,
|
|
265
|
+
private readonly pollMs = 400,
|
|
266
|
+
private readonly statSize: (path: string) => number | null = defaultStatSize,
|
|
267
|
+
private readonly readFrom: (path: string, offset: number, length: number) => string | null = defaultReadFrom,
|
|
268
|
+
) {
|
|
269
|
+
this.files = files.map(f => ({ ...f, offset: -1, partial: '' }))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Subscribe to live events; returns the unsubscribe function. The FIRST
|
|
273
|
+
* subscriber primes every offset to the current EOF (history is the replay
|
|
274
|
+
* reader's job) and starts the poll timer; the last unsubscribe stops it. */
|
|
275
|
+
subscribe(fn: (e: FleetEvent) => void): () => void {
|
|
276
|
+
if (this.subs.size === 0) {
|
|
277
|
+
for (const f of this.files) {
|
|
278
|
+
f.offset = this.statSize(f.path) ?? 0
|
|
279
|
+
f.partial = ''
|
|
280
|
+
}
|
|
281
|
+
this.timer = setInterval(() => this.tick(), this.pollMs)
|
|
282
|
+
this.timer.unref?.()
|
|
283
|
+
}
|
|
284
|
+
this.subs.add(fn)
|
|
285
|
+
return () => {
|
|
286
|
+
this.subs.delete(fn)
|
|
287
|
+
if (this.subs.size === 0 && this.timer) {
|
|
288
|
+
clearInterval(this.timer)
|
|
289
|
+
this.timer = undefined
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One poll pass over every file — exposed for tests (deterministic, no timer). */
|
|
295
|
+
tick(): void {
|
|
296
|
+
for (const f of this.files) {
|
|
297
|
+
const size = this.statSize(f.path)
|
|
298
|
+
if (size === null) {
|
|
299
|
+
// file gone (rotation window / never created yet) — restart at 0 when it appears
|
|
300
|
+
f.offset = 0
|
|
301
|
+
f.partial = ''
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
if (size < f.offset) {
|
|
305
|
+
// rotated: base moved to .1, a fresh base is being written — restart
|
|
306
|
+
f.offset = 0
|
|
307
|
+
f.partial = ''
|
|
308
|
+
}
|
|
309
|
+
if (size === f.offset) continue
|
|
310
|
+
const chunk = this.readFrom(f.path, f.offset, size - f.offset)
|
|
311
|
+
if (chunk === null) continue // transient read failure — retry next tick
|
|
312
|
+
f.offset = size
|
|
313
|
+
const text = f.partial + chunk
|
|
314
|
+
const lines = text.split('\n')
|
|
315
|
+
f.partial = lines.pop() ?? '' // the trailing piece before the next newline
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
const e = parseEventLine(line)
|
|
318
|
+
if (!e) continue
|
|
319
|
+
for (const s of [...this.subs]) {
|
|
320
|
+
try {
|
|
321
|
+
s({ ...e, src: f.src })
|
|
322
|
+
} catch {
|
|
323
|
+
/* a subscriber must never break the tailer */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function defaultStatSize(path: string): number | null {
|
|
332
|
+
try {
|
|
333
|
+
return statSync(path).size
|
|
334
|
+
} catch {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function defaultReadFrom(path: string, offset: number, length: number): string | null {
|
|
340
|
+
try {
|
|
341
|
+
const fd = openSync(path, 'r')
|
|
342
|
+
try {
|
|
343
|
+
const buf = Buffer.alloc(length)
|
|
344
|
+
const n = readSync(fd, buf, 0, length, offset)
|
|
345
|
+
return buf.subarray(0, n).toString('utf8')
|
|
346
|
+
} finally {
|
|
347
|
+
closeSync(fd)
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
355
|
+
// HTTP handler
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
const JSON_HEADERS = { 'content-type': 'application/json' }
|
|
359
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
360
|
+
const SSE_HEARTBEAT_MS = 15_000
|
|
361
|
+
const MAX_REPLAY = 500
|
|
362
|
+
|
|
363
|
+
function sendJson(res: ServerResponse, status: number, value: unknown): void {
|
|
364
|
+
res.writeHead(status, JSON_HEADERS)
|
|
365
|
+
res.end(`${JSON.stringify(value)}\n`)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
369
|
+
return new Promise((resolve, reject) => {
|
|
370
|
+
const chunks: Buffer[] = []
|
|
371
|
+
let size = 0
|
|
372
|
+
req.on('data', (c: Buffer) => {
|
|
373
|
+
size += c.length
|
|
374
|
+
if (size > MAX_BODY_BYTES) {
|
|
375
|
+
reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`))
|
|
376
|
+
req.destroy()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
chunks.push(c)
|
|
380
|
+
})
|
|
381
|
+
req.on('end', () => {
|
|
382
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
383
|
+
if (!raw) {
|
|
384
|
+
resolve({})
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(raw) as unknown
|
|
389
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
390
|
+
reject(new Error('request body must be a JSON object'))
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
resolve(parsed as Record<string, unknown>)
|
|
394
|
+
} catch (e) {
|
|
395
|
+
reject(new Error(`invalid JSON body: ${e instanceof Error ? e.message : String(e)}`))
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
req.on('error', reject)
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Resolve the SENDER identity for POST /fleet/v1/send. Explicit `from` (either a
|
|
403
|
+
* full `<runtime>-<personality>` or a bare personality → its default runtime) wins;
|
|
404
|
+
* otherwise the SINGLE natural-intelligence peer of the registry (the host owner —
|
|
405
|
+
* a GUI-originated message is the human's). Ambiguity → an instructive error. */
|
|
406
|
+
export function resolveSendCaller(from: string | undefined, env: NodeJS.ProcessEnv): ResolvedCaller {
|
|
407
|
+
const index = readPeersIndex({ env })
|
|
408
|
+
if (from?.trim()) {
|
|
409
|
+
const raw = from.trim()
|
|
410
|
+
const byName = index.peers.find(p => p.personality === raw)
|
|
411
|
+
if (byName) return resolveCallerIdentity({ personality: byName.personality, runtime: byName.runtime }, index)
|
|
412
|
+
const dash = raw.indexOf('-')
|
|
413
|
+
if (dash > 0) {
|
|
414
|
+
return resolveCallerIdentity({ runtime: raw.slice(0, dash), personality: raw.slice(dash + 1) }, index)
|
|
415
|
+
}
|
|
416
|
+
throw new Error(`unknown from peer "${raw}"`)
|
|
417
|
+
}
|
|
418
|
+
const natural = index.peers.filter(p => p.intelligence === 'natural')
|
|
419
|
+
if (natural.length === 1) {
|
|
420
|
+
return resolveCallerIdentity({ personality: natural[0]!.personality, runtime: natural[0]!.runtime }, index)
|
|
421
|
+
}
|
|
422
|
+
throw new Error(
|
|
423
|
+
natural.length === 0
|
|
424
|
+
? 'no natural-intelligence peer registered — pass "from" explicitly'
|
|
425
|
+
: `ambiguous default sender (${natural.length} natural peers) — pass "from" explicitly`,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export interface FleetHandlerOptions {
|
|
430
|
+
env?: NodeJS.ProcessEnv
|
|
431
|
+
/** Injectable CLI ops (tests). Default: lazy dynamic import of cli/index.ts. */
|
|
432
|
+
ops?: FleetOps
|
|
433
|
+
/** Injectable tailer poll interval (tests). */
|
|
434
|
+
pollMs?: number
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export type FleetHandler = (
|
|
438
|
+
req: IncomingMessage,
|
|
439
|
+
res: ServerResponse,
|
|
440
|
+
ctx: { deps: CallToolDeps },
|
|
441
|
+
) => Promise<void>
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Build the /fleet/v1 request handler the composition point injects into
|
|
445
|
+
* startDaemon (StartDaemonOptions.fleet). One handler instance per daemon —
|
|
446
|
+
* it owns the shared event tailer and the daemon start timestamp.
|
|
447
|
+
*/
|
|
448
|
+
export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler {
|
|
449
|
+
const env = opts.env ?? process.env
|
|
450
|
+
const cfg = loadLifecycleConfig(env)
|
|
451
|
+
const startedAtMs = Date.now()
|
|
452
|
+
const files = fleetEventFiles(cfg)
|
|
453
|
+
const tailer = new FleetEventTailer(files, opts.pollMs)
|
|
454
|
+
let opsPromise: Promise<FleetOps> | undefined
|
|
455
|
+
const getOps = (): Promise<FleetOps> => {
|
|
456
|
+
if (opts.ops) return Promise.resolve(opts.ops)
|
|
457
|
+
opsPromise ??= defaultOps()
|
|
458
|
+
return opsPromise
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return async (req, res, ctx) => {
|
|
462
|
+
const url = new URL(req.url ?? '/', 'http://fleet.local')
|
|
463
|
+
const path = url.pathname
|
|
464
|
+
const method = (req.method ?? 'GET').toUpperCase()
|
|
465
|
+
try {
|
|
466
|
+
// ── reads ────────────────────────────────────────────────────────────
|
|
467
|
+
if (path === '/fleet/v1/snapshot') {
|
|
468
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/snapshot' })
|
|
469
|
+
return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs))
|
|
470
|
+
}
|
|
471
|
+
if (path === '/fleet/v1/events') {
|
|
472
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/events' })
|
|
473
|
+
serveEvents(req, res, url, tailer, files)
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
const peerMatch = /^\/fleet\/v1\/peers\/([^/]+)$/.exec(path)
|
|
477
|
+
if (peerMatch) {
|
|
478
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/peers/<peer>' })
|
|
479
|
+
const personality = decodeURIComponent(peerMatch[1]!)
|
|
480
|
+
const snapshot = buildFleetSnapshot(env, cfg, await getOps(), startedAtMs)
|
|
481
|
+
const peer = snapshot.peers.find(p => p.personality === personality)
|
|
482
|
+
if (!peer) return sendJson(res, 404, { error: `unknown peer "${personality}"` })
|
|
483
|
+
const events = readRecentEvents(files, 50, personality)
|
|
484
|
+
return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: snapshot.ts, peer, events })
|
|
485
|
+
}
|
|
486
|
+
// ── commands ─────────────────────────────────────────────────────────
|
|
487
|
+
const cmdMatch = /^\/fleet\/v1\/peers\/([^/]+)\/(wake|stop|start|new|refresh|interrupt|compact)$/.exec(path)
|
|
488
|
+
if (cmdMatch) {
|
|
489
|
+
if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST' })
|
|
490
|
+
const personality = decodeURIComponent(cmdMatch[1]!)
|
|
491
|
+
const command = cmdMatch[2]!
|
|
492
|
+
let body: Record<string, unknown>
|
|
493
|
+
try {
|
|
494
|
+
body = await readBody(req)
|
|
495
|
+
} catch (e) {
|
|
496
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
497
|
+
}
|
|
498
|
+
const runtime = typeof body.runtime === 'string' ? body.runtime : undefined
|
|
499
|
+
const topic = typeof body.topic === 'string' ? body.topic : undefined
|
|
500
|
+
return await runCommand(res, command, { personality, runtime, topic }, cfg, env, await getOps())
|
|
501
|
+
}
|
|
502
|
+
if (path === '/fleet/v1/send') {
|
|
503
|
+
if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/send' })
|
|
504
|
+
let body: Record<string, unknown>
|
|
505
|
+
try {
|
|
506
|
+
body = await readBody(req)
|
|
507
|
+
} catch (e) {
|
|
508
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
509
|
+
}
|
|
510
|
+
if (typeof body.personality !== 'string' || !body.personality || typeof body.message !== 'string' || !body.message) {
|
|
511
|
+
return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/from)' })
|
|
512
|
+
}
|
|
513
|
+
let caller: ResolvedCaller
|
|
514
|
+
try {
|
|
515
|
+
caller = resolveSendCaller(typeof body.from === 'string' ? body.from : undefined, env)
|
|
516
|
+
} catch (e) {
|
|
517
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
518
|
+
}
|
|
519
|
+
// The SAME path the MCP tool takes (routeSend + delivery.log + post-delivery
|
|
520
|
+
// hooks) — ctx.deps is the daemon's own wired CallToolDeps.
|
|
521
|
+
const result = await callTool(
|
|
522
|
+
caller,
|
|
523
|
+
'send_to_peer',
|
|
524
|
+
{
|
|
525
|
+
personality: body.personality,
|
|
526
|
+
runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
|
|
527
|
+
message: body.message,
|
|
528
|
+
topic: typeof body.topic === 'string' ? body.topic : undefined,
|
|
529
|
+
},
|
|
530
|
+
ctx.deps,
|
|
531
|
+
)
|
|
532
|
+
const text = result.content[0]?.text ?? ''
|
|
533
|
+
if (result.isError) return sendJson(res, 502, { error: text })
|
|
534
|
+
return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
|
|
535
|
+
}
|
|
536
|
+
return sendJson(res, 404, {
|
|
537
|
+
error: `unknown fleet endpoint ${method} ${path}`,
|
|
538
|
+
endpoints: [
|
|
539
|
+
'GET /fleet/v1/snapshot',
|
|
540
|
+
'GET /fleet/v1/peers/<peer>',
|
|
541
|
+
'GET /fleet/v1/events?replay=N',
|
|
542
|
+
'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
|
|
543
|
+
'POST /fleet/v1/send',
|
|
544
|
+
],
|
|
545
|
+
})
|
|
546
|
+
} catch (e) {
|
|
547
|
+
if (!res.headersSent) {
|
|
548
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) })
|
|
549
|
+
} else {
|
|
550
|
+
try {
|
|
551
|
+
res.end()
|
|
552
|
+
} catch {
|
|
553
|
+
/* connection already gone */
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function safeParse(text: string): unknown {
|
|
561
|
+
try {
|
|
562
|
+
return JSON.parse(text)
|
|
563
|
+
} catch {
|
|
564
|
+
return text
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function runCommand(
|
|
569
|
+
res: ServerResponse,
|
|
570
|
+
command: string,
|
|
571
|
+
args: { personality: string; runtime?: string; topic?: string },
|
|
572
|
+
cfg: LifecycleConfig,
|
|
573
|
+
env: NodeJS.ProcessEnv,
|
|
574
|
+
ops: FleetOps,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
const { personality, runtime, topic } = args
|
|
577
|
+
switch (command) {
|
|
578
|
+
case 'wake': {
|
|
579
|
+
// Same H4 / crash-loop / wake-lock semantics as a message wake — wakeOrSpawn
|
|
580
|
+
// owns them. task:'' = bare bring-up (the seed/initial prompt is the first turn).
|
|
581
|
+
const r = await wakeOrSpawn({ personality, runtime, topic, task: '' }, { cfg, env })
|
|
582
|
+
return sendJson(res, r.status === 'FAILED' ? 502 : 200, { command, ...r })
|
|
583
|
+
}
|
|
584
|
+
case 'stop': {
|
|
585
|
+
const outcomes = ops.stopPeer(personality, runtime, { env })
|
|
586
|
+
const failed = outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootout' && o.reason !== undefined))
|
|
587
|
+
return sendJson(res, failed ? 502 : 200, { command, outcomes })
|
|
588
|
+
}
|
|
589
|
+
case 'start': {
|
|
590
|
+
const outcomes = ops.startPeer(personality, runtime, { env })
|
|
591
|
+
const failed = outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootstrap' && o.reason !== undefined))
|
|
592
|
+
return sendJson(res, failed ? 502 : 200, { command, outcomes })
|
|
593
|
+
}
|
|
594
|
+
case 'new': {
|
|
595
|
+
const o = await ops.newPeer(personality, runtime, { env })
|
|
596
|
+
return sendJson(res, o.action === 'fresh' ? 200 : 502, { command, ...o })
|
|
597
|
+
}
|
|
598
|
+
case 'refresh': {
|
|
599
|
+
const outcomes = ops.refreshPeer(personality, runtime, { env })
|
|
600
|
+
return sendJson(res, 200, { command, outcomes })
|
|
601
|
+
}
|
|
602
|
+
case 'interrupt': {
|
|
603
|
+
const r = await routeControl(personality, runtime, { name: 'interrupt' })
|
|
604
|
+
if (!r.ok) return sendJson(res, 502, { command, error: r.error.message })
|
|
605
|
+
return sendJson(res, 200, { ...r.value, command }) // routeControl echoes its own `command` — ours (the endpoint name) wins
|
|
606
|
+
}
|
|
607
|
+
case 'compact': {
|
|
608
|
+
const o = await ops.compactPeer(personality, runtime, { env })
|
|
609
|
+
return sendJson(res, o.action === 'compacted' ? 200 : 502, { command, ...o })
|
|
610
|
+
}
|
|
611
|
+
default:
|
|
612
|
+
return sendJson(res, 404, { error: `unknown command "${command}"` })
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Serve the SSE event stream: optional replay of the newest N historical events,
|
|
617
|
+
* then live tail. At-least-once semantics (a reconnect may re-see events — `id`
|
|
618
|
+
* is the event's epoch-ms for client-side dedup); heartbeat comment every 15 s. */
|
|
619
|
+
function serveEvents(
|
|
620
|
+
req: IncomingMessage,
|
|
621
|
+
res: ServerResponse,
|
|
622
|
+
url: URL,
|
|
623
|
+
tailer: FleetEventTailer,
|
|
624
|
+
files: Array<{ path: string; src: string }>,
|
|
625
|
+
): void {
|
|
626
|
+
res.writeHead(200, {
|
|
627
|
+
'content-type': 'text/event-stream',
|
|
628
|
+
'cache-control': 'no-cache',
|
|
629
|
+
connection: 'keep-alive',
|
|
630
|
+
})
|
|
631
|
+
const writeEvent = (e: FleetEvent): void => {
|
|
632
|
+
const data = JSON.stringify({ src: e.src, ev: e.ev, ...e.fields })
|
|
633
|
+
res.write(`event: ${e.ev}\nid: ${e.tsMs}\ndata: ${data}\n\n`)
|
|
634
|
+
}
|
|
635
|
+
const replayRaw = parseInt(url.searchParams.get('replay') ?? '', 10)
|
|
636
|
+
const replay = Number.isFinite(replayRaw) ? Math.max(0, Math.min(MAX_REPLAY, replayRaw)) : 0
|
|
637
|
+
if (replay > 0) for (const e of readRecentEvents(files, replay)) writeEvent(e)
|
|
638
|
+
res.write(`: connected\n\n`)
|
|
639
|
+
const unsubscribe = tailer.subscribe(writeEvent)
|
|
640
|
+
const heartbeat = setInterval(() => {
|
|
641
|
+
try {
|
|
642
|
+
res.write(`: hb\n\n`)
|
|
643
|
+
} catch {
|
|
644
|
+
/* torn connection — close handler cleans up */
|
|
645
|
+
}
|
|
646
|
+
}, SSE_HEARTBEAT_MS)
|
|
647
|
+
heartbeat.unref?.()
|
|
648
|
+
const cleanup = (): void => {
|
|
649
|
+
clearInterval(heartbeat)
|
|
650
|
+
unsubscribe()
|
|
651
|
+
}
|
|
652
|
+
res.on('close', cleanup)
|
|
653
|
+
req.on('close', cleanup)
|
|
654
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -359,6 +359,23 @@ export interface DaemonHandle {
|
|
|
359
359
|
close: () => Promise<void>
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The fleet-API request handler seam (Ф0 iapeer-tray) — the OPERATOR-CLIENT HTTP
|
|
364
|
+
* surface (/fleet/v1/*: snapshot + SSE events + commands), served on the SAME
|
|
365
|
+
* listeners behind the SAME bearer gate as the MCP path. Injected by the
|
|
366
|
+
* composition point (daemon/main.ts wires daemon/fleet.ts's buildFleetHandler) so
|
|
367
|
+
* the daemon library never imports lifecycle/CLI — the same seam pattern as
|
|
368
|
+
* wake/supervise. `ctx.deps` is the daemon's wired CallToolDeps: the fleet send
|
|
369
|
+
* command routes through the SAME callTool path as the MCP tool (delivery.log +
|
|
370
|
+
* post-delivery hooks included). OFF by default (library/tests): without the seam,
|
|
371
|
+
* /fleet/* URLs fall through to the MCP handler like any other request.
|
|
372
|
+
*/
|
|
373
|
+
export type FleetRequestHandler = (
|
|
374
|
+
req: IncomingMessage,
|
|
375
|
+
res: ServerResponse,
|
|
376
|
+
ctx: { deps: CallToolDeps },
|
|
377
|
+
) => Promise<void>
|
|
378
|
+
|
|
362
379
|
export interface StartDaemonOptions extends StorageOptions {
|
|
363
380
|
/**
|
|
364
381
|
* Unix-socket path (H8 base: 0600, same-uid local callers — CLI / pane /
|
|
@@ -439,6 +456,12 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
439
456
|
/** H4 launchd-managed detector — see CallToolDeps.isLaunchdManaged / the launchd-revive
|
|
440
457
|
* delivery retry. OFF by default (library/tests); production main wires the real one. */
|
|
441
458
|
isLaunchdManaged?: (personality: string) => boolean
|
|
459
|
+
/**
|
|
460
|
+
* Fleet-API handler (see FleetRequestHandler) — serves /fleet/v1/* on both
|
|
461
|
+
* listeners, behind the bearer gate. OFF by default; the production main wires
|
|
462
|
+
* daemon/fleet.ts's buildFleetHandler. Advertised in router.json as `fleet: 1`.
|
|
463
|
+
*/
|
|
464
|
+
fleet?: FleetRequestHandler
|
|
442
465
|
/**
|
|
443
466
|
* Write the discovery file (router.json) at <root>/state/iapeer/router.json with
|
|
444
467
|
* the active addresses `{sock, tcp}` — atomically on listen, removed on close.
|
|
@@ -456,27 +479,51 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
456
479
|
const host = opts.host ?? '127.0.0.1'
|
|
457
480
|
const bearer = opts.bearerToken?.trim() || undefined
|
|
458
481
|
|
|
459
|
-
// ONE
|
|
482
|
+
// The ONE wired CallToolDeps object — shared by the per-request MCP servers AND
|
|
483
|
+
// the fleet-API handler (its send command routes through the SAME callTool path,
|
|
484
|
+
// so a fleet-originated send gets the identical delivery.log line + hooks).
|
|
485
|
+
const callDeps: CallToolDeps = {
|
|
486
|
+
env: opts.env, // Б7 — thread the daemon's storage env into the request path (registry read + routeSend)
|
|
487
|
+
wake: opts.wake,
|
|
488
|
+
deliveryLogDir: opts.deliveryLogDir,
|
|
489
|
+
onDelivered: opts.onDelivered,
|
|
490
|
+
ephemeral: opts.ephemeral,
|
|
491
|
+
noteLiveTopic: opts.noteLiveTopic,
|
|
492
|
+
noteDelivered: opts.noteDelivered,
|
|
493
|
+
composerQueue: opts.composerQueue,
|
|
494
|
+
isLaunchdManaged: opts.isLaunchdManaged,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ONE HTTP request handler, shared by EVERY listener (socket + TCP): the bearer
|
|
498
|
+
// gate first, then the fleet-API branch (when wired), then stateless MCP —
|
|
460
499
|
// a fresh Server + transport per request, fully isolated.
|
|
461
500
|
const handle = (req: IncomingMessage, res: ServerResponse): void => {
|
|
462
|
-
// H8 bearer layer (FIRST, before any MCP work): when a token is configured,
|
|
501
|
+
// H8 bearer layer (FIRST, before any MCP/fleet work): when a token is configured,
|
|
463
502
|
// reject anything not carrying `Authorization: Bearer <token>`. Off when unset.
|
|
464
503
|
if (bearer && req.headers.authorization !== `Bearer ${bearer}`) {
|
|
465
504
|
res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' })
|
|
466
505
|
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'unauthorized' } }))
|
|
467
506
|
return
|
|
468
507
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
508
|
+
// Fleet-API branch (Ф0 iapeer-tray): /fleet/* is the operator-client surface.
|
|
509
|
+
// Deliberately AFTER the bearer gate and BEFORE MCP — the two surfaces share
|
|
510
|
+
// listeners but never overlap in URL space.
|
|
511
|
+
if (opts.fleet && req.url && (req.url === '/fleet' || req.url.startsWith('/fleet/'))) {
|
|
512
|
+
void opts.fleet(req, res, { deps: callDeps }).catch(() => {
|
|
513
|
+
if (!res.headersSent) {
|
|
514
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
515
|
+
res.end(JSON.stringify({ error: 'internal error' }))
|
|
516
|
+
} else {
|
|
517
|
+
try {
|
|
518
|
+
res.end()
|
|
519
|
+
} catch {
|
|
520
|
+
/* connection already gone */
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
})
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
const server = createMcpServer(callDeps)
|
|
480
527
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
481
528
|
res.on('close', () => {
|
|
482
529
|
transport.close()
|
|
@@ -568,7 +615,12 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
568
615
|
// `version` (В53): the RUNNING daemon's baked version — lets `iapeer update`
|
|
569
616
|
// detect an interrupted update (binary already at latest, live daemon still on
|
|
570
617
|
// the old code) and heal it with a restart instead of a false "already-latest".
|
|
571
|
-
|
|
618
|
+
// `fleet` (Ф0 iapeer-tray): the fleet-API capability marker — a client reads it
|
|
619
|
+
// to know this daemon serves /fleet/v1/* (absent on pre-fleet daemons).
|
|
620
|
+
writeFileAtomic(
|
|
621
|
+
discoveryPath,
|
|
622
|
+
`${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null, version: IAPEER_VERSION, ...(opts.fleet ? { fleet: 1 } : {}) })}\n`,
|
|
623
|
+
)
|
|
572
624
|
}
|
|
573
625
|
|
|
574
626
|
const removeArtifacts = (): void => {
|
package/src/daemon/main.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
} from '../transport/index.ts'
|
|
67
67
|
import { iapeerBinPath } from '../install/index.ts'
|
|
68
68
|
import { appendDeliveryEvent } from './deliverylog.ts'
|
|
69
|
+
import { buildFleetHandler } from './fleet.ts'
|
|
69
70
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
70
71
|
|
|
71
72
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
@@ -362,6 +363,11 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
362
363
|
// launchd-managed target (the daemon can't wake it, but KeepAlive revives it) → retry-
|
|
363
364
|
// resolve for a bounded window instead of failing in ~16ms — bridges a router restart.
|
|
364
365
|
isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
|
|
366
|
+
// Fleet-API (Ф0 iapeer-tray): the operator-client surface /fleet/v1/* — snapshot
|
|
367
|
+
// (the same listPeers truth as `iapeer list`), SSE events (tail of the durable
|
|
368
|
+
// logs), commands over the existing verb functions. Same listeners, same bearer
|
|
369
|
+
// gate; advertised in router.json as fleet:1. Contract: docs/15-fleet-api.md.
|
|
370
|
+
fleet: buildFleetHandler({ env }),
|
|
365
371
|
supervise: {
|
|
366
372
|
intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
|
|
367
373
|
// idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
|
package/src/storage/rotatelog.ts
CHANGED
|
Binary file
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// All paths resolve through the injected env → the same IAPEER_ROOT isolation as
|
|
7
7
|
// every other side-effect (sandbox rule). Reads only — the dashboard mutates nothing.
|
|
8
8
|
|
|
9
|
-
import { closeSync, openSync, readSync, statSync } from 'node:fs'
|
|
10
9
|
import { join } from 'node:path'
|
|
11
10
|
import { listPeers, type PeerListing } from '../../cli/index.ts'
|
|
12
11
|
import { loadLifecycleConfig } from '../../lifecycle/index.ts'
|
|
13
12
|
import { deliveryLogPath } from '../../daemon/deliverylog.ts'
|
|
14
13
|
import { lifecycleLogPath } from '../../lifecycle/eventlog.ts'
|
|
14
|
+
import { readLogTail } from '../../storage/rotatelog.ts'
|
|
15
15
|
import { assemblePeerLog } from './model.ts'
|
|
16
16
|
|
|
17
17
|
/** One peers snapshot (the poll unit). listPeers is synchronous and cheap: registry
|
|
@@ -42,26 +42,10 @@ export async function takeHostHeader(env: NodeJS.ProcessEnv): Promise<HostHeader
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** Read the last `bytes` of a file without loading the whole log
|
|
46
|
-
*
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
const size = statSync(path).size
|
|
50
|
-
const fd = openSync(path, 'r')
|
|
51
|
-
try {
|
|
52
|
-
const start = Math.max(0, size - bytes)
|
|
53
|
-
const buf = Buffer.alloc(size - start)
|
|
54
|
-
readSync(fd, buf, 0, buf.length, start)
|
|
55
|
-
// drop the leading partial line when we started mid-file
|
|
56
|
-
const s = buf.toString('utf8')
|
|
57
|
-
return start > 0 ? s.slice(s.indexOf('\n') + 1) : s
|
|
58
|
-
} finally {
|
|
59
|
-
closeSync(fd)
|
|
60
|
-
}
|
|
61
|
-
} catch {
|
|
62
|
-
return ''
|
|
63
|
-
}
|
|
64
|
-
}
|
|
45
|
+
/** Read the last `bytes` of a file without loading the whole log. Promoted to the
|
|
46
|
+
* storage layer (readLogTail — the fleet-API replay reader shares it); kept as a
|
|
47
|
+
* re-export so dashboard call sites/tests are untouched. */
|
|
48
|
+
export const readTail: (path: string, bytes?: number) => string = readLogTail
|
|
65
49
|
|
|
66
50
|
/** The per-peer log panel content: newest `limit` events concerning the peer from
|
|
67
51
|
* delivery.log + lifecycle.log (merged by timestamp). */
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// under `bun test` with zero terminal plumbing.
|
|
9
9
|
|
|
10
10
|
import type { PeerListing } from '../../cli/index.ts'
|
|
11
|
+
import { eventConcernsPeer as concernsPeer, parseEventLine as parseLogfmtLine } from '../../storage/rotatelog.ts'
|
|
11
12
|
|
|
12
13
|
// ─── row filtering ───────────────────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -55,36 +56,22 @@ export interface LogEvent {
|
|
|
55
56
|
fields: Record<string, string>
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
/** Parse one `ts=<iso> ev=<kind> k=v …` line; null for anything else (corrupt tail).
|
|
59
|
+
/** Parse one `ts=<iso> ev=<kind> k=v …` line; null for anything else (corrupt tail).
|
|
60
|
+
* Delegates to the storage-layer QUOTE-AWARE parser (the writer's symmetric read
|
|
61
|
+
* half) and adds the display time — the earlier display-only tokenizer split quoted
|
|
62
|
+
* multi-word values (reason="idle 3700s") mid-value. */
|
|
59
63
|
export function parseEventLine(line: string): LogEvent | null {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for (const tok of line.split(' ')) {
|
|
64
|
-
const i = tok.indexOf('=')
|
|
65
|
-
if (i > 0) fields[tok.slice(0, i)] = tok.slice(i + 1)
|
|
66
|
-
}
|
|
67
|
-
const ts = fields.ts
|
|
68
|
-
const ev = fields.ev
|
|
69
|
-
if (!ts || !ev) return null
|
|
70
|
-
const tsMs = Date.parse(ts)
|
|
71
|
-
if (!Number.isFinite(tsMs)) return null
|
|
72
|
-
const d = new Date(tsMs)
|
|
64
|
+
const p = parseLogfmtLine(line)
|
|
65
|
+
if (!p) return null
|
|
66
|
+
const d = new Date(p.tsMs)
|
|
73
67
|
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
74
|
-
return { tsMs, time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, ev, fields }
|
|
68
|
+
return { tsMs: p.tsMs, time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, ev: p.ev, fields: p.fields }
|
|
75
69
|
}
|
|
76
70
|
|
|
77
71
|
/** Does this event concern the peer? Matches bare personality and any `<rt>-<personality>`
|
|
78
|
-
* identity in the fields the two logs actually carry (to/caller/via/personality/identity).
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const key of ['to', 'caller', 'via', 'personality', 'identity', 'from', 'peer'] as const) {
|
|
82
|
-
const v = e.fields[key]
|
|
83
|
-
if (!v) continue
|
|
84
|
-
if (v === personality || v.endsWith(idSuffix)) return true
|
|
85
|
-
}
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
72
|
+
* identity in the fields the two logs actually carry (to/caller/via/personality/identity).
|
|
73
|
+
* Re-exported from the storage layer (the fleet peer-detail endpoint shares it). */
|
|
74
|
+
export const eventConcernsPeer: (e: LogEvent, personality: string) => boolean = concernsPeer
|
|
88
75
|
|
|
89
76
|
/** One display line per event — compact, the interesting fields only. */
|
|
90
77
|
export function formatEvent(e: LogEvent): { text: string; tone: 'ok' | 'fail' | 'info' } {
|