Skip to content

Commit 7c0b159

Browse files
committed
feat: unify Chronicle service operations
1 parent 7f1f6d7 commit 7c0b159

43 files changed

Lines changed: 3475 additions & 1142 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 46 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,25 @@ journalctl --user -u chronicle-service-manager
114114
journalctl --user -u chronicle-stack
115115
```
116116

117-
For an unmanaged node agent, inspect `edge/service-manager.log`. Container logs remain
118-
the next source for why a service exited. Direct commands such as
119-
`services.py restart`, `./restart.sh`, or raw Compose/Podman commands do **not** create
120-
service-operation ledger entries, so absence from the ledger does not prove a restart
121-
was automatic. Also check the relevant shell history or host journal when direct
122-
execution is plausible.
117+
For an unmanaged node agent, inspect `edge/service-manager.log`. Normal CLI and WebUI
118+
lifecycle commands report to the same ledger. Explicit `--direct` recovery, failed
119+
ledger reporting, and raw engine operations can leave no entry; absence does not
120+
prove an automatic restart.
121+
122+
## Service operations
123+
124+
For status, lifecycle, rebuilds, or unavailable-service reports, read
125+
[the service-management workflow](docs/init-system.md#service-management) first.
126+
Use `./services status --detailed`, then the CLI's `logs`, `inspect`, `doctor`, and
127+
`deployments --status` commands as appropriate. Start/stop/restart requires named
128+
groups or explicit `--all`; remote execution requires `--node` with a registered ID.
129+
LLM, wakeword, and TTS are managed groups. Normal actions wait for readiness and
130+
share WebUI operation tracking. Use explicit `--direct` only for local recovery
131+
without the manager; admission still applies.
132+
133+
Use raw engine commands for evidence the CLI does not expose, isolated development,
134+
unmanaged services, or a broken CLI. State the gap before going lower-level and
135+
redact secrets. After repairs, exercise the affected capability as well as readiness.
123136

124137
## Initial Setup & Configuration
125138

@@ -144,7 +157,7 @@ uv run --with-requirements setup-requirements.txt python wizard.py
144157
# For step-by-step instructions, see quickstart.md
145158
```
146159

