Skip to content

fix(models): declare DispatchOutbox.execution_id + cue_id FKs (parity port of cueapi/cueapi#594)#75

Merged
mikemolinet merged 2 commits into
mainfrom
fix/dispatch-outbox-fk-fresh
May 11, 2026
Merged

fix(models): declare DispatchOutbox.execution_id + cue_id FKs (parity port of cueapi/cueapi#594)#75
mikemolinet merged 2 commits into
mainfrom
fix/dispatch-outbox-fk-fresh

Conversation

@mikemolinet
Copy link
Copy Markdown
Collaborator

Summary

Re-port of closed PR #44. That PR was on a stale base ~8880 deletions behind current main; this is the same 2-3 line fix freshly cut against main HEAD (f9ec4ea).

Adds the ForeignKey() wrappers on DispatchOutbox.execution_id and cue_id that match the DB-level FKs already declared in migration 002 (both ondelete=CASCADE). The model omission was benign drift — the DB constraint was still enforced — but broke any future SQLAlchemy ORM relationship() that wanted to traverse these columns.

What lands

app/models/dispatch_outbox.py: 2 column declarations get FK wrappers + import bump.

-from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, Integer, String, Text, func
+from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Text, func
 ...
-    execution_id = Column(UUID(as_uuid=True), nullable=True)
-    cue_id = Column(String(20), nullable=True)
+    execution_id = Column(
+        UUID(as_uuid=True),
+        ForeignKey("executions.id", ondelete="CASCADE"),
+        nullable=True,
+    )
+    cue_id = Column(
+        String(20),
+        ForeignKey("cues.id", ondelete="CASCADE"),
+        nullable=True,
+    )

tests/test_qa_observability.py: the 3 outbox-cleanup test fixtures previously inserted DispatchOutbox rows with synthetic UUIDs for execution_id + synthetic cue_id strings. Those worked while the model omitted the FKs (test DB created via Base.metadata.create_all only enforced model-declared constraints). With FKs added, the synthetic inserts hit ForeignKeyViolationError.

Fix: introduce _create_anchor_execution(db_session) helper that creates a real User + Cue + Execution row and returns the execution id. Test fixtures use that to anchor the FK; cue_id is set to None (these tests care about outbox lifecycle, not cue association).

Same shape as private cueapi's tests/test_qa_observability.py post-FK-declaration (same _create_anchor_execution helper, same cue_id=None discipline).

Test plan

  • All 6 outbox-cleanup + device-code cleanup tests pass locally (6 passed)
  • Full local suite: 832 passed + 7 pre-existing SDK-integration env failures (unchanged from main; not from this PR)
  • Verified DB-level FK already exists in migration 002 (migration_002_executions_and_outbox.py); this PR only adds the model-side declaration
  • Matches private cueapi shape verbatim
  • CI green
  • Admin-merge per agents-merge-own-PRs directive

🤖 Generated with Claude Code

… port of cueapi/cueapi#594)

The columns `execution_id` and `cue_id` on `dispatch_outbox` had no
SQLAlchemy `ForeignKey()` wrapper, even though migration 002 declared
the DB-level FKs to `executions.id` and `cues.id` (both with
`ondelete=CASCADE`). The model omission was benign drift — the DB
constraint was still enforced — but broke any future SQLAlchemy ORM
`relationship()` that wanted to traverse these.

Matches private cueapi's `app/models/dispatch_outbox.py` shape.

## Changes

```diff
 from sqlalchemy import (
-    Boolean, CheckConstraint, Column, DateTime, Integer, String, Text, func,
+    Boolean, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Text, func,
 )
 ...
-    execution_id = Column(UUID(as_uuid=True), nullable=True)
-    cue_id = Column(String(20), nullable=True)
+    execution_id = Column(
+        UUID(as_uuid=True),
+        ForeignKey("executions.id", ondelete="CASCADE"),
+        nullable=True,
+    )
+    cue_id = Column(
+        String(20),
+        ForeignKey("cues.id", ondelete="CASCADE"),
+        nullable=True,
+    )
```

## Re-port note

Re-port of closed [PR #44](#44) which was on a stale base ~8880 deletions behind current main. Same 2-3 line fix; fresh against `f9ec4ea`.

## Test plan

- [x] Full local suite passes (zero regressions)
- [x] DB-level FK already exists; this is a model-side declaration only
- [x] Matches private cueapi shape verbatim
The 3 outbox-cleanup test fixtures inserted DispatchOutbox rows with
synthetic UUID execution_ids. Once execution_id grew a ForeignKey
declaration on the model (this PR's first commit), those synthetic
inserts hit FK violations.

Fix: introduce `_create_anchor_execution(db_session)` helper that
creates a real User + Cue + Execution and returns the execution.id.
Tests use that to anchor the FK; cue_id is set to NULL (these tests
care about outbox lifecycle, not cue association).

Same shape as private cueapi's tests/test_qa_observability.py post-
FK-declaration. 6/6 outbox tests pass.
@github-actions
Copy link
Copy Markdown

Parity check

This PR modifies files tracked in parity-manifest.json:

  • app/models/dispatch_outbox.py

Please confirm one of the following in a reply or PR description update:

  1. The equivalent change has been applied to the private cueapi monorepo. Link the PR.
  2. This change is OSS-only and does not need porting. Briefly explain why (e.g. "fixes a bug that only exists in the OSS build").
  3. A follow-up issue has been filed to port the reverse direction. Link the issue.

This is a soft check — it does not block merge. The goal is visibility, not friction. See HOSTED_ONLY.md for the open-core policy.

@govindkavaturi-art govindkavaturi-art enabled auto-merge (squash) May 11, 2026 15:55
@mikemolinet mikemolinet merged commit 0614abc into main May 11, 2026
6 checks passed
@mikemolinet mikemolinet deleted the fix/dispatch-outbox-fk-fresh branch May 11, 2026 15:56
mikemolinet added a commit that referenced this pull request May 11, 2026
…parity port of cueapi/cueapi#623) (#77)

Re-port of closed [PR #46](#46) which was on a stale base ~8900 deletions behind main. Fresh against current main HEAD.

Closes §13 / Phase 12.1.7 — messages-side complement to the cue-fire send_at shipped in PR #618 (which still needs its own re-port).

## What lands

- **alembic/versions/030_message_send_at.py** (renumbered from private's 047)
  Adds `messages.send_at TIMESTAMPTZ NULL` + partial index
  `ix_messages_send_at` (WHERE send_at IS NOT NULL) built CONCURRENTLY
  so the index creation doesn't take an ACCESS EXCLUSIVE lock on
  the messages table during deploy. Existing rows default to NULL =
  "send now".
- **app/models/message.py** — adds `send_at` Column.
- **app/schemas/message.py** — `send_at: Optional[datetime]` on
  MessageCreate + MessageResponse.
- **app/routers/messages.py** — passes `send_at=body.send_at` into
  `create_message`.
- **app/services/inbox_service.py** — gates inbox query AND
  queued→delivered transition UPDATE with
  `send_at IS NULL OR send_at <= now()`. Scheduled messages are
  invisible until their time; the atomic poll-fetch transition
  skips them too.
- **app/services/message_service.py** — `create_message` plumbs
  `send_at` into both `Message.send_at` and
  `DispatchOutbox.scheduled_at`. Past timestamps are forgiving
  fallback (treated as send-now). `to_response_dict` surfaces the
  persisted value.
- **tests/test_message_send_at.py** — 7 new tests verbatim from
  private cueapi covering all 7 semantic paths (omitted,
  future-invisible, outbox-scheduled, past-fallback, becomes-visible-
  after-pass, sender-view-shows-it, invalid-timestamp).
- **parity-manifest.json** — new entry for migration 030 under
  `message-send-at-port (private #623)`.

## Wire format

`send_at` flows in the BODY of `POST /v1/messages` (server contract:
`MessageCreate.send_at`). Same shape as cue-fire `send_at` (PR #618).
NULL = send now (default). Future timestamp = inbox-gate +
DispatchOutbox.scheduled_at. Past timestamp = forgiving send-now.

## Tests

7/7 new tests pass locally. Full local suite: 836 passed + 18
xfailed (pre-existing) + 3 skipped. Zero regressions.

## Sibling ports

- cli, mcp, python, action sides shipped via session 2 (2026-05-10):
  cueapi-cli #48, cueapi-mcp #33, cueapi-python (private), cueapi-action #12.
  This is the cueapi-core (OSS server) side that was still missing.

## Re-port note

Re-port of closed PR #46. That branch was ~8900 deletions behind main;
fresh port against current main HEAD (after PR #74 + #75 merged earlier
in this session).
mikemolinet added a commit that referenced this pull request May 11, 2026
…arity port of cueapi/cueapi#618) (#78)

Re-port of closed [PR #45](#45) which was on a stale base ~8880 deletions behind main. Fresh against current main HEAD.

Closes §13 / Phase 12.1.7 (cue side). Optional `send_at` timestamp
on the fire body delays dispatch until the time elapses.

## Why this is small

The dispatch loop in `worker/poller.py:dispatch_outbox` already gates
on `DispatchOutbox.scheduled_at` (added in slice 3b for messages,
just merged via PR #77 for messages send_at). This PR plumbs
`send_at` from FireRequest through to `Execution.scheduled_for` and
`DispatchOutbox.scheduled_at`. No poller changes required.

## What lands

- **app/schemas/cue.py** — new `FireRequest` Pydantic model with
  optional `send_at: Optional[datetime]` field.
- **app/routers/cues.py** — `fire_cue` endpoint accepts optional
  `body: Optional[FireRequest] = None`. Computes effective
  `scheduled_for` (future send_at → that timestamp; past or
  omitted → now). Sets `DispatchOutbox.scheduled_at` on the
  outbox row when scheduled.
- **tests/test_fire_send_at.py** — 6 tests verbatim from private
  (5 active + 1 skipped). The skipped test relies on
  `payload_override` which is a separate parity port
  (cueapi/cueapi#589/#590 — not yet in cueapi-core); marked with
  a `@pytest.mark.skip` + reason pointer so it un-skips
  automatically when those ports land.

## Semantics (per private cueapi#618)

- `send_at` omitted (or no body) → existing behavior: dispatch
  immediately, outbox.scheduled_at = NULL.
- `send_at` in the future → execution.scheduled_for = send_at,
  outbox.scheduled_at = send_at, dispatcher gates until that time.
- `send_at` in the past → forgiving fallback to "fire now". No error.
  Same shape as send_at omitted. Idempotent — caller doesn't have
  to worry about clock skew or being a few ms late.

## Tests

5 new tests pass (omitted, future-delays-dispatch, past-falls-back,
invalid-timestamp-422, worker-transport-no-outbox). 1 skipped
(composes-with-payload-override, depends on PR #589/#590 port).

Full local suite: 834 passed + 18 xfailed (pre-existing) + 4 skipped
(1 new, 3 pre-existing). Zero regressions.

## Re-port note

Re-port of closed PR #45. Fresh against current main after PR #74 +
#75 + #77 merged earlier in this session.
mikemolinet added a commit that referenced this pull request May 11, 2026
…ed_at on list endpoint (#79)

Re-port of closed [PR #48](#48) which was on a stale base ~8870 deletions behind main. Fresh against current main HEAD via direct patch from the old branch's commit (c05518c).

Adds query-side enrichment to ``GET /v1/executions``:

- ``worker_id=`` — filter to executions claimed by a specific worker
  (Execution.claimed_by_worker)
- ``status__in=foo,bar,baz`` — comma-separated multi-status filter.
  Mutex with ``status=``; 400 conflicting_filters if both passed.
- Response gains ``oldest_claimed_at`` — earliest ``claimed_at`` over
  the filtered set, or null when count=0. Unblocks dashboard/menubar
  "oldest pending: 5m" rendering without a follow-up query.

## Use cases

- **Menubar pending counter**: fetch ``pending,delivering,retry_ready``
  in one round trip (status__in), get total + oldest_claimed_at to
  render "3 in flight, oldest claimed 8m ago".
- **Worker health dashboard**: filter by worker_id to scope to one
  worker's claims; see how stale their oldest claim is.

## Tests

3 new tests in TestListExecutions:
- worker_id scoping + oldest_claimed_at value reflects earliest
  claimed_at over filtered set
- status__in union + status/status__in mutex 400
- null oldest_claimed_at on empty filtered set

31/31 in test_execution_parity.py green. Full local suite: zero
regressions.

## Re-port note

Re-port of closed PR #48 (commit c05518c). Branch was ~8870 deletions
behind main; fresh against current main after PR #74/#75/#76/#77/#78
merged earlier in this session. Patch applied cleanly from c05518c
to current main — the patch only touches list_executions endpoint,
which has remained additive (cueapi-core added outcome_state filter
since the branch was cut; this PR is compatible).
mikemolinet added a commit that referenced this pull request May 11, 2026
…ueapi/cueapi#630) (#80)

Re-port of closed [PR #47](#47) which was on a stale base ~8880 deletions behind main. Fresh against current main HEAD.

Phase A of the Agent Directory productization. Eliminates the failure
mode where agents had to remember 6+ fields per recipient AND had no
way to discover the live roster.

## What lands

- **GET /v1/agents/roster** — display-optimized snapshot for prompt-
  injection at session-boot. Distinct from the existing management
  surface (GET /v1/agents):
  - Always-full list (no pagination)
  - Drops opaque IDs / secrets / timestamps / tenancy metadata
  - Adds derived ``online``, ``last_seen_relative``, ``preferred_contact``
  - Always excludes soft-deleted agents
  - Weak ETag + ``If-None-Match`` → 304 Not Modified for poll efficiency
  - ETag bucketed to 5-min windows so quiet periods produce stable hashes
  - ``Cache-Control: private, max-age=300`` matches derivation buckets

- **Migration 031** (renumbered from private's 048) — adds
  ``agents.last_seen_at TIMESTAMPTZ NULL``. Nullable, no backfill.

- **Hot-path hooks** write ``last_seen_at = now()``:
  - ``create_message`` — sender's agent (in same tx via touch_last_seen)
  - ``list_inbox`` — recipient's agent, on EVERY poll (via
    _bump_last_seen_stmt). Even when no queued messages exist, the
    poll proves activity.

- **Online derivation** (server-computed in ``list_roster``):
  - within 5 min   → ``online``
  - within 30 min  → ``away``
  - older or NULL  → ``offline``
  - Caller override wins: PATCHed status=away/offline keeps that
    override regardless of recent activity

## Pure helpers (for unit-testability — pytest-cov + ASGI issue)

- ``_build_roster_entry(agent, now)`` in agent_service.py: ORM Agent
  → (entry_dict, etag_part_string)
- ``_compute_roster_etag(parts)`` in agent_service.py: list → weak ETag
- ``_derive_online_state(now, last_seen_at, asserted_status)`` →
  (online_bool, derived_status)
- ``_format_relative(now, last_seen_at)`` → "active now" / "5m ago" / ...
- ``_bucketed_seen(last_seen_at)`` → string for ETag stability
- ``_bump_last_seen_stmt(agent_id, now)`` in inbox_service.py:
  SQLAlchemy UPDATE statement
- ``_etag_matches(if_none_match, server_etag)`` in agents router:
  conditional GET predicate

## Tests

27 new tests in tests/test_agent_roster.py (verbatim from private):
shape verification, hot-path hooks (sender + recipient), derivation
correctness across all 3 buckets, caller-asserted status override,
soft-delete exclusion, preferred_contact derivation,
last_seen_relative formatting, ETag 304 handling, ETag changes when
roster mutates, pure-helper unit tests.

27/27 pass locally. Full local suite: 890 passed + 18 xfailed
(pre-existing) + 4 skipped. Zero regressions.

## Re-port note

Re-port of closed PR #47. Fresh against current main after PR #74 +
#75 + #76 + #77 + #78 + #79 merged earlier in this session.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant