Unified Job Architecture
Post-refactor reference for the Job subsystem. Migration complete through Phase H — the system executes end-to-end.
Three overlapping concepts (Task, BackgroundTask, Workflow) were collapsed into a single Job DDD aggregate. Progress tracking, step orchestration, and queue integration now live in one place with a clean hexagonal split.
Layers
Section titled “Layers”packages/platform-domain/src/jobs/ ← pure domain (no infra)├── entities/│ ├── Job.ts aggregate root│ ├── JobStep.ts data-shaped step + JobStepResult│ ├── JobPreset.ts single source of truth for preset → steps mapping│ ├── JobTemplate.ts reusable spec (userId + preset/steps + inputs)│ └── JobSchedule.ts cron + template; constructor validates name + cron└── ports/ ├── IJobRepository.ts persistence port (incl. findInFlightBySchedule) ├── IJobRunner.ts dispatch port (incl. schedule / unschedule) ├── IJobScheduleRepository.ts schedule persistence port └── IWorkflowStep.ts runtime step contract (BaseWorkflowStep)
apps/api/jobs/ ← application + infra├── usecases/│ ├── commands/ Create / Cancel / UpdateJobStatus / AddStepResult│ │ + CreateJobSchedule / DeleteJobSchedule / RunScheduleNow│ └── queries/ GetJob / ListJobs / GetJobSchedule / ListJobSchedules├── routes/ createJobsApp({ jobRepository, jobRunner, jobScheduleRepository }) — Hono factory├── adapters/ DrizzleJobRepository, DrizzleJobScheduleRepository,│ PgBossStarter, PgBossJobRunner,│ InMemoryJobRunner, InMemoryJobScheduleRepository└── workers/ ├── jobWorkflow.worker.ts pg-boss worker callback (discriminates scheduled vs immediate fires) ├── processJob.ts shared step loop (used by worker AND InMemoryJobRunner) ├── stepRegistry.ts name → IWorkflowStep lookup └── steps/ ├── runRead.ts shared engine for the atomic source-read steps ├── ReadGmailStep.ts read_gmail (read_classifieds / read_drive alongside) └── ReadUrlStep.ts read_url — fetches the pages linked in upstream itemsAggregate: Job<T>
Section titled “Aggregate: Job<T>”Immutable transitions return a new Job instance. Public surface:
| Member | Description |
|---|---|
id, type, steps, userId, createdAt | identity + structure |
status, progress, currentStep, itemProgress, result, updatedAt | live state |
metadata: JobMetadata | value object with .data / .get / .set |
start(), complete(result), fail(error) | state transitions |
updateProgress(...), updateStepProgress(...), addMetadata(...) | progress updates |
isPending() / isRunning() / isCompleted() / isFailed() | predicates |
hasErrors(), getErrorCount() | error inspection |
Errors live on job.result?.errors, not on the job itself. Use serializeJob() in routes/job.routes.ts as the canonical wire shape.
Use cases
Section titled “Use cases”One file per action:
- Commands:
CreateJobUsecase(optionalIJobRunner),CancelJobUsecase,UpdateJobStatusUsecase,AddStepResultUsecase - Queries:
GetJobUsecase,ListJobsUsecase
Each takes an IJobRepository (and the create command optionally an IJobRunner) in its constructor. Routes instantiate per-request — DI container is intentionally out of scope at current scale.
Ports & adapters
Section titled “Ports & adapters”| Port | Adapter | Status |
|---|---|---|
IJobRepository | DrizzleJobRepository | live |
IJobRunner<T> | PgBossJobRunner (default), InMemoryJobRunner (fallback / tests) | live |
IJobScheduleRepository | DrizzleJobScheduleRepository, InMemoryJobScheduleRepository (tests / fallback) | live |
IWorkflowStep<T> / BaseWorkflowStep<T> | concrete steps under apps/api/jobs/workers/steps/ (e.g. ReadGmailStep), discovered via StepRegistry | live |
apps/api/index.ts selects PgBossJobRunner when startJobQueue(...) succeeds; otherwise falls back to InMemoryJobRunner (dev mode without postgres, or tests). Both runners share the same processJob() step loop.
Presets
Section titled “Presets”JobPreset (in domain) holds the canonical preset → JobStep[] mapping plus a per-preset input schema (InputFieldSchema: type / required / label / optional default, where a default may itself be a ${env:KEY} placeholder). CreateJobUsecase resolves steps from it server-side, so presets and the runtime can’t drift.
Preset metadata is exposed for the dashboard’s create-schedule form via GET /api/jobs/presets (SDK: JobClient.listPresets()), returning { name, label, status: 'ready' | 'blocked', steps, inputs } per preset. status: 'blocked' flags a preset whose steps include an unregistered executor.
Registered presets: veilleToBoard, socialVeille, calendarToTasks, captureComments, gmailTriage, getSuppliersInvoices, agentResearch, implementTask, plus the cross-tenant operations presets (generateInvoices, trialLifecycleEmails) — full list + step gates in packages/platform-domain/src/jobs/entities/JobPreset.ts. There is no publish preset: socialPublish/blogPublish/emailPublish were each a lone publish step (and the first two were the same one), so publishing is triggered by the row’s lifecycle status and run either by the one-off job the dashboard’s “Publish now” enqueues or by a custom flow carrying the publish step. The blog preset went the same way as veille — the composer builds that chain directly. Presets carrying ${input:...} placeholders (gmailTriage, agentResearch, implementTask, …) collect those values from the schedule form — and they stay editable per-schedule afterwards; inputless presets resolve only ${env:...}. There’s no hardcoded preset for a Gmail → Notion dispatch anymore — the dashboard’s “Build custom flow” composer (scheduled-jobs.new.tsx) lets a user assemble that chain (or any other) directly, since the API already accepts an arbitrary template.steps array.
Why a preset shows “(coming soon)” in the dashboard
Section titled “Why a preset shows “(coming soon)” in the dashboard”A preset is returned with status: 'blocked' — rendered as a disabled ”… (coming soon)” option in /scheduled-jobs/new — when any step in its chain has no registered executor (stepRegistry.has(name), checked in GET /api/jobs/presets). In practice almost every step now registers unconditionally at boot (see the gate table below), so status is essentially always 'ready'; the meaningful per-user signal GET /api/jobs/presets returns is the channels array — which of the chain’s required channel schemes (gmail, llm, notion, …) this caller has actually connected. A preset whose steps are all registered but whose required channel isn’t connected still shows as ready in the picker; it fails clearly at run time (or, for the dashboard’s own guidance, the channels[].connected flag) rather than being hidden.
Step → registration gate:
| Step | Registered when |
|---|---|
read_gmail, read_drive | always — atomic per-source read steps sharing the runRead engine; need a connected gmail:// channel at run time |
read_notion | always — reads every row of the owner’s connected notion:// database (url/tags/description already structured, so no enrich needed downstream); resolves its own channel rather than the shared gmail:// credential runRead binds |
read_url | always — second-hop fetch: scrapes the URLs found in upstream items into page-content refs |
analyze, enrich | always — resolve their IContentEnricher per run from the owner’s connected llm:// channel; fail clearly at run time with none connected |
dispatch | always — dashboard/sales/link are internal schemes (always deliverable, no channel needed); the post/notion/email targets are all registered too, but writing to one needs a connected connector of that scheme at run time |
tag | always — needs a connected gmail:// channel at run time |
calendar_to_tasks | always — needs a connected gmail:// channel (Calendar scope) at run time |
triage | always — needs a connected LLM channel at run time (flagged Gmail thread → kanban Task, or → dispatch rows with emit: 'rows') |
agent, implement_task | always — need a connected LLM channel at run time; implement_task additionally needs AGENT_WORKSPACE_ROOT for its read-mostly code tools |
publish | always — routed per row, not per lane: a blog row is a pure state flip (no OAuth gate), a social row needs an OAuth-connected platform (X_CLIENT_ID + a connected account) or it bounces on its own |
update_link | always (DB-backed) |
watch | always (DB-backed observations); the optional LLM-confirm wires only when a connected LLM channel is present |
collect_invoices | always (DB-backed); the optional LLM tiebreak wires only when a connected LLM channel is present — without one it runs gate-only |
deliver_webhook | always (DB-backed, self-sourcing) |
Preset → step chain (from packages/platform-domain/src/jobs/entities/JobPreset.ts; all ready given the gate table above — the channel each needs is what actually varies per user):
| Preset (dashboard label) | Steps | Needs at run time |
|---|---|---|
veilleToBoard (Veille to board) | read_gmail → read_url → enrich → dispatch[dashboard] → tag | a connected gmail:// channel and an LLM channel; enriched links → kanban todo cards |
socialVeille (Gmail → X draft) | read_gmail → read_url → enrich → dispatch[social] → tag | a connected gmail:// channel and an LLM channel; composes drafts, no posting |
calendarToTasks (Calendar to tasks) | calendar_to_tasks | a connected gmail:// channel with Calendar scope; one kanban Task per upcoming event, idempotent on event id |
gmailTriage (Gmail triage) | read_gmail → triage → tag | a connected gmail:// channel and an LLM channel; a routing skill may add a route:<x> tag (also serves the “admin process”) |
getSuppliersInvoices (Get suppliers invoices) | read_gmail → collect_invoices → tag | a connected gmail:// channel; an LLM channel is OPTIONAL — deterministic rules decide first and the model is consulted only for mail they can’t call. Files entity: 'link' cards tagged from:invoices, which /api/accounting lists. Nothing is archived: a card carries the supplier’s link plus a Gmail permalink, never a copy |
agentResearch (Agent research) | agent | an LLM channel on an anthropic/openrouter provider (the tool-loop is unavailable on a Cline-only provider) |
implementTask (Implement task) | implement_task | an LLM channel and AGENT_WORKSPACE_ROOT; proposes a dry-run diff on tagged in-progress Tasks, never applies it |
captureComments (Capture comments) | capture_comments | at least one connected LinkedIn / Facebook Page account — capture_comments is the one preset step that is NOT registered unconditionally (no connectable comment reader ⇒ dropped), so this preset can genuinely show blocked |
generateInvoices (Generate invoices) | generate_invoices | STRIPE_SECRET_KEY + a connected Stripe account with chargesEnabled per selling org. Cross-tenant: run behind exactly ONE system-wide schedule |
trialLifecycleEmails (Trial reminder emails) | trial_lifecycle_emails | RESEND_API_KEY + SMTP_FROM. Cross-tenant, same shape as generateInvoices; idempotent via a lifecycle_emails ledger row per (org, kind) |
There is no hardcoded Gmail → Notion (or any other single-shot ingestion → dispatch) preset — the dashboard’s “Build custom flow” composer assembles an arbitrary template.steps chain, including a read_notion → dispatch(link://…) flow with no enrich step needed. Quotes are no longer a separate pipeline: they’re sales Deals living directly on the Task kanban (destination.scheme = 'sales'), so the old quote_requests / quotesToBoard presets and their read_quote_requests / mark_quote_requests_reviewed steps are gone.
Domain boundary
Section titled “Domain boundary”Enforced two ways:
packages/platform-domain/package.json— onlyzodas runtime dep. Nodrizzle-orm,pg-boss,hono, or Node built-ins.packages/platform-domain/eslint.config.js—no-restricted-importsblocksdrizzle-orm,pg-boss,hono,fs/http/https/path,node:*, and anyapps/*path.
Run bun run lint inside packages/platform-domain/ to check.
Step authoring
Section titled “Step authoring”To add a new step (e.g. analyze, enrich, dispatch):
- Implement
BaseWorkflowStep<T>underapps/api/jobs/workers/steps/:namemust match the string used inJobPreset(e.g.'analyze')doExecute(context)reads per-job config fromcontext.metadata.stepConfiganduserIdfromcontext.metadata.userId(both injected byprocessJobbefore each step runs)- Return a
StepResult<T>with the updated context and acontinueflag —continue: falsestops the workflow mid-job without failing it
- Register the step in
apps/api/index.tsagainst the sharedStepRegistry. Steps not in the registry cause the worker to fail the job with"No executor registered for step 'X'"— this is intentional fail-fast behavior. - Per-step results are persisted via
IJobRepository.addStepResultautomatically — no extra work needed.
Registered steps today (in apps/api/index.ts):
-
read_gmail/read_classifieds/read_drive— always registered. Atomic per-source read steps (one source each) sharing therunReadhelper, which resolves the owner’s channel + per-run reader factory (Gmail/Drive), records a deferred watermark advance (see “Watermarks advance only on job success” below — it no longer advances inline), mapsBaseContent → ReadItemRef, and merges counts intocontext.metadata.read. They replace the oldsourceFrom-dispatchingread. Message selection is fully manual: the Gmail presets no longer expose structuredgmailLabel/fromEmailinputs — a single requiredextraQueryinput carries raw Gmail search syntax (label:Triage,from:someone@x.com,to:me,has:attachment, …) and is the whole inclusion filter. It never forcesfrom:me, so a label-watch also catches received mail (a message you tag but didn’t send); addfrom:yourself to restrict to one sender. The-label:<processedLabel>exclusion is appended automatically and is what keeps re-runs idempotent (and what makesrunReaddrop the watermark’safter:bound — see below). -
Watermarks advance only on job success. A read step stashes its intended advance on
context.metadata.pendingWatermarks;processJobcommits them (via an injectedcommitWatermarks) only when the whole job completes — a mid-chain failure (e.g.dispatchhalting on “no LLM configured”) leaves the cursor where it was, so the message is re-read on the next run. Idempotency across those retries is the source’s own guard (Gmail’s-label:<processedLabel>exclusion), not the watermark. This prevents the class of bug where a message is read once, a later step fails, and the advanced cursor then hides it from every future run. -
read_url— always registered. Second-hop fetch: infromItemsmode it extracts the http(s) URLs from each upstream item’s content (or uses an expliciturlslist), scrapes each viaIWebScraper, and appends page-contentReadItemRefs (fetchedPage: true, carryingparentSourceMessageId/parentUrlso provenance survives the 1→N fan-out). Never replaces upstream items, sotagidempotency (which readsmetadata.read) is untouched. -
read_notion— always registered (apps/api/notion/jobs/ReadNotionStep.ts). Reads every row of the owner’s connectednotion://database (integration token + database id, the same channel thenotiondispatch target writes through) — no watermark/incremental filtering, since a curated database is small and the downstreamlinkdispatch target is idempotent per-URL. Shapes rows directly into bothcontext.items(standardReadItemRefs) andcontext.metadata.enrich.rows(DispatchRow[]), so aread_notion → dispatch(link://...)flow needs noenrichstep in between — the source already has structured url/tags/description. Column names are configurable viastepConfig.columnMap(defaults: Name / URL / Tags / Description). -
analyze— always registered (apps/api/jobs/workers/steps/AnalyzeStep.ts). Cheap per-item keep/drop pre-filter (no scraping) that runs beforeenrichpays the fan-out cost. Steerable:config.skilltakes anenricher-kind skill (ref by id or slug, optional@<version>pin) whose body REPLACES the free-textinstructionand is declared authoritative to the classifier — a selection rule (“only receipts”) otherwise loses to the generic relevance rubric, the failuretasks/lessons.mdrecords for triage. Resolution is shared withtriage(apps/api/skill/resolveSkillSteering.ts): a missing/disabled/mis-kinded/version-drifted skill never runs, it falls back to the free text and records the reason incontext.metadata.analyze.skill. The unsteered prompt is byte-identical to what it was before the step became skillable. -
enrich— registered when an LLM channel is available. Pure synthesis now: for each content ref (theread_urlpage refs when present, else the raw refs),IContentEnricher.analyzeContents itsrawContentinto a{title,url,summary,tags}row, skipping empty analyses. No scraping or URL discovery — that moved toread_url. Emits rows intocontext.metadata.enrich.rows;maxRowsPerJobdefault 50. -
dispatch— the step formerly namedexport(and its port formerlyIExportTarget) in earlier revisions of this doc; both were renamed in code and this doc now matches. Registered when any target is available (in practice always — see the gate table above). Readscontext.metadata.enrich.rows, routes to aMap<string, IDispatchTarget>keyed bystepConfig.destination’s URI scheme (canonical schemes:DESTINATION_SCHEMESinpackages/platform-domain/src/connector/Destination.ts).buildJobsassembles the map from all available targets:notion→NotionDatabaseWriter,email→EmailDigestExportTarget,dashboard→BoardTaskTarget(kanbanTasks,sourceType: 'dispatch'),link→LinkDispatchTarget(channel='link'Tasks,sourceType: 'link'— the rows backing the dashboard’s/linksinbox), plus one sharedDraftDispatchTargetregistered under every key returned byDestinationHandlerRegistry.drafters()(thepostandemailkinds), routingconfig.destinationto that handler’s draft axis.dashboard,sales, andlinkare internal schemes: no connected channel is required (onlydashboardandlinkhave a target —salesis internal with no target, a Task just “lives” there), and the dashboard’s “Build custom flow” composer offersdashboard/linkdirectly in the Destination picker’s “Board & Links” group (previously only reachable via the raw-JSON step-config fallback).DispatchStepthreadscontext.metadata.userId/orgIdinto every target’s config, plus a derivedpipelineName(the job’sscheduleNameslug, or itspreset) thatBoardTaskTarget/LinkDispatchTargetstamp as afrom:<pipelineName>provenance tag (e.g.from:notion-to-board) via the sharedpipelineTag()helper — falls back to the genericfrom:pipelinefor an unnamed one-off run. -
publish— the unified self-sourcing publish worker (apps/api/tasks/jobs/PublishStep.ts; reached by a custom flow carrying the step, or by the one-off job the dashboard’s “Publish now”/“Send now” enqueues — no preset points here). Ignoresenrich.rowsand selects its own due rows (trigger status +scheduledAt <= now) from the sharedITaskLifecycleRepository— the sameDrizzleTaskRepositoryinstance the draft target writes through, since drafts and posts are bothtasksrows. Dispatches byDestinationKindto anIDestinationHandler’s publish axis (PostDestinationHandler/EmailDestinationHandler, resolved viaDestinationHandlerRegistry.publishers()). Per row, in order: claim (repository.claim— an atomictriggerStatus → claimStatusconditional UPDATE; winning it is what makes publishing single-shot, and a lost claim is skipped rather than double-published), publish, then commit +afterCommit(e.g. blog→social cross-link), run outside the failure path so a cross-link failure can’t undo an already-committed publish. A per-row failure routes that row toneeds_review(a human must re-approve — no silent retry loop) and the pass continues.The social/blog gate is per-row, not per-lane. Social and blog are one
posthandler now, so there is deliberately no “drop the social publisher when nothing is connectable” step — doing that would silently disable blog publishing too. The handler instead refuses an individual row whose platform has no publisher, with a warning-levelPublishErrorthat bounces just that row. -
triage— always registered (apps/api/triage/jobs/TriageStep.ts). Asks anITriageDecider(LLM, resolved from the owner’sdashboard://channel) whether each read item needs a follow-up, and turns actionable ones into a kanban Task keyedmanual:<userId>:<gmail message id>.config.emitchooses what happens to a verdict:task(the default, and what every preset uses) creates that card here;rowsinstead appends aDispatchRowtometadata.enrich.rowsso a downstreamdispatchroutes it — to Links, Notion, a draft, or back to the board — which is what makesread_gmail → triage → dispatch → taga legal chain. The row carries the same title/tags/priority/dueDate/source and the same idempotency key, so a schedule can switch modes without duplicating anything, andBoardTaskTargetprefers those row-level values over its own defaults. The mode is explicit rather than inferred becauseDispatchSteptreats an empty row batch as a success: a card-creating triage sitting in front of adispatchwould report a green run nightly while exporting nothing —validateJobChainrejects that pairing (triage_emits_tasks) at build time. A decider failure is not a verdict. The deciders fail soft (triageFailSoft→ a “Review:” placeholder) and mark that decision degraded: true; the step then (a) writes the placeholder under a distinct…:failsoftkey so the canonical key stays free — a placeholder holding the canonical key would read as “already triaged” forever, which is how a Cline credit exhaustion once permanently poisoned 29 threads — (b) adds the message tocontext.metadata.tagExclusionssotagleaves it unlabelled and the next read retries it, and (c) afterDEGRADED_HALT_THRESHOLD(5) consecutive failures aborts the pass entirely (continue: false), because a run where every decision fails is an outage, not N follow-ups. When a later pass does judge the thread, its untouched placeholder (stilltodo, still taggedtriage:degraded) is deleted. The decider is given the subject fromReadItemRef.metadata.subject— without it every placeholder is titled the identical, useless “Review: flagged email thread”. -
tagExclusions— the “don’t mark this one processed” channel (apps/api/jobs/workers/steps/tagExclusions.ts).taglabels every message the read step pulled and the next read excludes that label, which makes any unprocessed message permanently invisible. A middle step that could not process an item adds its provider message id tocontext.metadata.tagExclusions;TagStepfilters those out before labelling. Step-agnostic by design (ids only, no reason) —tagmust not learn abouttriage.
Placeholder-resolved step config: CreateJobUsecase walks each step’s config and replaces whole-string placeholders in three passes — ${input:fieldName} first (user-supplied schedule/job inputs, with preset defaults filled by applyInputDefaults), then ${env:KEY} (env-derived values; unknown env keys are left intact — fail-loud at the worker), then ${setting:KEY} (the creating user’s settings, resolved per-job by a settingsResolver keyed on userId; an unset setting resolves to '' so a downstream consumer applies its own fallback). This lets preset definitions in pure domain reference input-, env-, and per-user-setting-derived values without coupling the domain layer to those stores. Mixed-string templating ("${env:X}/foo") is intentionally unsupported — step config is data, not templates.
No built-in preset uses ${setting:...} today (the one consumer, the quote_requests preset, was removed when quotes became sales Deals on the Task kanban), but it’s still live in CreateJobUsecase for any custom email:// dispatch flow: point to at ${setting:notificationEmail} (the user’s app:notifications.defaultEmail setting) and EmailDigestExportTarget falls back to NOTIFICATION_EMAIL when that resolves empty.
Scheduling
Section titled “Scheduling”Recurring fires of any preset (or explicit step list) on a cron, shipped in tasks/scheduled-jobs.md Phases 1–4. The dashboard management UI shipped separately (tasks/scheduled-jobs-ui.md) — see Dashboard surface below.
A JobSchedule is a long-lived row that spawns Jobs; each cron tick materializes a fresh Job aggregate that flows through the same processJob() loop. There is no “scheduled job” type — every fire produces a normal Job that shows up in GET /api/jobs alongside one-shot submissions. Schedules and Jobs are linked by Job.metadata.scheduleName.
Schedule names are namespaced server-side as {userId}.{slug}. Users supply only the slug; the namespace prevents cross-tenant collisions in pg-boss’s flat schedule table.
HTTP surface (under /api/jobs/schedules/)
Section titled “HTTP surface (under /api/jobs/schedules/)”| Method | Path | Notes |
|---|---|---|
| POST | / | Create + register cron. Validator enforces slug + cron + template (preset XOR steps) |
| GET | / | List my schedules |
| GET | /:name | Read one |
| DELETE | /:name | Unschedule + delete row |
| POST | /:name/run | Fire-once now, skips the overlap guard (manual trigger is allowed to overlap) |
Preset metadata for the create form is served by a sibling endpoint, GET /api/jobs/presets (not under /schedules). Jobs materialized by a schedule are filterable via GET /api/jobs?scheduleName={userId}.{slug}.
Worker dispatch
Section titled “Worker dispatch”jobWorkflow.worker.ts discriminates on payload shape:
{ jobId, userId }→ existing immediate-fire path viaprocessJob.{ __scheduledFire, scheduleName, template }→handleScheduledFire:- Query
IJobRepository.findInFlightBySchedule(scheduleName, userId). - If found, log + skip (overlap policy — see below).
- Otherwise materialize via
templateToJobCreate+CreateJobUsecase(withnullrunner, so we don’t re-enqueue onto pg-boss and loop), best-efforttouch(lastFiredAt)on the schedule, thenprocessJobinline.
- Query
Overlap policy — skip-if-previous-still-running
Section titled “Overlap policy — skip-if-previous-still-running”Locked decision (tasks/scheduled-jobs.md §0 D4). A scheduled fire that arrives while a prior fire is still pending or running is logged and skipped, not queued. Rationale: not every preset has per-step idempotency (gmailTriage happens to via Gmail-label dedup, but analyze and future dispatch targets don’t necessarily), so the failure mode “schedule slips” is preferable to “two writers race the same Notion dispatch.”
Hot lookup is backed by a partial index — jobs(user_id, (metadata->>'scheduleName')) WHERE status IN ('pending','running') — added in the create_job_schedules migration.
Don’t add bypass paths to the worker without explicit reason. The route layer’s POST /:name/run is the only intentional bypass (manual trigger).
pg-boss schedule integration
Section titled “pg-boss schedule integration”PgBossJobRunner.schedule(name, cron, template) calls boss.schedule(JOB_WORKFLOW_QUEUE, cron, payload, { key: name }). The key is what makes multiple schedules coexist on a single queue in pg-boss v12 — without it the queue would only hold one schedule total. unschedule(name) calls boss.unschedule(queue, name) and swallows not-found (idempotent like cancel).
Usecase invariants
Section titled “Usecase invariants”Both write-side schedule usecases enforce an ordering that prevents orphan state:
- Create — row first, then runner. If the runner fails, the row is rolled back. Never leave a
job_schedulesentry the cron-side doesn’t know about. - Delete — runner first, then row. If the runner fails, the row stays so an operator can retry the DELETE. Never orphan a cron registration without a row to find it again.
Inferred Job type
Section titled “Inferred Job type”templateToJobCreate (alongside CreateJobUsecase) resolves template.preset via JobPreset.stepsFor(...) and derives JobTypes from the step count — single-step vs workflow-of-steps. Every cron fire re-runs preset resolution + placeholder substitution, so changing an env var or a preset definition propagates automatically without re-saving schedules.
Retiring a preset
Section titled “Retiring a preset”Preset resolution distinguishes absent from unknown. No preset name (a custom-steps template) falls back to DEFAULT_STEPS; a name that isn’t in the registry throws UnknownJobPresetError from both JobPreset.stepsFor and JobPreset.inputsFor. It used to fall back in both cases, which meant deleting a preset silently converted every schedule pointing at it into a green-but-useless read + analyze run.
So deleting a preset from JobPreset.ts is safe by construction — the stored schedules that referenced it become loudly broken rather than quietly wrong:
POST /api/jobs/schedulesrefuses to create one (400), so the state can only arise from a retirement.reconcileJobSchedules(boot) does not register its cron and reports it infailures; when the retired preset was a SYSTEM one,systemSchedulesHealthyon/api/healthgoes false.- A scheduled fire logs
[jobs] schedule '<name>' cannot fire: …at error level and skips. Deliberately not rethrown — a permanently-broken template would otherwise be retried by pg-boss on every tick. Other schedules are unaffected. GET /api/jobs/schedules/:namestill returns 200, with atemplateErrorstring the dashboard’s schedule detail page renders as a destructive banner (and which disables “Run now”).POST /api/jobs/schedules/:name/run400s with the same message.
The fix for an operator is always the same: delete the schedule and recreate it against a registered preset.
Dashboard surface
Section titled “Dashboard surface”The scheduling management UI lives in apps/dashboard (tasks/scheduled-jobs-ui.md):
/scheduled-jobs— lists schedules (cron rendered human-readably viacronstrue, last-fired, manual run now)./scheduled-jobs/new— create form; preset picker fed byGET /api/jobs/presets, with input fields rendered from each preset’sinputsschema (status: 'blocked'presets shown as unavailable)./scheduled-jobs/$name— detail; schedule config plus the Jobs this schedule has materialized (via the?scheduleName=filter), and delete.
SDK (JobClient): createSchedule, listSchedules, getSchedule, deleteSchedule, runScheduleNow, listPresets, list({ scheduleName }). Pause/edit of an existing schedule is not yet implemented (edit = delete + recreate).
Monitoring
Section titled “Monitoring”GET /api/jobs/queue-stats — admin-only endpoint (gated by the ADMIN_EMAILS allowlist; 403 for everyone else) that returns live queue depth plus a 24-hour rollup per queue.
Response shape: { success: true, data: QueueStats[] } where each QueueStats has queue, pending, active, completed24h, failed24h, failureRate24h, avgDurationMs24h. SDK: JobClient.getQueueStats().
Port IJobQueueStats (packages/platform-domain/src/jobs/ports/IJobQueueStats.ts) keeps the domain infra-free. Adapters: PgBossQueueStats (reads pgboss.job + pgboss.archive read-only via Drizzle raw SQL; silently returns [] if the schema is not yet migrated) and InMemoryQueueStats (always [], used when pg-boss is unavailable). The dashboard home renders these stats as a “Queue health” card; a 403 response hides the card without breaking the page.
No ADMIN_EMAILS (and no NOTIFICATION_EMAIL fallback) set → gate denies everyone (403) → card hidden. The pgboss schema is runtime-managed by pg-boss — never add a drizzle migration for it; this adapter only reads it.
End-to-end UAT
Section titled “End-to-end UAT”Two scripts target the runtime path:
apps/api/scripts/uat-jobs.ts posts a single-step read job (read_gmail / read_classifieds / read_drive by --source) and polls until terminal. Use after touching the read/runner/registry path.
# Requires API on :3000 and a renewable session (~/.platform-cli/cookies.txt).# Auto-renews via apps/api/scripts/renew-session.ts on 401.bun run api:uat # gmail, last 30 daysbun run api:uat --source gmail --days 7bun run api:uat --api-url http://localhost:3000 --timeout 60bun run api:uat --no-poll # submit and exitExit codes: 0 on completed, 1 on failed/timeout, 2 on env errors (API unreachable, renew failure, bad args).
Open work
Section titled “Open work”Genuinely open product gaps — not structural debt:
analyzeneeds a connected LLM channel.analyzeis always registered but resolves itsIContentEnricherper run from the owner’sllm://channel; any job whose steps include it (today only theDEFAULT_STEPSfallback used when NO preset name is given) fails at that step with no channel connected.- Schedule pause/edit not implemented. The dashboard scheduling UI (list/create/detail,
tasks/scheduled-jobs-ui.md) shipped, but editing an existing schedule means delete + recreate; there’s no pause toggle yet. - No timezone-aware scheduling. pg-boss schedules fire in UTC. Add a
timezonefield toJobScheduleonly when a user asks. - Multi-instance scheduler scaling. pg-boss dispatches scheduled ticks from the singleton API instance that holds the boss connection — fine today. If we scale the dispatcher horizontally, the overlap-guard race window widens; revisit with an advisory lock keyed on
scheduleName. - No domain event dispatch.
JobEventBuswas removed in Phase G (it had zero subscribers). When jobs need to publish lifecycle events (e.g. for the activity log), add anIEventDispatcherport + adapter rather than a private pub/sub class. - Credit/gamification not gated. Running a Gmail → enrich → Notion job today (whether from
veilleToBoard-style presets or the custom-flow composer) consumes LLM + Notion API quota without any credit-deduction check. When credit pricing is agreed, wirecreditService.canExecuteTrade()intoCreateJobUsecase.