147-
**Note on Convenience Scripts**: Chronicle provides wrapper scripts (`./wizard.sh`, `./start.sh`, `./restart.sh`, `./stop.sh`, `./status.sh`) that simplify the longer `uv run --with-requirements setup-requirements.txt python` commands. Use these for everyday operations.
160+
**Operator entry points**: Use `./wizard.sh` for configuration and `./services` for service management. See [service operations](#service-operations).
148161

149162
**Temporary Tooling Rule**: When a Python tool or dependency is needed only for a one-off task, run it ephemerally with `uv run --with <package> ...`; do not install it into a project environment or add it to project dependencies. For a package-owned CLI, use `uvx --from <package> <entrypoint>`. This applies to browser automation, screenshots, data inspection, and other temporary utilities.
150163

@@ -172,11 +185,12 @@ Key features:
172185

173186
### Backend Development (Chronicle Backend - Primary)
174187
```bash
175-
cd backend
188+
# From the repository root, start the managed backend
189+
./services start backend
176190

177-
# Start full stack with Docker
178-
docker compose up --build -d
191+
cd backend
179192

193+
# Standalone development alternative (use an isolated configuration)
180194
uv run python src/main.py
181195

182196
# Code formatting and linting
@@ -264,28 +278,21 @@ npm run web
264278
```
265279

266280
### Additional Services
267-
```bash
268-
# ASR Services
269-
cd extras/asr-services
270-
docker compose up parakeet-asr # Offline ASR with Parakeet
271281

272-
# Speaker Recognition (with tests)
273-
cd extras/speaker-recognition
274-
docker compose up --build
275-
./run-test.sh # Run speaker recognition integration tests
282+
Start configured service groups from the repository root:
276283

277-
# HAVPE Relay (ESP32 bridge)
278-
cd extras/havpe-relay
279-
docker compose up --build
280-
281-
# TTS Services (text-to-speech, run ONE provider at a time on port 8770)
282-
cd extras/tts
283-
docker compose up tada-tts -d --build # HumeAI TADA (GPU, voice cloning)
284-
docker compose up fish-tts -d --build # Fish Speech (GPU, 50+ langs, emotion tags)
285-
docker compose up kittentts-tts -d --build # KittenTTS (~25MB CPU ONNX, no GPU)
286-
docker compose up kokoro-tts -d --build # Kokoro-82M (<~1GB VRAM GPU/CPU, preset voices)
284+
```bash
285+
./services start asr-services
286+
./services start speaker-recognition
287+
./services start tts
287288
```
288289

290+
Choose the ASR/TTS provider through setup or the WebUI System controls. For builds,
291+
provider changes, and diagnostics, follow the
292+
[service-management workflow](docs/init-system.md#service-management).
293+
Speaker recognition integration tests remain in `extras/speaker-recognition/run-test.sh`.
294+
The HAVPE relay has its own lifecycle; see `extras/havpe-relay/` for its setup.
295+
289296
## Architecture Overview
290297

291298
### Unknown speaker invariant
@@ -605,9 +612,8 @@ Provider-based text-to-speech (`extras/tts/`), built on the same provider patter
605612
# Configure (selects provider, model, CUDA version) — from the repository root
606613
uv run --with-requirements setup-requirements.txt python extras/tts/init.py
607614
608-
# Start ONE provider
609-
cd extras/tts
610-
docker compose up tada-tts -d --build # or fish-tts / kittentts-tts
615+
# Start the configured provider through the managed TTS group
616+
./services start tts
611617
612618
# Test
613619
curl http://localhost:8770/health
@@ -625,7 +631,7 @@ curl -X POST http://localhost:8770/synthesize -F "text=Hello world." -o output.w
625631
**POST /synthesize** — `text` (required); optional `reference_audio` (WAV) + `reference_text` for voice cloning; optional generation params (`temperature`, `top_p`, `repetition_penalty`, `seed`, `max_new_tokens`). Returns WAV bytes with `X-Sample-Rate`, `X-Provider`, `X-Model` headers.
626632

627633
**Notes:**
628-
- Not registered in `services.py` — manage with `docker compose` directly (like the HAVPE relay).
634+
- Registered as `tts` in `services.py`; select the provider via setup or the WebUI System controls. See [service management](docs/init-system.md#service-management).
629635
- GPU providers require CUDA 12.6+ (`PYTORCH_CUDA_VERSION=cu126`/`cu128`); `cu121` is unsupported (torch>=2.7).
630636
- Add a provider by creating `extras/tts/providers/{name}/` with `service.py`, `synthesizer.py`, and `Dockerfile` (subclass `BaseTTSService`).
631637
- An optional `edge-agent` sidecar (`--profile edge`) advertises the service on the Tailnet.
@@ -636,22 +642,18 @@ curl -X POST http://localhost:8770/synthesize -F "text=Hello world." -o output.w
636642

637643
**Single Machine (Default):**
638644
```bash
639-
# Everything on one machine
640-
docker compose up --build -d
645+
# Start configured services assigned to this node
646+
./services start --all
641647
```
642648

643649
**Distributed Setup (GPU + Backend separation):**
644650

645651
#### GPU Machine Setup
646652
```bash
647-
# Start GPU-accelerated services
648-
cd extras/asr-services
649-
docker compose up moonshine -d
653+
# From the repository root on the GPU node, start configured service groups
654+
./services start asr-services speaker-recognition
650655
651-
cd extras/speaker-recognition
652-
docker compose up --build -d
653-
654-
# Ollama with GPU support
656+
# Optional external Ollama, outside Chronicle's managed service registry
655657
docker run -d --gpus=all -p 11434:11434 \
656658
-v ollama:/root/.ollama \
657659
ollama/ollama:latest
@@ -664,8 +666,8 @@ OLLAMA_BASE_URL=http://[gpu-machine-tailscale-ip]:11434
664666
SPEAKER_SERVICE_URL=http://[gpu-machine-tailscale-ip]:8085
665667
PARAKEET_ASR_URL=http://[gpu-machine-tailscale-ip]:8080
666668
667-
# Start lightweight backend services
668-
docker compose up --build -d
669+
# From the repository root, start the managed backend
670+
./services start backend
669671
```
670672

671673
#### Tailscale Networking
@@ -817,7 +819,7 @@ For temporary Python-backed tooling that is not part of the repo dependencies, p
817819
- Prefer GPU acceleration whenever the workload and deployed service support it.
818820

819821
**Container Engine (Docker or Podman):**
820-
- The project supports **both Docker and Podman**. The active engine is set by `container_engine` in `config/config.yml` (default `docker`); prefer the lifecycle scripts (`./start.sh`/`./stop.sh`/`./restart.sh`) which route through the selected engine. For one-off manual commands under Podman use `podman-compose` (not `docker compose`). See **[@docs/podman.md](docs/podman.md)**.
822+
- The project supports **both Docker and Podman**. The active engine is set by `container_engine` in `config/config.yml` (default `docker`); prefer the lifecycle scripts (`./services start --all`/`./services stop --all`/`./services restart --all`) which route through the selected engine. For the lower-level cases described in [service operations](#service-operations), use `podman-compose` under Podman. See **[@docs/podman.md](docs/podman.md)**.
821823

822824
**Docker Build Guidelines:**
823825
- Use `docker compose build` (or `podman-compose build`) without `--no-cache` by default for faster builds

‎README.md‎

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ chronicle/
6767
├── config/ # Central configuration files
6868
├── tests/ # Integration & unit tests
6969
├── wizard.py # Root setup orchestrator
70-
├── services.py # Service lifecycle manager
70+
├── services # Operator CLI
71+
├── services.py # Shared lifecycle implementation
7172
└── *.sh # Convenience scripts (wrappers)
7273
```
7374

@@ -113,34 +114,21 @@ chronicle/
113114
uv run --with-requirements setup-requirements.txt python wizard.py
114115
```
115116

116-
**Note**: Convenience scripts (*.sh) are wrappers around `wizard.py` and `services.py` that simplify the longer `uv run` commands.
117+
Use `./wizard.sh` for configuration and `./services` for operations.
117118

118119
### Service Management
119-
```bash
120-
# Start all configured services
121-
./start.sh
122-
123-
# Restart all services (preserves containers)
124-
./restart.sh
125-
126-
# Check service status
127-
./status.sh
128-
129-
# Stop all services
130-
./stop.sh
131-
```
132-
133-
<details>
134-
<summary>Full commands (click to expand)</summary>
135120

136121
```bash
137-
# What the convenience scripts wrap
138-
uv run --with-requirements setup-requirements.txt python services.py start --all --build
139-
uv run --with-requirements setup-requirements.txt python services.py restart --all
140-
uv run --with-requirements setup-requirements.txt python services.py status
141-
uv run --with-requirements setup-requirements.txt python services.py stop --all
122+
./services status --detailed
123+
./services start llm-services wakeword-service
124+
./services start --all
125+
./services restart backend
126+
./services stop --all
127+
./services logs wakeword-service --tail 100
142128
```
143-
</details>
129+
130+
Start/restart waits for readiness. See the [operator workflow](docs/init-system.md#service-management)
131+
for remote nodes, image builds, operation history, and explicit local recovery.
144132

145133
### Development
146134
```bash

‎backend/docker-compose.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# The Chronicle backend stack. What each service is for, and why the non-obvious
22
# settings below are the way they are: docs/backend/compose-stack.md
3-
# Drive it with ./start.sh / ./stop.sh / ./restart.sh, not by hand.
3+
# Drive it with ./services start --all / ./services stop --all / ./services restart --all, not by hand.
44

55
# Explicit DNS upstreams; Tailscale's resolver first. Without these, aardvark
66
# (Podman) can silently stop forwarding external queries; with only public ones,

‎backend/webui/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ This starts:
7676
## Deployment
7777

7878
This project runs the Vite dev server (`webui-dev`) as the only webui — there is
79-
no separate static/nginx production build. The standard stack (`./start.sh`)
79+
no separate static/nginx production build. The standard stack (`./services start --all`)
8080
starts it on http://localhost:5173, and Caddy fronts it for HTTPS (microphone
8181
access, remote/Tailscale). Source is volume-mounted, so changes hot-reload with
8282
no rebuild.

