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
26 changes: 4 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ jobs:
with:
python-version: "3.11"
- name: Install uv
run: pip install uv==0.5.*
run: pip install "uv==0.11.*"
- name: uv sync
run: uv sync --frozen --all-extras
# --all-packages --all-groups so each workspace member's dev group
# (ruff, pytest, aiosqlite) is installed, not just the root project.
run: uv sync --frozen --all-extras --all-packages --all-groups
- name: ruff check
run: uv run ruff check .
- name: ruff format --check
Expand Down Expand Up @@ -63,23 +65,3 @@ jobs:
run: npm run test
- name: vite build
run: npm run build

deploy:
# Only run on direct push to main and only if the deploy token is configured.
# Forks / PR builds don't have access to secrets so this skips automatically.
needs: [backend, frontend]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency: deploy-${{ github.ref }}
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Ensure FLY_API_TOKEN is set
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
run: |
if [ -z "$FLY_API_TOKEN" ]; then
echo "FLY_API_TOKEN is not set; skipping deploy. Add the secret to enable."
exit 0
fi
flyctl deploy --config deploy/fly.toml --remote-only
4 changes: 1 addition & 3 deletions apps/api/field_notes_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,7 @@ async def require_api_key_query(
if hmac.compare_digest(key, expected):
global _warned_raw_key
if not _warned_raw_key:
logger.warning(
"DEPRECATED: /events ?key= used; switch to POST /sse-token + ?token=."
)
logger.warning("DEPRECATED: /events ?key= used; switch to POST /sse-token + ?token=.")
_warned_raw_key = True
return
raise HTTPException(
Expand Down
1 change: 1 addition & 0 deletions apps/api/field_notes_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def _normalize_pg_url(cls, v: str) -> str:
if isinstance(v, str) and v.startswith("postgres://"):
return v.replace("postgres://", "postgresql+asyncpg://", 1)
return v

field_notes_cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
# SSE keepalive interval. Overridable in tests so we don't wait 25s for a keepalive assertion.
field_notes_sse_keepalive_seconds: float = 25.0
Expand Down
12 changes: 6 additions & 6 deletions apps/api/field_notes_api/events_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@
from __future__ import annotations

import asyncio
import contextlib
import json
import logging
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from datetime import UTC, datetime

from field_notes_schema import EventEnvelope

logger = logging.getLogger(__name__)


def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()


class EventBus:
Expand Down Expand Up @@ -53,15 +54,14 @@ def _drain_and_resync(self, q: asyncio.Queue[str]) -> None:
except asyncio.QueueEmpty:
break
logger.warning(
"events_bus: subscriber queue full; dropped %d pending event(s) and emitted resync sentinel (subscriber=%s)",
"events_bus: subscriber queue full; dropped %d pending event(s) and "
"emitted resync sentinel (subscriber=%s)",
dropped,
id(q),
)
# Queue is now empty; this put_nowait cannot raise QueueFull.
try:
with contextlib.suppress(asyncio.QueueFull): # pragma: no cover — defensive
q.put_nowait(self._resync_msg())
except asyncio.QueueFull: # pragma: no cover — defensive
pass

async def publish(self, env: EventEnvelope) -> None:
msg = env.model_dump_json()
Expand Down
1 change: 1 addition & 0 deletions apps/api/field_notes_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async def get_response(self, path, scope): # type: ignore[override]
response.headers["Cache-Control"] = "no-cache"
return response


settings = get_settings()

app = FastAPI(title="Field Notes API", version="0.1.0")
Expand Down
7 changes: 2 additions & 5 deletions apps/api/field_notes_api/routers/cells.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,7 @@ async def create_cell(
# the cell, regardless of what `body.status` (or kind) wanted. This is the
# invariant; cell status reflects review state, not agent self-reporting.
status_value: str | None
if _source(request) == "mcp":
status_value = CellStatus.open.value
elif body.kind == CellKind.empty:
if _source(request) == "mcp" or body.kind == CellKind.empty:
status_value = CellStatus.open.value
else:
status_value = body.status.value if body.status is not None else None
Expand Down Expand Up @@ -403,8 +401,7 @@ async def patch_visual_sandbox(
raise HTTPException(
status_code=422,
detail=(
f"`find` appears {actual} time(s) in visual.sandbox.{body.target}, "
f"expected_count={body.expected_count}"
f"`find` appears {actual} time(s) in visual.sandbox.{body.target}, expected_count={body.expected_count}"
),
)
new_visual = dict(c.visual)
Expand Down
3 changes: 1 addition & 2 deletions apps/api/field_notes_api/routers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@
import json
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, Query
from field_notes_schema import EventEnvelope
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse

from datetime import UTC, datetime

from ..auth import SSE_TOKEN_TTL_SECONDS, mint_sse_token, require_api_key, require_api_key_query
from ..config import get_settings
from ..db import get_session
Expand Down
4 changes: 1 addition & 3 deletions apps/api/field_notes_api/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from pathlib import Path

from sqlalchemy import func, select, text

from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine

Expand Down Expand Up @@ -94,8 +93,7 @@ async def import_db(source_url: str, dest_url: str, *, overwrite: bool = False)
if existing:
if not overwrite:
raise RuntimeError(
f"dest table {table.name} is not empty ({existing} rows); "
"pass overwrite=True to replace"
f"dest table {table.name} is not empty ({existing} rows); pass overwrite=True to replace"
)
await dc.execute(table.delete())
# Copy forward (parent→child).
Expand Down
38 changes: 29 additions & 9 deletions apps/api/tests/test_cells.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ async def test_patch_sandbox_rejects_missing_find(client, project_id) -> None:

async def test_patch_sandbox_404_on_unknown_cell(client) -> None:
import uuid as _u

r = await client.post(
f"/cells/{_u.uuid4()}/visual-sandbox/patch",
json={"target": "html", "find": "x", "replace": "y"},
Expand All @@ -250,7 +251,9 @@ async def test_patch_sandbox_404_on_unknown_cell(client) -> None:

async def test_patch_sandbox_422_on_non_sandbox_visual(client, project_id) -> None:
c = await _create_cell(
client, project_id, title="S",
client,
project_id,
title="S",
visual={"kind": "svg", "source": "<svg/>"},
)
r = await client.post(
Expand All @@ -276,7 +279,11 @@ async def test_patch_sandbox_locked_returns_409(client, project_id) -> None:
async def test_patch_visual_preserves_unspecified_sandbox_fields(client, project_id) -> None:
"""PATCH /cells/{id} with only `html` must not clobber existing js/css."""
c = await _create_sandbox(
client, project_id, html="<h1>orig</h1>", js="console.log('j')", css="body{}",
client,
project_id,
html="<h1>orig</h1>",
js="console.log('j')",
css="body{}",
)
r = await client.patch(
f"/cells/{c['id']}",
Expand All @@ -292,7 +299,11 @@ async def test_patch_visual_preserves_unspecified_sandbox_fields(client, project
async def test_patch_visual_replaces_on_kind_change(client, project_id) -> None:
"""Switching kind (sandbox -> svg) must replace fully, not merge."""
c = await _create_sandbox(
client, project_id, html="<h1>h</h1>", js="j", css="c",
client,
project_id,
html="<h1>h</h1>",
js="j",
css="c",
)
r = await client.patch(
f"/cells/{c['id']}",
Expand All @@ -310,7 +321,11 @@ async def test_patch_visual_empty_string_does_not_overwrite(client, project_id)
"""Explicit "" is the schema default and indistinguishable from unset;
must NOT clobber existing content."""
c = await _create_sandbox(
client, project_id, html="<h1>keep</h1>", js="keepjs", css="keepcss",
client,
project_id,
html="<h1>keep</h1>",
js="keepjs",
css="keepcss",
)
r = await client.patch(
f"/cells/{c['id']}",
Expand Down Expand Up @@ -376,7 +391,9 @@ async def test_append_initializes_visual_if_none(client, project_id) -> None:

async def test_append_rejects_kind_change(client, project_id) -> None:
c = await _create_cell(
client, project_id, title="S",
client,
project_id,
title="S",
visual={"kind": "svg", "source": "<svg/>"},
)
r = await client.post(
Expand Down Expand Up @@ -464,9 +481,7 @@ async def test_create_http_status_passes_through(client, project_id) -> None:
assert r.json()["status"] == "rejected"


async def test_patch_mcp_status_forced_open_even_when_body_says_verified(
client, project_id
) -> None:
async def test_patch_mcp_status_forced_open_even_when_body_says_verified(client, project_id) -> None:
# Seed cell + verdict (so we can confirm verdict relationship is untouched).
c = await _create_cell(client, project_id, title="A", deep={"na": True})
vr = await client.post(f"/cells/{c['id']}/verdict", json={"state": "accept", "note": "ok"})
Expand Down Expand Up @@ -633,7 +648,12 @@ def _vid(url: str) -> dict:
async def test_mcp_agent_video_ephemeral_url_rejected(client, project_id) -> None:
r = await client.post(
f"/projects/{project_id}/cells",
json={"kind": "agent", "title": "V", "deep": {"na": True}, "video": _vid("https://foo.trycloudflare.com/c.mp4")},
json={
"kind": "agent",
"title": "V",
"deep": {"na": True},
"video": _vid("https://foo.trycloudflare.com/c.mp4"),
},
headers=MCP,
)
assert r.status_code == 422, r.text
Expand Down
Loading
Loading