Nine TDD tasks: package scaffold, config loading, agentdb bootstrap + v1 migrations, read-only Firefly client, heartbeat job, scheduler + healthcheck + entrypoint, verify CLI, containerization, and the human-in-the-loop production deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uv7wnzF6Xv6fmaRjFTiMMq
1276 lines
41 KiB
Markdown
1276 lines
41 KiB
Markdown
# Phase 1: Foundation — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Stand up the `agent` worker container — config, `agentdb` with migrations, a read-only Firefly API client, a scheduled heartbeat that proves end-to-end connectivity — and deploy it to the production host.
|
|
|
|
**Architecture:** A Python 3.12 package under `agent/` in this repo, run as one container in the existing compose stack. On startup it creates/migrates `agentdb` (a second database in the existing Postgres container), runs a heartbeat once, then hands off to APScheduler. The heartbeat calls Firefly's `/api/v1/about` and records the outcome in `sync_runs` — the same logging path every future connector will use.
|
|
|
|
**Tech Stack:** Python 3.12, APScheduler 3.x, httpx, psycopg 3, pytest, testcontainers (dev only), Docker/compose.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-08-23-accounting-agent-architecture-design.md`
|
|
|
|
## Global Constraints
|
|
|
|
- Python 3.12+ (`requires-python = ">=3.12"`); container base image `python:3.12-slim`.
|
|
- Runtime dependencies limited to: `apscheduler>=3.10,<4`, `httpx>=0.27`, `psycopg[binary]>=3.1`. Dev-only: `pytest>=8`, `testcontainers[postgres]>=4`.
|
|
- TDD: every code task writes the failing test first, sees it fail, then implements.
|
|
- Firefly API access in this phase is **read-only** (GET endpoints only). The agent never writes to the ledger.
|
|
- Secrets live only in `.env` on the host. Never commit `.env`, tokens, or passwords.
|
|
- All timestamps are UTC (`TIMESTAMPTZ` in Postgres, `datetime.now(timezone.utc)` in Python).
|
|
- The `agent` container attaches only to the internal `firefly` network: no Traefik labels, no published ports.
|
|
- Commit after every green test cycle.
|
|
- Prerequisite for running tests locally: Docker daemon running (testcontainers starts a throwaway `postgres:16-alpine`).
|
|
- Testing deviation from spec, by design: the spec's throwaway-Firefly compose test profile arrives in Phase 2 with the sync engine. Phase 1's client makes two GET calls, tested against `httpx.MockTransport`; Task 9's `agent.verify` exercises the real API in production.
|
|
|
|
## File Structure
|
|
|
|
```
|
|
agent/
|
|
├── Dockerfile
|
|
├── .dockerignore
|
|
├── pyproject.toml
|
|
├── README.md # setup + deploy runbook (Task 8)
|
|
├── src/agent/
|
|
│ ├── __init__.py # __version__
|
|
│ ├── __main__.py # entrypoint: ensure db → migrate → heartbeat → scheduler
|
|
│ ├── config.py # AgentConfig, load_config, ConfigError
|
|
│ ├── db.py # DSNs, ensure_database, run_migrations, connect
|
|
│ ├── migrations/0001_initial.sql
|
|
│ ├── firefly.py # FireflyClient (read-only), FireflyError
|
|
│ ├── scheduler.py # build_scheduler
|
|
│ ├── healthcheck.py # Docker HEALTHCHECK entry
|
|
│ ├── verify.py # `python -m agent.verify` post-deploy check
|
|
│ └── jobs/
|
|
│ ├── __init__.py
|
|
│ └── heartbeat.py # run_heartbeat
|
|
└── tests/
|
|
├── conftest.py # session postgres container, per-test cfg
|
|
├── test_smoke.py
|
|
├── test_config.py
|
|
├── test_db.py
|
|
├── test_firefly.py
|
|
├── test_heartbeat.py
|
|
├── test_scheduler.py
|
|
└── test_verify.py
|
|
```
|
|
|
|
Everything runs from `agent/` (`cd agent && pytest`). Repo-root files touched: `docker-compose.yml`, `.env.example`, `.gitignore`.
|
|
|
|
---
|
|
|
|
### Task 1: Package scaffold
|
|
|
|
**Files:**
|
|
- Create: `agent/pyproject.toml`, `agent/src/agent/__init__.py`, `agent/src/agent/jobs/__init__.py`, `agent/tests/test_smoke.py`
|
|
- Modify: `.gitignore`
|
|
|
|
**Interfaces:**
|
|
- Produces: importable package `agent` with `agent.__version__ == "0.1.0"`; working `pytest` invocation from `agent/`.
|
|
|
|
- [ ] **Step 1: Create `agent/pyproject.toml`**
|
|
|
|
```toml
|
|
[project]
|
|
name = "firefly-agent"
|
|
version = "0.1.0"
|
|
description = "Personal accounting agent worker for the Firefly III stack"
|
|
requires-python = ">=3.12"
|
|
dependencies = [
|
|
"apscheduler>=3.10,<4",
|
|
"httpx>=0.27",
|
|
"psycopg[binary]>=3.1",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"pytest>=8",
|
|
"testcontainers[postgres]>=4",
|
|
]
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/agent"]
|
|
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing smoke test** — `agent/tests/test_smoke.py`
|
|
|
|
```python
|
|
def test_package_imports():
|
|
import agent
|
|
|
|
assert agent.__version__ == "0.1.0"
|
|
```
|
|
|
|
- [ ] **Step 3: Set up the venv and run the test to verify it fails**
|
|
|
|
```bash
|
|
cd /home/jake/dev/firefly
|
|
python3 -m venv .venv
|
|
source .venv/bin/activate
|
|
pip install -e './agent[dev]'
|
|
cd agent && pytest tests/test_smoke.py -v
|
|
```
|
|
|
|
Expected: FAIL (`agent` has no attribute `__version__` — the editable install will fail or the import will, since `src/agent/__init__.py` doesn't exist yet; create empty dirs as needed to get the install through, then see the assertion fail).
|
|
|
|
- [ ] **Step 4: Implement** — `agent/src/agent/__init__.py`
|
|
|
|
```python
|
|
__version__ = "0.1.0"
|
|
```
|
|
|
|
Also create empty `agent/src/agent/jobs/__init__.py`.
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `cd agent && pytest tests/test_smoke.py -v` — Expected: PASS
|
|
|
|
- [ ] **Step 6: Add ignores to repo-root `.gitignore`**
|
|
|
|
Append these lines:
|
|
|
|
```
|
|
.venv/
|
|
__pycache__/
|
|
*.egg-info/
|
|
.pytest_cache/
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add agent/ .gitignore
|
|
git commit -m "feat(agent): scaffold Python package for the accounting agent worker"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Config loading
|
|
|
|
**Files:**
|
|
- Create: `agent/src/agent/config.py`
|
|
- Test: `agent/tests/test_config.py`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `class ConfigError(ValueError)`
|
|
- `@dataclass(frozen=True) AgentConfig` with fields: `db_host: str`, `db_port: int`, `db_user: str`, `db_password: str`, `agent_db_name: str`, `firefly_url: str`, `firefly_token: str`, `heartbeat_interval_minutes: int`
|
|
- `load_config(env: Mapping[str, str] | None = None) -> AgentConfig` — reads `os.environ` when `env` is None.
|
|
|
|
Environment variables (shared names match the existing `.env`): required — `DB_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `FIREFLY_API_URL`, `AGENT_FIREFLY_TOKEN`; optional with defaults — `DB_PORT`=5432, `AGENT_DB_NAME`=`agentdb`, `HEARTBEAT_INTERVAL_MINUTES`=60.
|
|
|
|
- [ ] **Step 1: Write the failing tests** — `agent/tests/test_config.py`
|
|
|
|
```python
|
|
import pytest
|
|
|
|
from agent.config import AgentConfig, ConfigError, load_config
|
|
|
|
FULL_ENV = {
|
|
"DB_HOST": "db",
|
|
"DB_PORT": "5433",
|
|
"POSTGRES_USER": "firefly",
|
|
"POSTGRES_PASSWORD": "s3cret",
|
|
"AGENT_DB_NAME": "agentdb2",
|
|
"FIREFLY_API_URL": "http://app:8080",
|
|
"AGENT_FIREFLY_TOKEN": "tok123",
|
|
"HEARTBEAT_INTERVAL_MINUTES": "15",
|
|
}
|
|
|
|
MINIMAL_ENV = {
|
|
"DB_HOST": "db",
|
|
"POSTGRES_USER": "firefly",
|
|
"POSTGRES_PASSWORD": "s3cret",
|
|
"FIREFLY_API_URL": "http://app:8080",
|
|
"AGENT_FIREFLY_TOKEN": "tok123",
|
|
}
|
|
|
|
|
|
def test_load_config_reads_all_vars():
|
|
cfg = load_config(FULL_ENV)
|
|
assert cfg == AgentConfig(
|
|
db_host="db",
|
|
db_port=5433,
|
|
db_user="firefly",
|
|
db_password="s3cret",
|
|
agent_db_name="agentdb2",
|
|
firefly_url="http://app:8080",
|
|
firefly_token="tok123",
|
|
heartbeat_interval_minutes=15,
|
|
)
|
|
|
|
|
|
def test_defaults_applied():
|
|
cfg = load_config(MINIMAL_ENV)
|
|
assert cfg.db_port == 5432
|
|
assert cfg.agent_db_name == "agentdb"
|
|
assert cfg.heartbeat_interval_minutes == 60
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"missing",
|
|
["DB_HOST", "POSTGRES_USER", "POSTGRES_PASSWORD", "FIREFLY_API_URL", "AGENT_FIREFLY_TOKEN"],
|
|
)
|
|
def test_missing_required_var_names_the_var(missing):
|
|
env = {k: v for k, v in MINIMAL_ENV.items() if k != missing}
|
|
with pytest.raises(ConfigError, match=missing):
|
|
load_config(env)
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cd agent && pytest tests/test_config.py -v` — Expected: FAIL with `ModuleNotFoundError: No module named 'agent.config'`
|
|
|
|
- [ ] **Step 3: Implement** — `agent/src/agent/config.py`
|
|
|
|
```python
|
|
"""Agent configuration loaded from environment variables."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
|
|
|
|
class ConfigError(ValueError):
|
|
"""A required environment variable is missing or invalid."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentConfig:
|
|
db_host: str
|
|
db_port: int
|
|
db_user: str
|
|
db_password: str
|
|
agent_db_name: str
|
|
firefly_url: str
|
|
firefly_token: str
|
|
heartbeat_interval_minutes: int
|
|
|
|
|
|
def _require(env: Mapping[str, str], name: str) -> str:
|
|
value = env.get(name, "").strip()
|
|
if not value:
|
|
raise ConfigError(f"missing required environment variable: {name}")
|
|
return value
|
|
|
|
|
|
def load_config(env: Mapping[str, str] | None = None) -> AgentConfig:
|
|
if env is None:
|
|
env = os.environ
|
|
return AgentConfig(
|
|
db_host=_require(env, "DB_HOST"),
|
|
db_port=int(env.get("DB_PORT", "5432")),
|
|
db_user=_require(env, "POSTGRES_USER"),
|
|
db_password=_require(env, "POSTGRES_PASSWORD"),
|
|
agent_db_name=env.get("AGENT_DB_NAME", "agentdb"),
|
|
firefly_url=_require(env, "FIREFLY_API_URL"),
|
|
firefly_token=_require(env, "AGENT_FIREFLY_TOKEN"),
|
|
heartbeat_interval_minutes=int(env.get("HEARTBEAT_INTERVAL_MINUTES", "60")),
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `cd agent && pytest tests/test_config.py -v` — Expected: PASS (7 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add agent/src/agent/config.py agent/tests/test_config.py
|
|
git commit -m "feat(agent): load config from environment with validation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: agentdb — database bootstrap and migrations
|
|
|
|
**Files:**
|
|
- Create: `agent/src/agent/db.py`, `agent/src/agent/migrations/0001_initial.sql`, `agent/tests/conftest.py`
|
|
- Test: `agent/tests/test_db.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `AgentConfig` from Task 2.
|
|
- Produces:
|
|
- `admin_dsn(cfg: AgentConfig) -> str` — DSN to the always-present `postgres` maintenance database.
|
|
- `agent_dsn(cfg: AgentConfig) -> str` — DSN to `cfg.agent_db_name`.
|
|
- `ensure_database(cfg: AgentConfig) -> None` — creates `agentdb` if absent; idempotent.
|
|
- `run_migrations(cfg: AgentConfig) -> list[str]` — applies pending `migrations/*.sql` in name order, returns the names applied (empty list when up to date).
|
|
- `connect(cfg: AgentConfig) -> psycopg.Connection` — connection to agentdb.
|
|
- Tables after migration 0001: `schema_migrations`, `sync_runs`, `documents`, `bills`, `approvals`.
|
|
- Test fixtures produced for later tasks (in `conftest.py`): `pg` (session-scoped Postgres container), `cfg` (per-test `AgentConfig` pointing at a unique database name in that container).
|
|
|
|
- [ ] **Step 1: Write the migration SQL** — `agent/src/agent/migrations/0001_initial.sql`
|
|
|
|
This is data, not logic — write it before the tests that assert on it. Schema is the spec's "agentdb schema (v1)" verbatim:
|
|
|
|
```sql
|
|
-- agentdb schema v1 (see spec: agentdb schema section)
|
|
|
|
CREATE TABLE sync_runs (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
connector TEXT NOT NULL,
|
|
started_at TIMESTAMPTZ NOT NULL,
|
|
finished_at TIMESTAMPTZ,
|
|
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')),
|
|
items_upserted INTEGER NOT NULL DEFAULT 0,
|
|
detail TEXT
|
|
);
|
|
|
|
CREATE INDEX sync_runs_connector_id_idx ON sync_runs (connector, id DESC);
|
|
|
|
CREATE TABLE documents (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
path TEXT NOT NULL UNIQUE,
|
|
sha256 CHAR(64) NOT NULL UNIQUE,
|
|
institution TEXT NOT NULL,
|
|
account TEXT,
|
|
doc_type TEXT NOT NULL,
|
|
period TEXT,
|
|
source TEXT NOT NULL,
|
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE bills (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
payee TEXT NOT NULL,
|
|
expected_amount NUMERIC(12,2),
|
|
due_date DATE NOT NULL,
|
|
funding_account TEXT,
|
|
autopay BOOLEAN NOT NULL DEFAULT false,
|
|
status TEXT NOT NULL DEFAULT 'upcoming'
|
|
CHECK (status IN ('upcoming', 'approved', 'paid', 'skipped', 'overdue'))
|
|
);
|
|
|
|
CREATE TABLE approvals (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
bill_id BIGINT NOT NULL REFERENCES bills (id),
|
|
amount NUMERIC(12,2) NOT NULL,
|
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
decided_at TIMESTAMPTZ,
|
|
decision TEXT CHECK (decision IN ('approved', 'denied')),
|
|
telegram_message_id TEXT,
|
|
executed_at TIMESTAMPTZ
|
|
);
|
|
```
|
|
|
|
(`schema_migrations` is created by the runner itself, not by a migration.)
|
|
|
|
- [ ] **Step 2: Write the shared test fixtures** — `agent/tests/conftest.py`
|
|
|
|
```python
|
|
import itertools
|
|
|
|
import pytest
|
|
from testcontainers.postgres import PostgresContainer
|
|
|
|
from agent.config import AgentConfig
|
|
|
|
_db_counter = itertools.count()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def pg():
|
|
with PostgresContainer("postgres:16-alpine") as container:
|
|
yield container
|
|
|
|
|
|
@pytest.fixture
|
|
def cfg(pg) -> AgentConfig:
|
|
"""AgentConfig pointing at a fresh, uniquely named database per test."""
|
|
return AgentConfig(
|
|
db_host=pg.get_container_host_ip(),
|
|
db_port=int(pg.get_exposed_port(5432)),
|
|
db_user=pg.username,
|
|
db_password=pg.password,
|
|
agent_db_name=f"agentdb_test_{next(_db_counter)}",
|
|
firefly_url="http://firefly.test",
|
|
firefly_token="test-token",
|
|
heartbeat_interval_minutes=60,
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Write the failing tests** — `agent/tests/test_db.py`
|
|
|
|
```python
|
|
from agent.db import connect, ensure_database, run_migrations
|
|
|
|
EXPECTED_TABLES = {"schema_migrations", "sync_runs", "documents", "bills", "approvals"}
|
|
|
|
|
|
def test_ensure_database_creates_and_is_idempotent(cfg):
|
|
ensure_database(cfg)
|
|
ensure_database(cfg) # second call must not raise
|
|
with connect(cfg) as conn:
|
|
assert conn.execute("SELECT 1").fetchone()[0] == 1
|
|
|
|
|
|
def test_run_migrations_applies_each_migration_exactly_once(cfg):
|
|
ensure_database(cfg)
|
|
|
|
first = run_migrations(cfg)
|
|
assert first == ["0001_initial.sql"]
|
|
|
|
second = run_migrations(cfg)
|
|
assert second == []
|
|
|
|
with connect(cfg) as conn:
|
|
rows = conn.execute(
|
|
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
|
|
).fetchall()
|
|
assert EXPECTED_TABLES <= {r[0] for r in rows}
|
|
|
|
|
|
def test_sync_runs_rejects_bad_status(cfg):
|
|
import psycopg
|
|
import pytest
|
|
|
|
ensure_database(cfg)
|
|
run_migrations(cfg)
|
|
with connect(cfg) as conn, pytest.raises(psycopg.errors.CheckViolation):
|
|
conn.execute(
|
|
"INSERT INTO sync_runs (connector, started_at, status)"
|
|
" VALUES ('x', now(), 'bogus')"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they fail**
|
|
|
|
Run: `cd agent && pytest tests/test_db.py -v` — Expected: FAIL with `ModuleNotFoundError: No module named 'agent.db'` (the container may take ~10s to start first run)
|
|
|
|
- [ ] **Step 5: Implement** — `agent/src/agent/db.py`
|
|
|
|
```python
|
|
"""agentdb access: bootstrap, migrations, connections."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import psycopg
|
|
|
|
from agent.config import AgentConfig
|
|
|
|
_MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
|
|
|
|
|
def _dsn(cfg: AgentConfig, dbname: str) -> str:
|
|
return (
|
|
f"host={cfg.db_host} port={cfg.db_port} dbname={dbname} "
|
|
f"user={cfg.db_user} password={cfg.db_password}"
|
|
)
|
|
|
|
|
|
def admin_dsn(cfg: AgentConfig) -> str:
|
|
"""DSN for the maintenance database that always exists."""
|
|
return _dsn(cfg, "postgres")
|
|
|
|
|
|
def agent_dsn(cfg: AgentConfig) -> str:
|
|
return _dsn(cfg, cfg.agent_db_name)
|
|
|
|
|
|
def connect(cfg: AgentConfig) -> psycopg.Connection:
|
|
return psycopg.connect(agent_dsn(cfg))
|
|
|
|
|
|
def ensure_database(cfg: AgentConfig) -> None:
|
|
"""Create the agent database if it does not exist. Idempotent."""
|
|
with psycopg.connect(admin_dsn(cfg), autocommit=True) as conn:
|
|
exists = conn.execute(
|
|
"SELECT 1 FROM pg_database WHERE datname = %s", (cfg.agent_db_name,)
|
|
).fetchone()
|
|
if not exists:
|
|
# CREATE DATABASE cannot be parameterized; the name comes from
|
|
# our own config, quoted defensively via psycopg's identifier API.
|
|
from psycopg import sql
|
|
|
|
conn.execute(
|
|
sql.SQL("CREATE DATABASE {}").format(sql.Identifier(cfg.agent_db_name))
|
|
)
|
|
|
|
|
|
def run_migrations(cfg: AgentConfig) -> list[str]:
|
|
"""Apply pending migrations in filename order. Returns names applied."""
|
|
applied: list[str] = []
|
|
with connect(cfg) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
|
" name TEXT PRIMARY KEY,"
|
|
" applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
|
)
|
|
done = {
|
|
row[0] for row in conn.execute("SELECT name FROM schema_migrations")
|
|
}
|
|
for path in sorted(_MIGRATIONS_DIR.glob("*.sql")):
|
|
if path.name in done:
|
|
continue
|
|
conn.execute(path.read_text())
|
|
conn.execute(
|
|
"INSERT INTO schema_migrations (name) VALUES (%s)", (path.name,)
|
|
)
|
|
applied.append(path.name)
|
|
return applied
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests to verify they pass**
|
|
|
|
Run: `cd agent && pytest tests/test_db.py -v` — Expected: PASS (3 tests)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add agent/src/agent/db.py agent/src/agent/migrations/ agent/tests/conftest.py agent/tests/test_db.py
|
|
git commit -m "feat(agent): agentdb bootstrap, migration runner, v1 schema"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Firefly API client (read-only)
|
|
|
|
**Files:**
|
|
- Create: `agent/src/agent/firefly.py`
|
|
- Test: `agent/tests/test_firefly.py`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
- `class FireflyError(Exception)`
|
|
- `class FireflyClient` with:
|
|
- `__init__(self, base_url: str, token: str, transport: httpx.BaseTransport | None = None)` — `transport` exists for tests only.
|
|
- `about(self) -> dict` — returns the `data` object from `GET /api/v1/about` (contains `version`).
|
|
- `accounts(self, type: str = "asset") -> list[dict]` — all pages of `GET /api/v1/accounts`; each item is Firefly's account object (`{"id": ..., "attributes": {"name": ...}}`).
|
|
|
|
- [ ] **Step 1: Write the failing tests** — `agent/tests/test_firefly.py`
|
|
|
|
```python
|
|
import httpx
|
|
import pytest
|
|
|
|
from agent.firefly import FireflyClient, FireflyError
|
|
|
|
|
|
def make_client(handler) -> FireflyClient:
|
|
return FireflyClient(
|
|
"http://firefly.test", "tok", transport=httpx.MockTransport(handler)
|
|
)
|
|
|
|
|
|
def test_about_sends_bearer_token_and_returns_data():
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.headers["Authorization"] == "Bearer tok"
|
|
assert request.headers["Accept"] == "application/json"
|
|
assert request.url.path == "/api/v1/about"
|
|
return httpx.Response(200, json={"data": {"version": "6.1.1"}})
|
|
|
|
assert make_client(handler).about() == {"version": "6.1.1"}
|
|
|
|
|
|
def test_non_200_raises_firefly_error_with_status():
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(401, json={"message": "Unauthenticated"})
|
|
|
|
with pytest.raises(FireflyError, match="401"):
|
|
make_client(handler).about()
|
|
|
|
|
|
def test_accounts_walks_all_pages():
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/api/v1/accounts"
|
|
assert request.url.params["type"] == "asset"
|
|
page = int(request.url.params.get("page", "1"))
|
|
data = {
|
|
1: [{"id": "1", "attributes": {"name": "NFCU Checking"}}],
|
|
2: [{"id": "2", "attributes": {"name": "Venmo"}}],
|
|
}[page]
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": data,
|
|
"meta": {"pagination": {"total_pages": 2, "current_page": page}},
|
|
},
|
|
)
|
|
|
|
accounts = make_client(handler).accounts()
|
|
assert [a["attributes"]["name"] for a in accounts] == ["NFCU Checking", "Venmo"]
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cd agent && pytest tests/test_firefly.py -v` — Expected: FAIL with `ModuleNotFoundError: No module named 'agent.firefly'`
|
|
|
|
- [ ] **Step 3: Implement** — `agent/src/agent/firefly.py`
|
|
|
|
```python
|
|
"""Minimal read-only client for the Firefly III REST API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
|
|
class FireflyError(Exception):
|
|
"""Firefly III returned an unexpected response."""
|
|
|
|
|
|
class FireflyClient:
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
token: str,
|
|
transport: httpx.BaseTransport | None = None,
|
|
) -> None:
|
|
self._http = httpx.Client(
|
|
base_url=base_url,
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json",
|
|
},
|
|
timeout=30.0,
|
|
transport=transport,
|
|
)
|
|
|
|
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
response = self._http.get(path, params=params)
|
|
if response.status_code != 200:
|
|
raise FireflyError(
|
|
f"GET {path} -> {response.status_code}: {response.text[:200]}"
|
|
)
|
|
return response.json()
|
|
|
|
def about(self) -> dict:
|
|
return self._get("/api/v1/about")["data"]
|
|
|
|
def accounts(self, type: str = "asset") -> list[dict]:
|
|
results: list[dict] = []
|
|
page = 1
|
|
while True:
|
|
body = self._get("/api/v1/accounts", params={"type": type, "page": page})
|
|
results.extend(body["data"])
|
|
pagination = body.get("meta", {}).get("pagination", {})
|
|
if page >= pagination.get("total_pages", 1):
|
|
return results
|
|
page += 1
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `cd agent && pytest tests/test_firefly.py -v` — Expected: PASS (3 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add agent/src/agent/firefly.py agent/tests/test_firefly.py
|
|
git commit -m "feat(agent): read-only Firefly III API client"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Heartbeat job
|
|
|
|
**Files:**
|
|
- Create: `agent/src/agent/jobs/heartbeat.py`
|
|
- Test: `agent/tests/test_heartbeat.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `AgentConfig` (Task 2), `connect` (Task 3), `FireflyClient`/`FireflyError` (Task 4).
|
|
- Produces: `run_heartbeat(cfg: AgentConfig, client: FireflyClient | None = None) -> str` — calls Firefly `about()`, inserts one `sync_runs` row (`connector='heartbeat'`, status `'ok'` or `'error'`), returns the status string. Builds its own client from `cfg` when `client` is None; the parameter exists for tests.
|
|
|
|
- [ ] **Step 1: Write the failing tests** — `agent/tests/test_heartbeat.py`
|
|
|
|
```python
|
|
from agent.db import connect, ensure_database, run_migrations
|
|
from agent.firefly import FireflyError
|
|
from agent.jobs.heartbeat import run_heartbeat
|
|
|
|
|
|
class FakeFirefly:
|
|
def __init__(self, version: str | None = None, error: Exception | None = None):
|
|
self._version = version
|
|
self._error = error
|
|
|
|
def about(self) -> dict:
|
|
if self._error:
|
|
raise self._error
|
|
return {"version": self._version}
|
|
|
|
|
|
def _last_run(cfg):
|
|
with connect(cfg) as conn:
|
|
return conn.execute(
|
|
"SELECT connector, status, detail, started_at, finished_at"
|
|
" FROM sync_runs ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
|
|
|
|
def test_heartbeat_records_ok(cfg):
|
|
ensure_database(cfg)
|
|
run_migrations(cfg)
|
|
|
|
status = run_heartbeat(cfg, client=FakeFirefly(version="6.1.1"))
|
|
|
|
assert status == "ok"
|
|
connector, db_status, detail, started_at, finished_at = _last_run(cfg)
|
|
assert connector == "heartbeat"
|
|
assert db_status == "ok"
|
|
assert "6.1.1" in detail
|
|
assert finished_at >= started_at
|
|
|
|
|
|
def test_heartbeat_records_error_and_does_not_raise(cfg):
|
|
ensure_database(cfg)
|
|
run_migrations(cfg)
|
|
|
|
status = run_heartbeat(cfg, client=FakeFirefly(error=FireflyError("GET /api/v1/about -> 401")))
|
|
|
|
assert status == "error"
|
|
connector, db_status, detail, *_ = _last_run(cfg)
|
|
assert db_status == "error"
|
|
assert "401" in detail
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cd agent && pytest tests/test_heartbeat.py -v` — Expected: FAIL with `ModuleNotFoundError: No module named 'agent.jobs.heartbeat'`
|
|
|
|
- [ ] **Step 3: Implement** — `agent/src/agent/jobs/heartbeat.py`
|
|
|
|
```python
|
|
"""Heartbeat: prove Firefly API + agentdb connectivity on a schedule."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from agent.config import AgentConfig
|
|
from agent.db import connect
|
|
from agent.firefly import FireflyClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def run_heartbeat(cfg: AgentConfig, client: FireflyClient | None = None) -> str:
|
|
if client is None:
|
|
client = FireflyClient(cfg.firefly_url, cfg.firefly_token)
|
|
|
|
started_at = datetime.now(timezone.utc)
|
|
try:
|
|
info = client.about()
|
|
status, detail = "ok", f"firefly version {info.get('version', 'unknown')}"
|
|
except Exception as exc: # any failure is a recorded outcome, not a crash
|
|
status, detail = "error", f"{type(exc).__name__}: {exc}"
|
|
logger.warning("heartbeat failed: %s", detail)
|
|
|
|
finished_at = datetime.now(timezone.utc)
|
|
with connect(cfg) as conn:
|
|
conn.execute(
|
|
"INSERT INTO sync_runs (connector, started_at, finished_at, status, detail)"
|
|
" VALUES (%s, %s, %s, %s, %s)",
|
|
("heartbeat", started_at, finished_at, status, detail),
|
|
)
|
|
return status
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `cd agent && pytest tests/test_heartbeat.py -v` — Expected: PASS (2 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add agent/src/agent/jobs/heartbeat.py agent/tests/test_heartbeat.py
|
|
git commit -m "feat(agent): heartbeat job recording to sync_runs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Scheduler, healthcheck, entrypoint
|
|
|
|
**Files:**
|
|
- Create: `agent/src/agent/scheduler.py`, `agent/src/agent/healthcheck.py`, `agent/src/agent/__main__.py`
|
|
- Test: `agent/tests/test_scheduler.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `AgentConfig`, `ensure_database`, `run_migrations`, `connect`, `run_heartbeat`.
|
|
- Produces:
|
|
- `build_scheduler(cfg: AgentConfig, *, scheduler_class: type = BlockingScheduler) -> BaseScheduler` — registers job id `"heartbeat"` on an interval of `cfg.heartbeat_interval_minutes` minutes. `scheduler_class` exists for tests (BackgroundScheduler).
|
|
- `python -m agent` — startup sequence: load config → `ensure_database` → `run_migrations` → `run_heartbeat` once → scheduler runs forever.
|
|
- `python -m agent.healthcheck` — exit 0 when agentdb answers `SELECT 1`, exit 1 otherwise (Docker HEALTHCHECK).
|
|
|
|
- [ ] **Step 1: Write the failing test** — `agent/tests/test_scheduler.py`
|
|
|
|
```python
|
|
from datetime import timedelta
|
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
from agent.config import AgentConfig
|
|
from agent.scheduler import build_scheduler
|
|
|
|
|
|
def _cfg(minutes: int) -> AgentConfig:
|
|
return AgentConfig(
|
|
db_host="db",
|
|
db_port=5432,
|
|
db_user="u",
|
|
db_password="p",
|
|
agent_db_name="agentdb",
|
|
firefly_url="http://app:8080",
|
|
firefly_token="tok",
|
|
heartbeat_interval_minutes=minutes,
|
|
)
|
|
|
|
|
|
def test_build_scheduler_registers_heartbeat_at_configured_interval():
|
|
scheduler = build_scheduler(_cfg(minutes=15), scheduler_class=BackgroundScheduler)
|
|
scheduler.start(paused=True)
|
|
try:
|
|
job = scheduler.get_job("heartbeat")
|
|
assert job is not None
|
|
assert job.trigger.interval == timedelta(minutes=15)
|
|
finally:
|
|
scheduler.shutdown(wait=False)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd agent && pytest tests/test_scheduler.py -v` — Expected: FAIL with `ModuleNotFoundError: No module named 'agent.scheduler'`
|
|
|
|
- [ ] **Step 3: Implement** — `agent/src/agent/scheduler.py`
|
|
|
|
```python
|
|
"""APScheduler assembly: one place where jobs get registered."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from apscheduler.schedulers.base import BaseScheduler
|
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
|
|
|
from agent.config import AgentConfig
|
|
from agent.jobs.heartbeat import run_heartbeat
|
|
|
|
|
|
def build_scheduler(
|
|
cfg: AgentConfig, *, scheduler_class: type = BlockingScheduler
|
|
) -> BaseScheduler:
|
|
scheduler = scheduler_class(timezone="UTC")
|
|
scheduler.add_job(
|
|
run_heartbeat,
|
|
"interval",
|
|
args=[cfg],
|
|
minutes=cfg.heartbeat_interval_minutes,
|
|
id="heartbeat",
|
|
)
|
|
return scheduler
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd agent && pytest tests/test_scheduler.py -v` — Expected: PASS
|
|
|
|
- [ ] **Step 5: Implement the healthcheck** — `agent/src/agent/healthcheck.py`
|
|
|
|
Glue over tested parts; no dedicated test.
|
|
|
|
```python
|
|
"""Docker HEALTHCHECK entry: healthy = agentdb answers SELECT 1."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from agent.config import load_config
|
|
from agent.db import connect
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
cfg = load_config()
|
|
with connect(cfg) as conn:
|
|
conn.execute("SELECT 1")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"unhealthy: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|
|
```
|
|
|
|
- [ ] **Step 6: Implement the entrypoint** — `agent/src/agent/__main__.py`
|
|
|
|
```python
|
|
"""Agent entrypoint: bootstrap the database, then run scheduled jobs forever."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from agent import __version__
|
|
from agent.config import load_config
|
|
from agent.db import ensure_database, run_migrations
|
|
from agent.jobs.heartbeat import run_heartbeat
|
|
from agent.scheduler import build_scheduler
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("agent")
|
|
|
|
|
|
def main() -> None:
|
|
logger.info("firefly-agent %s starting", __version__)
|
|
cfg = load_config()
|
|
ensure_database(cfg)
|
|
applied = run_migrations(cfg)
|
|
if applied:
|
|
logger.info("applied migrations: %s", ", ".join(applied))
|
|
status = run_heartbeat(cfg)
|
|
logger.info("startup heartbeat: %s", status)
|
|
logger.info(
|
|
"scheduler starting; heartbeat every %d minutes",
|
|
cfg.heartbeat_interval_minutes,
|
|
)
|
|
build_scheduler(cfg).start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
- [ ] **Step 7: Run the full suite**
|
|
|
|
Run: `cd agent && pytest -v` — Expected: all tests PASS
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add agent/src/agent/scheduler.py agent/src/agent/healthcheck.py agent/src/agent/__main__.py agent/tests/test_scheduler.py
|
|
git commit -m "feat(agent): scheduler, healthcheck, and container entrypoint"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Verify CLI
|
|
|
|
**Files:**
|
|
- Create: `agent/src/agent/verify.py`
|
|
- Test: `agent/tests/test_verify.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: everything above.
|
|
- Produces: `python -m agent.verify` — post-deploy check printing: migrations applied + last sync runs (from agentdb), Firefly version, and the list of asset account names. Internal function `verify(cfg: AgentConfig, client: FireflyClient | None = None) -> list[str]` returns the report lines (printing wrapper around it); `client` param for tests.
|
|
|
|
- [ ] **Step 1: Write the failing test** — `agent/tests/test_verify.py`
|
|
|
|
```python
|
|
from agent.db import ensure_database, run_migrations
|
|
from agent.jobs.heartbeat import run_heartbeat
|
|
from agent.verify import verify
|
|
|
|
|
|
class FakeFirefly:
|
|
def about(self) -> dict:
|
|
return {"version": "6.1.1"}
|
|
|
|
def accounts(self, type: str = "asset") -> list[dict]:
|
|
return [
|
|
{"id": "1", "attributes": {"name": "NFCU Checking"}},
|
|
{"id": "2", "attributes": {"name": "Venmo"}},
|
|
]
|
|
|
|
|
|
def test_verify_reports_db_firefly_and_accounts(cfg):
|
|
ensure_database(cfg)
|
|
run_migrations(cfg)
|
|
run_heartbeat(cfg, client=FakeFirefly())
|
|
|
|
report = "\n".join(verify(cfg, client=FakeFirefly()))
|
|
|
|
assert "1 migration(s) applied" in report
|
|
assert "heartbeat ok" in report
|
|
assert "firefly: OK (version 6.1.1)" in report
|
|
assert "2 asset account(s)" in report
|
|
assert "NFCU Checking" in report
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd agent && pytest tests/test_verify.py -v` — Expected: FAIL with `ModuleNotFoundError: No module named 'agent.verify'`
|
|
|
|
- [ ] **Step 3: Implement** — `agent/src/agent/verify.py`
|
|
|
|
```python
|
|
"""Post-deploy verification: `python -m agent.verify`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from agent.config import AgentConfig, load_config
|
|
from agent.db import connect
|
|
from agent.firefly import FireflyClient
|
|
|
|
|
|
def verify(cfg: AgentConfig, client: FireflyClient | None = None) -> list[str]:
|
|
lines: list[str] = []
|
|
|
|
with connect(cfg) as conn:
|
|
migrations = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0]
|
|
runs = conn.execute(
|
|
"SELECT connector, status, finished_at"
|
|
" FROM sync_runs ORDER BY id DESC LIMIT 3"
|
|
).fetchall()
|
|
lines.append(f"agentdb: OK ({migrations} migration(s) applied)")
|
|
for connector, status, finished_at in runs:
|
|
lines.append(f" last run: {connector} {status} at {finished_at}")
|
|
|
|
if client is None:
|
|
client = FireflyClient(cfg.firefly_url, cfg.firefly_token)
|
|
info = client.about()
|
|
lines.append(f"firefly: OK (version {info.get('version', 'unknown')})")
|
|
|
|
accounts = client.accounts()
|
|
lines.append(f"firefly: {len(accounts)} asset account(s):")
|
|
for account in accounts:
|
|
lines.append(f" - {account['attributes']['name']}")
|
|
return lines
|
|
|
|
|
|
def main() -> int:
|
|
for line in verify(load_config()):
|
|
print(line)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd agent && pytest tests/test_verify.py -v` — Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add agent/src/agent/verify.py agent/tests/test_verify.py
|
|
git commit -m "feat(agent): post-deploy verify command"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Dockerfile, compose service, env docs, runbook
|
|
|
|
**Files:**
|
|
- Create: `agent/Dockerfile`, `agent/.dockerignore`, `agent/README.md`
|
|
- Modify: `docker-compose.yml` (add `agent` service after `importer`), `.env.example` (new section), `README.md` (repo root, one line)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `python -m agent` and `python -m agent.healthcheck` from Task 6.
|
|
- Produces: `docker compose build agent` succeeds; `docker compose config -q` validates; `.env.example` documents the four new variables.
|
|
|
|
- [ ] **Step 1: Create `agent/.dockerignore`**
|
|
|
|
```
|
|
tests/
|
|
__pycache__/
|
|
*.egg-info/
|
|
.pytest_cache/
|
|
```
|
|
|
|
- [ ] **Step 2: Create `agent/Dockerfile`**
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim
|
|
|
|
WORKDIR /app
|
|
|
|
COPY pyproject.toml ./
|
|
COPY src ./src
|
|
|
|
RUN pip install --no-cache-dir .
|
|
|
|
HEALTHCHECK --interval=60s --timeout=10s --start-period=30s \
|
|
CMD ["python", "-m", "agent.healthcheck"]
|
|
|
|
CMD ["python", "-m", "agent"]
|
|
```
|
|
|
|
- [ ] **Step 3: Build the image to verify**
|
|
|
|
Run: `docker build -t firefly-agent ./agent` — Expected: builds successfully.
|
|
|
|
- [ ] **Step 4: Add the `agent` service to `docker-compose.yml`**
|
|
|
|
Insert after the `importer` service, matching the file's existing style:
|
|
|
|
```yaml
|
|
agent:
|
|
build: ./agent
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
networks:
|
|
- firefly
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
app:
|
|
condition: service_healthy
|
|
```
|
|
|
|
Note: no Traefik labels and no `traefik_proxy` network — the agent is internal-only (Global Constraints).
|
|
|
|
- [ ] **Step 5: Add the agent section to `.env.example`**
|
|
|
|
Append:
|
|
|
|
```
|
|
# --- Accounting Agent ---
|
|
# Firefly API base URL as seen from inside the compose network
|
|
FIREFLY_API_URL=http://app:8080
|
|
# Personal Access Token: Firefly UI -> Options -> Profile -> OAuth -> Personal Access Tokens
|
|
AGENT_FIREFLY_TOKEN=REPLACE_WITH_PERSONAL_ACCESS_TOKEN
|
|
# Name of the agent's own database inside the existing Postgres container
|
|
AGENT_DB_NAME=agentdb
|
|
HEARTBEAT_INTERVAL_MINUTES=60
|
|
```
|
|
|
|
- [ ] **Step 6: Validate compose config**
|
|
|
|
Run: `docker compose config -q` — Expected: exit 0, no output. (Requires the local `.env` to exist; placeholder values are fine.)
|
|
|
|
- [ ] **Step 7: Write `agent/README.md`**
|
|
|
|
```markdown
|
|
# firefly-agent
|
|
|
|
Worker container for the personal accounting agent. See the architecture spec:
|
|
`docs/superpowers/specs/2026-08-23-accounting-agent-architecture-design.md`.
|
|
|
|
## What it does (Phase 1)
|
|
|
|
On startup: creates `agentdb` in the stack's Postgres if missing, applies SQL
|
|
migrations from `src/agent/migrations/`, runs one heartbeat, then schedules the
|
|
heartbeat every `HEARTBEAT_INTERVAL_MINUTES`. The heartbeat calls Firefly's
|
|
`/api/v1/about` and records the outcome in `sync_runs`.
|
|
|
|
## Development
|
|
|
|
python3 -m venv .venv && source .venv/bin/activate
|
|
pip install -e './agent[dev]'
|
|
cd agent && pytest # needs Docker running (testcontainers)
|
|
|
|
## Configuration
|
|
|
|
All via environment variables (see `.env.example`, "Accounting Agent" section):
|
|
`FIREFLY_API_URL`, `AGENT_FIREFLY_TOKEN` (required), `AGENT_DB_NAME`,
|
|
`HEARTBEAT_INTERVAL_MINUTES`, plus the existing `DB_HOST`/`DB_PORT`/
|
|
`POSTGRES_USER`/`POSTGRES_PASSWORD`.
|
|
|
|
## Deploy & verify
|
|
|
|
# on the host, in the repo directory
|
|
git pull
|
|
docker compose build agent
|
|
docker compose up -d agent
|
|
docker compose exec agent python -m agent.verify
|
|
|
|
Expected verify output: agentdb OK with migrations applied, recent heartbeat
|
|
runs, Firefly version, and your asset accounts listed.
|
|
|
|
Inspect heartbeats directly:
|
|
|
|
docker compose exec db psql -U firefly -d agentdb \
|
|
-c "SELECT connector, status, finished_at FROM sync_runs ORDER BY id DESC LIMIT 5;"
|
|
```
|
|
|
|
- [ ] **Step 8: Add one line to the repo-root `README.md`**
|
|
|
|
```markdown
|
|
# Firefly III - jbnel.dev Finance Stack
|
|
|
|
`agent/` — personal accounting agent worker; see `agent/README.md` and `docs/superpowers/specs/`.
|
|
```
|
|
|
|
- [ ] **Step 9: Run the full test suite one more time**
|
|
|
|
Run: `cd agent && pytest -v` — Expected: all PASS
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add agent/Dockerfile agent/.dockerignore agent/README.md docker-compose.yml .env.example README.md
|
|
git commit -m "feat(agent): containerize agent and wire into compose stack"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Production setup and deploy (human-in-the-loop with Jake)
|
|
|
|
No new code. This task configures Firefly and deploys the agent. Steps marked **[Jake]** need his hands (credentials, UI clicks); the rest can be driven together over SSH once the workflow is confirmed.
|
|
|
|
- [ ] **Step 1: [Jake] Confirm the deploy workflow**
|
|
|
|
The plan assumes: SSH to the host running the stack, `git pull` in the repo directory, `docker compose` commands there. Confirm or correct; record the actual workflow in `agent/README.md` if it differs.
|
|
|
|
- [ ] **Step 2: [Jake] Create the Personal Access Token**
|
|
|
|
Firefly UI (`https://firefly.jbnel.dev`) → **Options → Profile → OAuth → Personal Access Tokens → Create new token**, name it `agent`. Copy the token (shown once).
|
|
|
|
- [ ] **Step 3: [Jake] Create asset accounts in Firefly**
|
|
|
|
Firefly UI → **Accounts → Asset accounts → Create**. One per real account; suggested starting set (adjust to reality): NFCU Checking, NFCU Savings, TFCU Checking, Cash App, Venmo, PayPal. Opening balances optional — Phase 2 ingestion will populate transactions.
|
|
|
|
- [ ] **Step 4: [Jake] Add agent variables to the host `.env`**
|
|
|
|
Append the "Accounting Agent" section from `.env.example` to the host's `.env`, with the real token in `AGENT_FIREFLY_TOKEN`.
|
|
|
|
- [ ] **Step 5: Deploy**
|
|
|
|
On the host, in the repo directory:
|
|
|
|
```bash
|
|
git pull
|
|
docker compose build agent
|
|
docker compose up -d agent
|
|
docker compose ps agent # wait for status: healthy
|
|
```
|
|
|
|
- [ ] **Step 6: Verify**
|
|
|
|
```bash
|
|
docker compose exec agent python -m agent.verify
|
|
```
|
|
|
|
Expected: `agentdb: OK (1 migration(s) applied)`, a `heartbeat ok` run, `firefly: OK (version 6.x)`, and the asset accounts from Step 3 listed. If `firefly:` shows 401, the token is wrong; if connection refused, check `FIREFLY_API_URL=http://app:8080`.
|
|
|
|
- [ ] **Step 7: Confirm the schedule is live**
|
|
|
|
After one heartbeat interval (default 60 min — or set `HEARTBEAT_INTERVAL_MINUTES=2` temporarily and `docker compose up -d agent` to recreate):
|
|
|
|
```bash
|
|
docker compose exec db psql -U firefly -d agentdb \
|
|
-c "SELECT connector, status, finished_at FROM sync_runs ORDER BY id DESC LIMIT 5;"
|
|
```
|
|
|
|
Expected: multiple `heartbeat | ok` rows with advancing timestamps. Restore the interval if it was lowered.
|
|
|
|
- [ ] **Step 8: Close out Phase 1**
|
|
|
|
Mark this plan's checkboxes done and commit any runbook corrections discovered during deploy:
|
|
|
|
```bash
|
|
git add -A && git commit -m "docs(agent): record actual deploy workflow from first production deploy"
|
|
```
|
|
|
|
Phase 1's definition of done: the agent container runs healthy on the host, heartbeats accumulate in `sync_runs`, and `python -m agent.verify` passes against production Firefly with real asset accounts listed.
|