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.

Spec Draft docs/superpowers/specs/2026-05-13-sub-slice-c-design.md
What this does

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.

intake segmentation categorization appraisal

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_indexhubspot_deal_id (TEXT NOT NULL UNIQUE), hubspot_checked_in_at (TIMESTAMPTZ), updated_at DESC index
  • integrations/hubspot/ new package: HubspotClient + Pydantic types + HubspotError
  • features/deals/service.py extended: DealSyncService, DealQueueService
  • features/deals/schemas.py extended: StageProgress, DealQueueRow, DealQueuePage
  • workers/tasks.py adds poll_hubspot periodic task
  • routers/admin.py adds GET /admin/queue/deals?limit&before
  • core/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.ts deleted; route + components edited; IOTab/LogsTab/ErrorTab deleted
  • MSW handler + fixture for the new endpoint

Out of scope (deferred)

  • VCC appraisal-contents enrichment → needs deal_images table
  • 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_outputs table
  • step.logs[] → observability slice
  • Deal input preview image → deal_images port
  • pytest-recording cassette 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 fieldBackendCall
run.iddeal.idmap
run.dealIddeal.hubspot_deal_idmap
run.imageUrldrop / deferdeal_images port
run.updatedAtdeal.updated_atmap
run.createdAtdeal.created_atmap

Per-step (pipeline_run)

UI fieldBackendCall
step.id / stage / status / startedAt / endedAt / errorMessagepipeline_run.* + synthesized pendingmap (status via adapter)
step.model / step.modelVersiondrop / defer — first real-stage slice
step.retriesdrop / defer — failure-handling slice
step.paramsdrop / defer — first real-stage slice
step.outputdrop / deferstage_outputs table
step.errorStackdrop permanently — structlog captures server-side
step.logs[]drop / defer — observability slice

Actions

UI elementBackendCall
"Retry step"drop / defer — failure-handling slice
"Re-run from scratch"POST /admin/deals/{id}/processwire to existing endpoint
What survives Queue table + detail sheet, lighter Table: Deal · Stage progress · Status · Duration · Updated. Detail sheet: header + PipelineTrail + "Re-run from scratch" action + Timeline tab only. Filters (search, status, stage) unchanged, FE-side.

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);
Cursor in data No separate poll-state table The poller's cursor is 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.

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()
    if settings.hubspot_access_token is None:
        logger.info("hubspot_poll_skipped", reason="no_token")
        return

    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(...)
Ordering Commit before enqueue Trade-off: a vanishingly rare "row exists, no job" failure if 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

FailureEffectRecovery
HubSpot 5xx mid-pollNo commit, no enqueuesNext tick retries
Commit failsAll-or-nothingNext tick retries
Commit ok, defer_async raisesRow exists, no jobAdmin endpoint re-enqueues
Two ticks overlapProcrastinate locksNone needed
Token missingLogs + returnsSet token + 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
Why 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;
} { ... }
Deleted
queue-store.ts
zustand store; replaced by useQuery.
Deleted
queue-mock.ts
Types now come from generated; helpers replaced by adapter.
Deleted
IOTab / LogsTab / ErrorTab
Backed by fields the backend doesn't model yet.

Edited

  • admin.production.queue.tsxuseQueueStore replaced with useQuery; refetchInterval: isLive ? 2000 : false; isLive + filters become local state
  • QueueTable.tsx — takes rows + filters as props; filter logic moves out of the table
  • RunDetailSheet.tsxselectedRunId from URL search-param; Timeline tab only; "Re-run from scratch" calls useEnqueueProcessDealMutation; "Retry step" removed
  • PipelineTrail.tsx — typechange only
  • TimelineTab.tsx — typechange; drop refs to logs[], params, output, model, retries, errorStack
Type-source policy Adapter is the one allowed seam Per the README, types come from @/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)

FileTypeCoverage
test_deal_model.py (edit)unithubspot_deal_id required + unique; hubspot_checked_in_at nullable
test_hubspot_client.py (new)respx-mockedDay-batching, pagination, 4xx/5xx → HubspotError, missing token → raise
test_deal_sync_service.py (new)integrationHappy path; idempotent re-poll; cursor (empty → bootstrap, non-empty → max − 2d); no-token short-circuit
test_poll_hubspot_task.py (new)InMemoryConnectorPeriodic registration; no-token no-op; delegates to service
test_deal_queue_service.py (new)integrationPagination cursor; exactly 2 SQL queries; _build_steps synthesis; REVIEW/DONE excluded
test_admin_queue_route.py (new)route200 + shape; limit bounds (422); before parses ISO; empty DB ⇒ empty page
test_admin_deals_route.py (edit)routeSmoke: poller-created row enqueueable via existing endpoint
test_settings.py (edit)unitNew settings parse; SecretStr for the token
conftest.py (edit)infraDeal factory adds hubspot_deal_id

Frontend

FileCoverage
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

Gate Five checks before PR
  1. cd backend && uv run pytest -v && uv run ruff check . && uv run mypy src
  2. make openapi && cd web && bun install && bun run codegen && bun run lint && bun run build
  3. Full stack: docker compose up -d; worker logs show poll_hubspot registered as periodic
  4. End-to-end without HubSpot: insert a deal directly, curl localhost:8000/admin/queue/deals returns the row with 4 stage steps
  5. 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

RiskLikelihoodMitigation
HubSpot rate-limits the pollLow5-min default, <100 deals/window, settings-tunable, 4xx surfaces it
Periodic task fires while previous still runningLowProcrastinate's periodic-task locking
selectinload IN (...) larger than expectedLowCapped via limit ≤ 200 → 201 ids
Drift on cursor pagination during 2 s pollBoundedRe-render on next tick — acceptable for live view
Procrastinate periodic-cron disappoints under loadLowApproach B (separate scheduler service) is the documented fallback
Token leaks via structlogMitigatedSecretStr + structlog filter for password/token/Authorization

12 Open decisions for user review

Review needed Eleven calls defaulted in the spec — flag any you want changed Each was posed during section-by-section design. The defaults below ship the spec; this section is the single place to find and contest them.
  1. §01 queue-mock.ts deleted 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.
  2. §04 Defer the (current_stage, status) index on deals. Revisit when filter behavior is measured.
  3. §05 Cursor lives in deals.hubspot_checked_in_at rather than a dedicated poll-state table. Fewer tables; more debuggable shape.
  4. §05 Silently no-op when HUBSPOT_ACCESS_TOKEN is missing. Logs hubspot_poll_skipped reason=no_token and returns. Alternative: raise loudly so missing config can't go unnoticed.
  5. §06 Commit-first then enqueue. Guarantees no orphan jobs; trades a vanishingly rare "row without job" failure (admin endpoint recovers).
  6. §07 Trim REVIEW / DONE from DealQueuePage.steps[]. Backend stays consistent internally; FE sees exactly its 4 visible stages.
  7. §07 Keyset pagination via before=updated_at rather than opaque cursor or offset.
  8. §07 Filters (status, stage, search) stay FE-only for this slice. Revisit when 50-row pages stop being enough.
  9. §08 URL-based selectedRunId via TanStack Router search params (rather than local state). Makes the detail-sheet view shareable.
  10. §08 PipelineTrail kept as-is (typechange only). Simplification deferred until it causes drag.
  11. §08 Single adaptQueuePage adapter as the codified API↔UI tech debt. README explicitly anticipates this place.
Next step Implementation plan After user reviews §12 and locks decisions, the implementation plan lands at docs/superpowers/plans/2026-05-13-sub-slice-c.md via the writing-plans skill.