Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/skills/verify-call-agent/features/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Preconditions:

## Gotchas

- SQLite `users` is the same file as `dids`. On Fly, set `NEON_DATABASE_URL` or the row dies on redeploy.
- SQLite `users` is the same file as `dids`. On Fly, set `NEON_DATABASE_URL` or the row dies on redeploy. The Fly image installs `psycopg[binary]` from pyproject; a hot-install does not survive the next image deploy.
- `account upsert` does not need `ACCOUNT_SYNC_SECRET`. The HTTP routes do.
- Kickoff `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`. Confirm/dial never calls `ledger.provision`. The iMessage bot is `LINQ_FROM_E164`. Kickoff ignores `users.from_did`.
- `from_did` must not equal `phone`. Personal number is not the Telnyx from-number. Two accounts may share one legacy `from_did`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ Preconditions:
- `account_roundtrip` upserts a `users` row on the same SQLite file as `dids`.
- `verify-quick-ask` still uses a throwaway SQLite workspace. Do not point it at Neon.
- A leftover `R2_ACCOUNT_ID` in the shell is enough to fail the lever. Unset the four required `R2_*` vars and `NEON_DATABASE_URL` before a stub drive.
- Fly `hidden-beacon-080` needs `psycopg[binary]` when `NEON_DATABASE_URL` is set. pyproject lists that extra as a package dependency and the Dockerfile `pip install .` plus `import psycopg` so the next image deploy includes it. A machine-local `pip install` is wiped on redeploy.
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ CALL_AGENT_ARTIFACTS_DIR=./artifacts

# Durable ledger. When set, did provision/list and users (accounts)
# use Neon (Postgres) with the same dids and users schemas. Unset or
# blank keeps SQLite.
# pip install "psycopg[binary]" before setting this.
# blank keeps SQLite. The package depends on psycopg[binary]; the Fly
# image installs it via pip install . Do not hot-install on the machine.
NEON_DATABASE_URL=

# After hangup, default wipe deletes the run-dir audio, transcript, and
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ COPY pyproject.toml .
COPY call_agent/ call_agent/

RUN pip install --no-cache-dir . \
&& python -c "import psycopg" \
&& mkdir -p /app/artifacts

ENV CALL_AGENT_ARTIFACTS_DIR=/app/artifacts
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ call-agent book --to +14155552671 --name Nathan --party 2 --time "Friday 7pm" --

`quick-ask` prints one JSON object with `status` (`answered`, `declined`, `no_answer`, or `failed`), `answer_text`, `call_control_id`, `transcript_ref`, and `audio_ref`. `book` prints `status` (`booked`, `declined`, `no_answer`, or `failed`) and `confirmation_text` on a booked result. The refs are relative to `CALL_AGENT_ARTIFACTS_DIR`. During the call, each run writes one directory there with `dial.json`, `session_start.json`, `events.jsonl`, `transcript.json`, `audio/inbound.wav`, `audio/outbound.wav`, `hangup.json`, `result.json`, `call_report.json`, and `call_report.redacted.json`. After hangup, those files are deleted unless `RETAIN_CALL_ARTIFACTS` is set or the request sets `retain_call_artifacts=True`. Crash leftovers are accepted. `verify-quick-ask` and `verify-book` retain so they can prove hangup and transcript shape.

Laptop and stub mode keep the ledger in `CALL_AGENT_DB_PATH` (SQLite). When `NEON_DATABASE_URL` is set, `did provision` and `did list` use Neon with the same `dids` table. When retention is on and `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_BUCKET` are all set, `run_call` also puts `transcript.json`, `result.json`, `call_report.json`, and both audio files to that bucket. Default wipe skips that publish. `events.jsonl`, `dial.json`, and `call_report.redacted.json` stay on the local disk when retained. A partial R2 set fails before the call starts. Install `psycopg[binary]` before setting the Neon URL.
Laptop and stub mode keep the ledger in `CALL_AGENT_DB_PATH` (SQLite). When `NEON_DATABASE_URL` is set, `did provision` and `did list` use Neon with the same `dids` table. When retention is on and `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_BUCKET` are all set, `run_call` also puts `transcript.json`, `result.json`, `call_report.json`, and both audio files to that bucket. Default wipe skips that publish. `events.jsonl`, `dial.json`, and `call_report.redacted.json` stay on the local disk when retained. A partial R2 set fails before the call starts. The package depends on `psycopg[binary]`, so `pip install .` (and the Fly image) includes the Neon driver. Do not hot-install on the machine.

