Sub-slice C — Queue endpoint, FE wiring, VCC poller
Sub-slice C of the worker roadmap. Deals start arriving from HubSpot and appear live on the admin queue panel — three bundled pieces, one PR.
Three pieces bundled into one slice: (1) a Procrastinate periodic task poll_hubspot that searches HubSpot for newly-checked-in deals, upserts them by hubspot_deal_id, and enqueues process_deal for newcomers; (2) a GET /admin/queue/deals endpoint that returns paginated per-deal rows with synthesized stage progress; (3) the Lovable-designed queue panel switched from its zustand mock to a generated TanStack Query hook with 2-second polling.
Approach A from brainstorming. B (standalone scheduler service) and C (split list/detail endpoints) rejected. After this slice the loop closes: deals enter the system on their own and are visible end-to-end.
Queue endpoint exposes the 4 leftmost stages only. review + done are pipeline internals the operator queue doesn't surface.
01 Context
Sub-slice B made deals processable end-to-end via an admin trigger. Sub-slice C closes the loop:
- Deals start arriving on their own (from HubSpot, the upstream source-of-truth).
- Operators see them live in the queue page Lovable already designed.
The Lovable mock (web/src/lib/queue-mock.ts + queue-store.ts) is the design target — same visual surface, real data. The FE today renders fields the backend doesn't model yet (step.params, step.output, step.logs[], deal input image, etc.); each one was triaged and either mapped, dropped for now, or flagged for a future slice (see §03).
The VCC poller is deliberately minimal: search HubSpot, create Deal rows, enqueue process_deal. No image-size gating, no terminal-stage skipping, no VCC appraisal-contents enrichment — those depend on tables (deal_images) and product knowledge that belong with later slices.
02 Scope
In scope
- Alembic
0007_add_hubspot_deal_id_and_queue_index—hubspot_deal_id(TEXT NOT NULL UNIQUE),hubspot_checked_in_at(TIMESTAMPTZ),updated_at DESCindex integrations/hubspot/new package:HubspotClient+ Pydantic types +HubspotErrorfeatures/deals/service.pyextended:DealSyncService,DealQueueServicefeatures/deals/schemas.pyextended:StageProgress,DealQueueRow,DealQueuePageworkers/tasks.pyaddspoll_hubspotperiodic taskrouters/admin.pyaddsGET /admin/queue/deals?limit&beforecore/settings.py:HUBSPOT_ACCESS_TOKEN,HUBSPOT_POLL_INTERVAL_MINUTES,HUBSPOT_BOOTSTRAP_DAYS- ~20 new backend tests across 6 new files + 4 edits
- FE:
lib/api/adapters/queue.ts(new);queue-store.ts+queue-mock.tsdeleted; route + components edited;IOTab/LogsTab/ErrorTabdeleted - MSW handler + fixture for the new endpoint
Out of scope (deferred)
- VCC appraisal-contents enrichment → needs
deal_imagestable - Terminal-stage filtering → needs maintainable allow-list home
- Small-image gate → depends on
deal_images - Per-step retry endpoint → failure-handling slice
step.params/step.output/ model+version → real-stage slice +stage_outputstablestep.logs[]→ observability slice- Deal input preview image →
deal_imagesport pytest-recordingcassette setup → contract suite later- Server-side filtering / search on queue endpoint → keep FE-only for now
- Index on
(current_stage, status)→ defer; "promote once stable" deals.raw_payload JSONB,deals.synced_at→ YAGNI
03 UI ↔ backend field audit
The Lovable mock renders many fields the new endpoint won't provide yet. Each row below: what the UI uses, whether the backend has it, and the chosen call. Anything dropped is recorded as a deferred UI capability with the slice that would unlock it.
Top-level (run / deal)
| UI field | Backend | Call |
|---|---|---|
| run.id | deal.id | map |
| run.dealId | deal.hubspot_deal_id | map |
| run.imageUrl | — | drop / defer — deal_images port |
| run.updatedAt | deal.updated_at | map |
| run.createdAt | deal.created_at | map |
Per-step (pipeline_run)
| UI field | Backend | Call |
|---|---|---|
| step.id / stage / status / startedAt / endedAt / errorMessage | pipeline_run.* + synthesized pending | map (status via adapter) |
| step.model / step.modelVersion | — | drop / defer — first real-stage slice |
| step.retries | — | drop / defer — failure-handling slice |
| step.params | — | drop / defer — first real-stage slice |
| step.output | — | drop / defer — stage_outputs table |
| step.errorStack | — | drop permanently — structlog captures server-side |
| step.logs[] | — | drop / defer — observability slice |
Actions
| UI element | Backend | Call |
|---|---|---|
| "Retry step" | — | drop / defer — failure-handling slice |
| "Re-run from scratch" | POST /admin/deals/{id}/process | wire to existing endpoint |
04 Schema migration
One migration: alembic/versions/0007_add_hubspot_deal_id_and_queue_index.py.
ALTER TABLE deals ADD COLUMN hubspot_deal_id TEXT; ALTER TABLE deals ADD COLUMN hubspot_checked_in_at TIMESTAMPTZ; -- backfill seeds in the same migration step: UPDATE deals SET hubspot_deal_id = 'seed-' || id::text WHERE hubspot_deal_id IS NULL; UPDATE deals SET hubspot_checked_in_at = created_at WHERE hubspot_checked_in_at IS NULL; ALTER TABLE deals ALTER COLUMN hubspot_deal_id SET NOT NULL; CREATE UNIQUE INDEX ix_deals_hubspot_deal_id ON deals (hubspot_deal_id); CREATE INDEX ix_deals_updated_at_desc ON deals (updated_at DESC); CREATE INDEX ix_deals_hubspot_checked_in_at ON deals (hubspot_checked_in_at DESC);
max(deals.hubspot_checked_in_at) − 2 days. The unique index on hubspot_deal_id absorbs duplicates from the overlap. One fewer table, fully debuggable shape.
TEXT for hubspot_deal_id, not bigint — HubSpot deal ids are documented as numeric strings. pipeline_runs unchanged.
05 HubSpot integration
Async HTTPX client ported from checkin-pipeline/app/hubspot.py — returns Pydantic models, raises a single HubspotError, no internal retry (periodic ticks are the retry strategy).
class HubspotDealSummary(BaseModel):
hubspot_deal_id: str
name: str | None = None
country: str | None = None
created_at: datetime | None = None
deal_stage: str | None = None
amount: float | None = None
checked_in_at: datetime # always present (the property we filter on)
class HubspotError(Exception):
"""Wraps HTTPX errors and non-2xx responses; carries response body when available."""
class HubspotClient:
def __init__(self, access_token: str, base_url: str = "https://api.hubapi.com") -> None: ...
async def search_checked_in_deals(
self,
since: datetime,
until: datetime,
) -> list[HubspotDealSummary]:
"""Iterate by day to stay under HubSpot's 10K-per-query limit; follow pagination cursors."""
Settings: HUBSPOT_ACCESS_TOKEN: SecretStr | None = None, HUBSPOT_POLL_INTERVAL_MINUTES = 5, HUBSPOT_BOOTSTRAP_DAYS = 30. SecretStr so the token can't appear in pydantic-settings dumps or structlog output.
SecretStr | None so the API can boot without it. The worker entrypoint calls _validate_worker_settings() which raises RuntimeError if HUBSPOT_ACCESS_TOKEN is unset. Missing prod config crashes the worker at boot — visible immediately — rather than silently no-op'ing every 5 minutes. Tests inject a placeholder via the settings fixture.
06 Periodic poller task
@app.periodic(cron="*/5 * * * *")
@app.task(queue="default", name="poll_hubspot")
async def poll_hubspot(timestamp: int) -> None:
settings = get_settings()
# No token-presence check here: worker startup validation guarantees it.
sm = get_session_factory(settings.database_url)
async with sm() as session:
service = DealSyncService(session, settings)
result = await service.sync_once()
logger.info(
"hubspot_poll_completed",
fetched=result.fetched,
new_deals=result.new_deals,
skipped_existing=result.skipped_existing,
cursor_since=result.cursor_since.isoformat(),
)
DealSyncService.sync_once()
@dataclass
class SyncResult:
fetched: int
new_deals: int
skipped_existing: int
cursor_since: datetime
async def sync_once(self) -> SyncResult:
since, until = await self._compute_window()
summaries = await self._hubspot.search_checked_in_deals(since=since, until=until)
new_deal_ids: list[UUID] = []
for summary in summaries:
deal_id, created = await self._upsert(summary)
if created:
new_deal_ids.append(deal_id)
await self._session.commit()
# Commit-first, then enqueue: guarantees jobs never reference uncommitted rows.
for deal_id in new_deal_ids:
await process_deal.defer_async(deal_id=str(deal_id))
return SyncResult(...)
defer_async crashes between commit and enqueue. Recoverable via POST /admin/deals/{id}/process. Buys us: enqueued jobs never reference uncommitted rows.
Upsert uses Postgres ON CONFLICT (hubspot_deal_id) DO UPDATE with xmin = txid_current() to distinguish insert vs. update. Only hubspot_checked_in_at and updated_at get overwritten on conflict; status / current_stage are pipeline-owned and never reset by sync.
Failure scenarios
| Failure | Effect | Recovery |
|---|---|---|
| HubSpot 5xx mid-poll | No commit, no enqueues | Next tick retries |
| Commit fails | All-or-nothing | Next tick retries |
Commit ok, defer_async raises | Row exists, no job | Admin endpoint re-enqueues |
| Two ticks overlap | Procrastinate locks | None needed |
| Token missing | Worker fails to boot (loud) | Set token in env + redeploy |
07 Queue endpoint
@router.get("/queue/deals", response_model=DealQueuePage)
async def list_queue_deals(
limit: int = Query(default=50, ge=1, le=200),
before: datetime | None = Query(default=None),
session: AsyncSession = Depends(db_session),
) -> DealQueuePage:
return await DealQueueService(session).list_page(limit=limit, before=before)
Response schemas
class StageProgress(BaseModel):
stage: Literal["intake", "segmentation", "categorization", "appraisal"]
status: Literal["pending", "running", "succeeded", "failed"]
started_at: datetime | None = None
ended_at: datetime | None = None
pipeline_run_id: UUID | None = None
error: str | None = None
class DealQueueRow(BaseModel):
id: UUID
hubspot_deal_id: str
status: DealStatus
current_stage: PipelineStage
created_at: datetime
updated_at: datetime
steps: list[StageProgress] # exactly 4: intake, segmentation, categorization, appraisal
class DealQueuePage(BaseModel):
items: list[DealQueueRow]
next_before: datetime | None
REVIEW / DONE excluded from steps[]
They're pipeline internals the operator queue doesn't surface. current_stage may still equal review or done; the FE uses status for the row's display state.
_build_steps (latest-by-started_at wins for stages with multiple runs)
UI_STAGES = (
PipelineStage.INTAKE,
PipelineStage.SEGMENTATION,
PipelineStage.CATEGORIZATION,
PipelineStage.APPRAISAL,
)
by_stage: dict[PipelineStage, PipelineRun] = {}
for run in sorted(deal.pipeline_runs, key=lambda r: r.started_at):
by_stage[run.stage] = run # last write wins ⇒ latest
result = []
for stage in UI_STAGES:
run = by_stage.get(stage)
if run is None:
result.append(StageProgress(stage=stage, status="pending"))
else:
result.append(StageProgress(
stage=stage,
status=run.status.value,
started_at=run.started_at,
ended_at=run.ended_at,
pipeline_run_id=run.id,
error=run.error if run.status == RunStatus.FAILED else None,
))
return result
Query uses selectinload on Deal.pipeline_runs — exactly two SQL statements regardless of row count. Keyset pagination via before=updated_at; limit+1 fetched to detect "is there a next page?". Drift on updates crossing the boundary is acceptable — polling is 2 s.
08 Frontend wiring
New adapter (web/src/lib/api/adapters/queue.ts)
import type { components } from "@/lib/api/generated/types.gen";
type ApiQueuePage = components["schemas"]["DealQueuePage"];
export interface QueueRow {
dealId: string; // hubspot_deal_id, display
dealUuid: string; // backend UUID, for actions
createdAt: number;
updatedAt: number;
steps: QueueStep[]; // 4, canonical order
status: "pending" | "running" | "done" | "failed";
durationMs: number;
}
export function adaptQueuePage(page: ApiQueuePage): {
rows: QueueRow[];
nextBefore: string | null;
} { ... }
Edited
admin.production.queue.tsx—useQueueStorereplaced withuseQuery;refetchInterval: isLive ? 2000 : false;isLive+ filters become local stateQueueTable.tsx— takes rows + filters as props; filter logic moves out of the tableRunDetailSheet.tsx—selectedRunIdfrom URL search-param; Timeline tab only; "Re-run from scratch" callsuseEnqueueProcessDealMutation; "Retry step" removedPipelineTrail.tsx— typechange onlyTimelineTab.tsx— typechange; drop refs tologs[],params,output,model,retries,errorStack
@/lib/api/generated/types.gen; the adapter under @/lib/api/adapters/ is the codified API↔UI tech debt. Anything that needs the old queue-mock.ts shape goes through the adapter.
09 Testing
Backend (~20 new test cases)
| File | Type | Coverage |
|---|---|---|
| test_deal_model.py (edit) | unit | hubspot_deal_id required + unique; hubspot_checked_in_at nullable |
| test_hubspot_client.py (new) | respx-mocked | Day-batching, pagination, 4xx/5xx → HubspotError, missing token → raise |
| test_deal_sync_service.py (new) | integration | Happy path; idempotent re-poll; cursor (empty → bootstrap, non-empty → max − 2d) |
| test_poll_hubspot_task.py (new) | InMemoryConnector | Periodic registration; delegates to service |
| test_worker_settings_validation.py (new) | unit | _validate_worker_settings raises clear error when token unset |
| test_deal_queue_service.py (new) | integration | Pagination cursor; exactly 2 SQL queries; _build_steps synthesis; REVIEW/DONE excluded |
| test_admin_queue_route.py (new) | route | 200 + shape; limit bounds (422); before parses ISO; empty DB ⇒ empty page |
| test_admin_deals_route.py (edit) | route | Smoke: poller-created row enqueueable via existing endpoint |
| test_settings.py (edit) | unit | New settings parse; SecretStr for the token |
| conftest.py (edit) | infra | Deal factory adds hubspot_deal_id |
Frontend
| File | Coverage |
|---|---|
| web/src/lib/api/adapters/queue.test.ts (new) | Status synthesis (4 cases); durationMs open vs. closed; ISO→ms; UUID vs. display id preserved |
| QueueTable.test.tsx (new) | Filter behavior (status, stage, search); row click sets URL search-param |
Deliberately not testing: live HubSpot (separate contracts suite, later); 2 s polling under load (manual sanity); E2E via Playwright (deferred per architecture spec).
10 Verification gate
cd backend && uv run pytest -v && uv run ruff check . && uv run mypy srcmake openapi && cd web && bun install && bun run codegen && bun run lint && bun run build- Full stack:
docker compose up -d; worker logs showpoll_hubspotregistered as periodic - End-to-end without HubSpot: insert a deal directly,
curl localhost:8000/admin/queue/dealsreturns the row with 4 stage steps - FE + real backend:
VITE_USE_MOCKS=false bun run dev; queue page shows the row; "Re-run from scratch" enqueues a job (worker logs)
11 Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| HubSpot rate-limits the poll | Low | 5-min default, <100 deals/window, settings-tunable, 4xx surfaces it |
| Periodic task fires while previous still running | Low | Procrastinate's periodic-task locking |
selectinload IN (...) larger than expected | Low | Capped via limit ≤ 200 → 201 ids |
| Drift on cursor pagination during 2 s poll | Bounded | Re-render on next tick — acceptable for live view |
| Procrastinate periodic-cron disappoints under load | Low | Approach B (separate scheduler service) is the documented fallback |
| Token leaks via structlog | Mitigated | SecretStr + structlog filter for password/token/Authorization |
12 Open decisions for user review
- §01
queue-mock.tsdeleted entirely. Types come from generated. Aligns with the README's type-source policy. Implicit from "frontend completely only use the generated types"; flagging in case you want a phased delete. - §04 Defer the
(current_stage, status)index ondeals. Revisit when filter behavior is measured. - §05 Cursor lives in
deals.hubspot_checked_in_atrather than a dedicated poll-state table. Fewer tables; more debuggable shape. - §05 Fail at worker boot when
HUBSPOT_ACCESS_TOKENis missing. Worker entrypoint runs_validate_worker_settings()and raisesRuntimeError. The API service is unaffected (it doesn't import the poller). Tests inject a placeholder via the settings fixture. Changed from earlier silent-no-op default per user feedback. - §06 Commit-first then enqueue. Guarantees no orphan jobs; trades a vanishingly rare "row without job" failure (admin endpoint recovers).
- §07 Trim
REVIEW/DONEfromDealQueuePage.steps[]. Backend stays consistent internally; FE sees exactly its 4 visible stages. - §07 Keyset pagination via
before=updated_atrather than opaque cursor or offset. - §07 Filters (status, stage, search) stay FE-only for this slice. Revisit when 50-row pages stop being enough.
- §08 URL-based
selectedRunIdvia TanStack Router search params (rather than local state). Makes the detail-sheet view shareable. - §08
PipelineTrailkept as-is (typechange only). Simplification deferred until it causes drag. - §08 Single
adaptQueuePageadapter as the codified API↔UI tech debt. README explicitly anticipates this place.
docs/superpowers/plans/2026-05-13-sub-slice-c.md via the writing-plans skill.