Observability
Background jobs
Exports, imports and bulk actions run in the background: the Jobs panel, completion popups, async API requests, CLI waiting, limits and retention.
Slow actions — exports, imports, package builds and installs, bulk deletes and other actions on many items, sync runs — run as background jobs. Clicking the button returns at once with “Preparing your export… we'll let you know when it's ready”; you can keep working. Quick jobs still finish within the click.
In the app
- Jobs indicator (top bar): a spinner and the number of running jobs. Click it for the Jobs panel: recent jobs with their progress, Cancel, Retry and Download. Workspace admins (Edit settings) can switch to every job of the workspace.
- Completion popup: when a job finishes — on whatever page you are by then — a popup offers Download (exports), shows the result (imports, bulk actions) or the error with Retry.
- Bell: every finished job also leaves a notification, so a result is not lost when you navigate away or reload. Clicking it opens the Jobs panel.
Results (files) are kept for 7 days, then deleted with the job. Only the person who started a job can download its file.
| Action | Job kind |
|---|---|
| Export (any artifact or selection) | config.export |
| Import (apply; the dry-run plan is instant) | config.apply |
| Report → Download Excel | report.export |
| Packages: build & download, download a version, install / upgrade, complete setup, uninstall, history CSV | package.* |
| Delete selected (workflows, data tables, rows, business rules, agents, reports, workflow activities, retention policies, references, API keys) | bulk.delete |
| Activate / deactivate selected workflows | workflows.set_active |
| Bulk permissions | object_permissions.bulk_set |
| Add tag to selected references | references.add_tag |
| Bulk assign / remove a role | roles.bulk |
| Regenerate connector references | connectors.regenerate_references |
| Queue: Pause all / Resume all / Delete | workflow_queue.bulk |
| Migrate live instances | workflow_instances.migrate |
| GitHub sync pushes and deployments, directory syncs | sync.watch |
Permissions are the same as for the action itself, and are checked again — as you, with your roles at that moment — when the job runs.
API
Slow endpoints have an async variant. Add ?async=true or the header Prefer: respond-async; the response is 202 Accepted with the job (and Location: /api/v1/jobs/{id}):
curl -X POST "$URL/api/v1/config/apply" -H "Authorization: Bearer $KEY" \
-H "Prefer: respond-async" -H "Content-Type: application/json" -d @files.json
# 202 { "success": true, "data": { "job": { "id": "…", "status": "queued", … } } }
curl "$URL/api/v1/jobs/$JOB" -H "Authorization: Bearer $KEY" # status, progress
curl "$URL/api/v1/jobs/$JOB/result" -H "Authorization: Bearer $KEY" # what the synchronous call returns
curl -OJ "$URL/api/v1/jobs/$JOB/download" -H "Authorization: Bearer $KEY" # file resultsAsync endpoints: GET /config/export, POST /config/apply, POST /packages/{id}/versions, POST /package-installs, POST /package-installs/{id}/upgrade, POST /package-installs/{id}/uninstall, POST /data-tables/{id}/rows/bulk-delete, POST /references/bulk-delete, POST /workflows/{id}/instances/migrate (marked x-usertasks-async in the OpenAPI document). An Idempotency-Key header returns the same job for a repeated request. Without the flag every endpoint answers synchronously, as before.
| Endpoint | |
|---|---|
GET /jobs | Your jobs (tenant API keys: the jobs the key started); scope=tenant for admins, active=true, status=… |
GET /jobs/{id} | Status (queued, running, succeeded, failed, cancelled), progress 0–100, message, result summary and counts, error |
POST /jobs/{id}/cancel | Queued jobs stop at once; running jobs at their next progress report |
POST /jobs/{id}/retry | A new job with the same input |
GET /jobs/{id}/result | The JSON result (409 until finished, 410 once expired) |
GET /jobs/{id}/download | The result file |
A job started with an API key keeps that key's permissions as a ceiling and stops if the key is revoked or expires.
SDK and CLI
const result = await client.requestAndWait('POST', '/config/apply', { body, onProgress: (job) => console.log(job.progress) })
const { job } = await client.startJob('GET', '/config/export', { query: { format: 'manifest' } })
const file = await client.downloadJob(job.id)CLI commands with an async variant start a job and wait for it, printing progress on stderr and the same output as before; --no-wait prints the job instead. usertasks jobs list | get | cancel | retry | result | wait | download work with any job.
How it runs (self-hosting)
- The job starts in the same request, right after the response (
after()), so it needs no extra infrastructure. - The scheduler tick runs
user-jobsevery minute (every scheduler: pg_cron, the worker, Vercel / external cron): jobs whose start was lost, retries that are due, the next chunk of long jobs, and jobs whose invocation died (their lease expired)./api/cron/user-jobsdoes the same on demand (processor secret). - Each invocation spends at most
USER_JOB_BUDGET_MS(default 50 s) on a job; long jobs hand over in chunks and continue in a fresh invocation, so serverless time limits are respected. - Failures from unexpected errors are retried with backoff (15 s, 1 min, 4 min…) up to the job kind's limit; permission and validation errors fail at once.
- Results are stored in the private
job-resultsbucket of the configured storage provider and served only through the authorised download routes.user-jobs-cleanup(every 15 minutes) deletes expired jobs and their files;USER_JOB_RESULT_TTL_HOURS(default 168) sets how long they are kept. - Live updates use the realtime provider (Supabase Realtime or SSE) with polling as the fallback.