## Read a call

Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ Neon holds the DID ledger when `NEON_DATABASE_URL` is set. R2 holds the finished
| name | status | does | proof |
| --- | --- | --- | --- |
| SQLite ledger fallback | shipped | `parse_ledger_config` returns `SqliteLedgerConfig` when `NEON_DATABASE_URL` is unset or blank. `open_ledger` constructs `Ledger`. | `call_agent/persistence.py` `parse_ledger_config`. `call_agent/ledger.py` `open_ledger`. `tests/test_persistence.py` `test_unset_env_selects_sqlite_and_local`. `tests/test_ledger.py` `test_open_ledger_unset_returns_sqlite` |
| Neon ledger | shipped | `NEON_DATABASE_URL` selects `NeonLedger` / `NeonAccounts` and the same `dids` and `users` schemas. Provision stays idempotent. Account upsert is idempotent on phone. A blank or schemeless URL fails loud. Missing `psycopg` fails loud. | `call_agent/ledger.py` `NeonLedger` `DIDS_SCHEMA`. `call_agent/accounts.py` `NeonAccounts` `USERS_SCHEMA`. `call_agent/persistence.py` `NeonLedgerConfig`. `tests/test_ledger.py` `test_neon_ledger_roundtrip_through_sqlite_bridge`. `tests/test_accounts.py` `test_neon_accounts_roundtrip_through_sqlite_bridge`. `tests/test_persistence.py` `test_neon_url_without_scheme_fails_loud` |
| Neon ledger | shipped | `NEON_DATABASE_URL` selects `NeonLedger` / `NeonAccounts` and the same `dids` and `users` schemas. Provision stays idempotent. Account upsert is idempotent on phone. A blank or schemeless URL fails loud. Missing `psycopg` fails loud. Package depends on `psycopg[binary]`. The Fly Dockerfile `pip install .` includes it and fails the image build if the import is missing. Do not hot-install on the machine. | `pyproject.toml` `psycopg[binary]`. `Dockerfile`. `call_agent/ledger.py` `NeonLedger` `DIDS_SCHEMA`. `call_agent/accounts.py` `NeonAccounts` `USERS_SCHEMA`. `call_agent/persistence.py` `NeonLedgerConfig`. `tests/test_ledger.py` `test_neon_ledger_roundtrip_through_sqlite_bridge` `test_psycopg_binary_is_on_the_image_path`. `tests/test_accounts.py` `test_neon_accounts_roundtrip_through_sqlite_bridge`. `tests/test_persistence.py` `test_neon_url_without_scheme_fails_loud` |
| Local artifact fallback | shipped | `parse_artifact_config` returns `LocalArtifactConfig` when no `R2_*` var is set. `LocalArtifactStore` put/get writes under `CALL_AGENT_ARTIFACTS_DIR`. | `call_agent/persistence.py` `parse_artifact_config`. `call_agent/artifacts.py` `LocalArtifactStore`. `tests/test_artifacts.py` `test_local_artifact_put_get_roundtrip` `test_open_artifact_store_defaults_local` |
| R2 artifacts | shipped | Complete `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_BUCKET` selects `R2ArtifactStore`. Put/get use the S3 API. Partial env raises `PersistenceConfigError`. Object keys and run-dir names use `artifact_slug` (`:` → `_`); raw `call_control_id` stays in data fields. `sign_r2_request` signs `raw_path`. When `retain_call_artifacts` is true, `run_call` publishes transcript, result, `call_report.json`, and both audio files after hangup. Publish failure writes `publish_error.json` and still returns the call result. Default wipe skips that publish and deletes the local run dir. `events.jsonl` stays local when retained. | `call_agent/artifacts.py` `artifact_slug` `R2ArtifactStore` `publish_durable_artifacts` `DURABLE_RELATIVE` `wipe_run_dir` `sign_r2_request`. `call_agent/runtime.py` `run_call`. `call_agent/report.py` `CallReport.artifact_prefix`. `tests/test_artifacts.py` `test_r2_put_get_with_httpx_mock` `test_sign_r2_request_uses_raw_path` `test_artifact_slug_sanitizes_colon_and_keeps_safe_chars`. `tests/test_runtime.py` `test_artifact_key_slug_colon_ccid_keeps_raw_in_result` `test_publish_failure_keeps_result`. `tests/test_privacy.py` `test_wipe_skips_durable_publish`. `tests/test_persistence.py` `test_partial_r2_fails_loud_and_names_missing` |
| `artifact_slug` | shipped | `re.sub(r"[^A-Za-z0-9._-]", "_", ccid)` for every artifact path and R2 key. Raw ccid in `result.call_control_id` and `CallReport.call_control_id`. Stub can place `v3:stub_colon_0001`. | `call_agent/artifacts.py` `artifact_slug`. `call_agent/telnyx.py` `StubTelnyxHttp.placed_call_control_id`. `tests/test_runtime.py` `test_artifact_key_slug_colon_ccid_keeps_raw_in_result` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
"typer>=0.27,<1",
"uvicorn>=0.35,<1",
"websockets>=15,<16",
"psycopg[binary]>=3,<4",
]

[project.optional-dependencies]
Expand Down
15 changes: 15 additions & 0 deletions tests/test_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,18 @@ def fake_import(name: str, *args: object, **kwargs: object) -> object:
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(PersistenceConfigError, match="psycopg is not installed"):
_psycopg_connect("postgresql://user:pass@ep-test/neondb")()


def test_psycopg_binary_is_on_the_image_path() -> None:
import psycopg
from psycopg.rows import dict_row

repo = Path(__file__).resolve().parents[1]
pyproject = (repo / "pyproject.toml").read_text()
dockerfile = (repo / "Dockerfile").read_text()

assert '"psycopg[binary]>=3,<4"' in pyproject
assert "pip install --no-cache-dir ." in dockerfile
assert 'python -c "import psycopg"' in dockerfile
assert callable(psycopg.connect)
assert dict_row is not None
Loading