‎backend/webui/src/components/ExternalServices.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export default function ExternalServices({
236236
setLastFailedOp(null)
237237
if (service.name === 'backend' && action !== 'start') {
238238
const confirmed = window.confirm(
239-
'Stopping the backend takes down this dashboard — you will need ./start.sh on the host to bring it back. Continue?'
239+
'Stopping the backend takes down this dashboard — you will need ./services start backend on the host to bring it back. Continue?'
240240
)
241241
if (!confirmed) return
242242
}
@@ -391,8 +391,7 @@ export default function ExternalServices({
391391
</h3>
392392
<p className="text-sm text-gray-500 dark:text-gray-400">
393393
Service manager agent is not reachable. Start it on the host with{' '}
394-
<code className="px-1 bg-gray-100 dark:bg-gray-700 rounded">./start.sh</code> or{' '}
395-
<code className="px-1 bg-gray-100 dark:bg-gray-700 rounded">uv run python services.py manager start</code>.
394+
<code className="px-1 bg-gray-100 dark:bg-gray-700 rounded">./services manager start</code>.
396395
</p>
397396
</Card>
398397
)

‎backend/webui/src/components/RemoteControl.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export default function RemoteControl({ isAdmin }: { isAdmin: boolean }) {
136136
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
137137
Backed by a tmux session (<code className="text-xs">tmux attach -t {data.session ?? 'chronicle-rc'}</code> at
138138
the desktop). Make it survive reboots from the wizard's "auto-start on boot" step, or{' '}
139-
<code className="text-xs">services.py remote-control install</code>.
139+
<code className="text-xs">./services remote-control install</code>.
140140
</p>
141141
</Card>
142142
)

‎deployment_guard.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Host-side admission adapter shared by CLI, boot and node-agent operations."""
2+
3+
import os
4+
from contextlib import contextmanager
5+
from contextvars import ContextVar
6+
from pathlib import Path
7+
8+
import requests
9+
import yaml
10+
from dotenv import dotenv_values
11+
12+
from service_deployments import PlacementError, Plan
13+
14+
_held = ContextVar("deployment_activations", default=frozenset())
15+
16+
17+
def placement_config(root: Path) -> dict:
18+
path = root / "config" / "config.yml"
19+
if not path.exists():
20+
return {}
21+
config = yaml.safe_load(path.read_text()) or {}
22+
return config.get("service_placement") or {}
23+
24+
25+
def control_token(root: Path) -> str:
26+
return (
27+
os.getenv("SERVICE_PLACEMENT_TOKEN")
28+
or dotenv_values(root / "backend" / ".env").get("SERVICE_PLACEMENT_TOKEN")
29+
or ""
30+
)
31+
32+
33+
def control_headers(root: Path) -> dict:
34+
token = control_token(root)
35+
return {"Authorization": f"Bearer {token}"} if token else {}
36+
37+
38+
def local_services(root: Path, services: list[str]) -> list[str]:
39+
"""Select bulk activation targets; admission still fences each activation.
40+
41+
Groups absent from the plan remain node-local. Both primary and warm standby
42+
instances are eligible. An unreadable plan must never become permission to
43+
start every enabled service.
44+
"""
45+
cfg = placement_config(root)
46+
if not cfg:
47+
return services
48+
url = cfg.get("coordinator_url", "").rstrip("/")
49+
node = cfg.get("node_id")
50+
if not url or not node:
51+
raise PlacementError("service_placement requires coordinator_url and node_id")
52+
try:
53+
response = requests.get(
54+
f"{url}/deployments", headers=control_headers(root), timeout=15
55+
)
56+
response.raise_for_status()
57+
plan = Plan.model_validate(response.json()["plan"])
58+
except (requests.RequestException, ValueError, KeyError, TypeError) as exc:
59+
raise PlacementError(
60+
f"Cannot read deployment plan; refusing bulk activation: {exc}"
61+
) from exc
62+
if node not in plan.nodes:
63+
raise PlacementError(
64+
f"Register node {node} in the deployment plan before starting services"
65+
)
66+
return [
67+
name
68+
for name in services
69+
if name not in plan.deployments
70+
or any(instance.node == node for instance in plan.deployments[name].instances)
71+
]
72+
73+
74+
@contextmanager
75+
def activation(root: Path, service: str, *, stop_revision: int | None = None):
76+
cfg = placement_config(root)
77+
if not cfg or service in _held.get():
78+
yield
79+
return
80+
url = cfg.get("coordinator_url", "").rstrip("/")
81+
node = cfg.get("node_id")
82+
if not url or not node:
83+
raise PlacementError("service_placement requires coordinator_url and node_id")
84+
try:
85+
response = requests.post(
86+
f"{url}/deployments/activate",
87+
json={"service": service, "node": node, "stop_revision": stop_revision},
88+
headers=control_headers(root),
89+
timeout=60,
90+
)
91+
if not response.ok:
92+
raise PlacementError(response.json().get("detail", response.text))
93+
token = response.json().get("token")
94+
except requests.RequestException as exc:
95+
raise PlacementError(
96+
f"Deployment authority unavailable; refusing activation: {exc}"
97+
) from exc
98+
marker = _held.set(_held.get() | {service})
99+
try:
100+
yield
101+
finally:
102+
_held.reset(marker)
103+
if token:
104+
try:
105+
response = requests.delete(
106+
f"{url}/deployments/activations/{token}",
107+
headers=control_headers(root),
108+
timeout=15,
109+
)
110+
response.raise_for_status()
111+
except requests.RequestException as exc:
112+
raise PlacementError(
113+
f"Activation completed but reservation {token} could not be released: {exc}"
114+
) from exc

0 commit comments

Comments
 (0)