Skip to content

feat(status): list Harper jobs on a Jobs tab, and follow a CSV import's job in a toast - #1880

Merged
dawsontoth merged 3 commits into
stagefrom
claude/1266-status-jobs
Oct 12, 2026
Merged

dawsontoth merged 3 commits into
stagefrom
claude/1266-status-jobs

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor
Woodland Jobs Dashboard Showcase

⊙ Problem

Studio has no view of Harper's jobs — the background runs behind CSV/S3 loads, exports, bulk deletes and backups — so there is no way to see what ran, what failed, or why. A CSV import also blocks behind an "Importing…" spinner that shows neither whether Harper has started the job nor how long it has been running (#1266).

❓ Your call: #1266 lists Jobs, a table view, refresh, "live" event binding, and an import toast. This PR does the first three and the toast. Live binding is left out because it needs the WebSocket precedent in #1261 (human required), and the issue's "or invoking a job as a result of another operation?" is an open question, so only CSV imports get the toast. Hence Refs, not a closing keyword. Reversible: either can follow as its own PR.

Ported onto stage after the deploy stack landed. It is rebased linearly onto stage, which now carries #1868's structured job messages and #1884's grant bands. waitForJob keeps both behaviors on the same lines, and canListJobs moved from the removed isGrantGateInert to #1884's gateVerdict, so a Harper 5.3 role whose allowlist lists search_jobs_by_start_date now gets the list. Details are under Changes and Verification.

💡 Solution

A Jobs tab on the instance Status page. It lists jobs started in the selected window, newest first, via search_jobs_by_start_date, with status, type, start time, duration, user and message. Each row expands to show the full record. Harper sometimes records a job's message as an object (a refused bulk load's permission report), so the expanded row renders it as JSON text, never [object Object]. The tab:

  • Has its own poll, off by default. It keys on the window width and computes the bounds when it fetches, so each poll slides the same query forward. It fetches when the tab opens and on Refresh now, and polls only on an interval picked on the tab, through pollUnlessForbidden, so a 403 stops the timer. That interval is its own jobsRefresh search parameter, which defaults to off. Jobs shares the existing range parameter.
  • Sits outside the analytics gate. It reads no get_analytics, so it joins Overview in ANALYTICS_FREE_TABS: a failed analytics probe no longer redirects ?tab=jobs to Overview or swaps in the "analytics unavailable" notice.
  • Runs only for roles that can list jobs. Harper registers search_jobs_by_start_date as super_user-only, and an operations allowlist delegates it only from 5.3.0-beta.3, where its authorization entry is first named (harper utility/operation_authorization.ts: unnamed at v5.2.9:373, named at v5.3.2:433). canListJobs allows a super user, and allows an allowlist that lists the operation only where the role editor's gateVerdict says the listing grants it for the instance's version. Any other role sees an explanation, and the query stays disabled, so no request is sent.
  • Bounds what it fetches and renders. The operation takes no limit or cursor, so every poll re-fetches the whole window. Jobs therefore offers windows up to 7 days (a range=30d link reads as 7 days there), renders at most the 200 newest rows, and says how many it held back.
  • Says it is per-node on a cluster. hdb_job is in harper's NON_REPLICATING_SYSTEM_TABLES, so a cluster route shows the jobs of whichever node its request reached, and the copy says so.

CSV imports show their job in a toast. waitForJob reports every polled job through a new onUpdate, and the import shows a loading toast — Queued · 2s · job <id>, then Running · 14s · job <id> — that turns into the result when the load finishes. Harper writes nothing to a job between starting it and finishing it, so the toast shows state and elapsed time, not a percentage. The toast is settled from useImportDataMutation's own onSuccess/onError, so it still settles if the modal unmounts mid-import, which the old mutate(…, { onSuccess }) toast did not. A throw from any listener callback — per poll, success or error — is caught and logged, so it cannot turn a load Harper finished into a reported failure.

The change stays self-contained to avoid kylebernhardy's open Status work (#1456, #1458, #1459, #1460, #1485, #1486, #1516, #1517): new files under status/jobs/, plus one tab registration and a shared tab predicate in StatusTabs.tsx and statusSearch.ts.

⚖️ Alternatives

The planning review returned better-alternative-exists, and the graded leg kept three of its points as majors through round 2. Gemini ruled all three of those overrulings factually right. Here is how each point was resolved:

  • Adopted: cap the window at 7 days and the rendered rows at 200, keep the tab outside the analytics gate with one predicate used for the redirect, picker, hint and body, settle the toast from mutation-level callbacks, isolate every listener callback, and gate an allowlist grant on the role catalog's rule rather than trusting it everywhere.
  • Overruled — a server-side bound before shipping. Harper creates an hdb_job row only in executeJob (harper server/serverHelpers/serverUtilities.ts), once per job-type operations call, and each job starts its own worker thread (jobRunner.ts launchJobThread). So the graded leg's "100 jobs a minute" history is not a workload Harper sustains. The 7-day cap and the 200-row render cap bound the rest.
  • Overruled — fan out per cluster member. Studio has no per-member client on a cluster route. The Fabric Connect proxy also routes cluster operations to the first RUNNING instance (central-manager src/resources/cluster/Cluster.js handleProxiedOperation), so the answering node is stable there. The node-scope copy covers the rest.
  • Overruled — extract a generic StatusTimeControls from TimeRangePicker. Custom time range and brush/zoom for Status charts #1456 (custom time range and brush/zoom) is about to reshape that component. Jobs uses the same TIME_PRESETS/REFRESH_OPTIONS and URL params in its own small toolbar instead.
  • Overruled — stop the StatusTabs refresh tick while Jobs is open. Overview relies on the tick to clear a failed panel, and stopping it on Jobs would leave the charts on a stale window when the user switches back. It also touches refresh logic owned by Analytics Status tabs: per-tick query + client-side aggregation cost (free-tier load; UI vs core vs aggregation service) #1516/Widening the Status time range rescales stale data into a sliver instead of showing skeletons #1517. The cost is one re-render per refresh interval (at least 30s).

❓ Your call: Who can list jobs? Chosen: a super user, plus a role whose allowlist lists search_jobs_by_start_date where #1884's gateVerdict says the listing grants it (5.3.0-beta.3 on), so the tab and the role editor always agree. The version comes from the registration_info the instance route has already loaded, and it is read only for a role that lists the grant. If a refetch of it fails, the listed grant is tried rather than refused, and a 403 shows the same explanation. The alternative is to fail closed there, which would hide the tab from a role the instance would serve.

❓ Your call: The import modal still blocks while the job runs, and the toast is added alongside. Letting the modal close and the toast carry the job is the natural next step, but it changes when onImported refreshes the grid. Reversible.

❓ Your call: Jobs stops at the 7-day preset, because each poll re-fetches the whole window and Harper's operation has no limit or cursor. That gives up a 30-day job audit. Alternative: offer 30 days with a slower poll. Changing JOB_WINDOW_PRESETS is one line.

Decided: Jobs auto-refresh defaults to off; push updates tracked in HarperFast/harper#3179. search_jobs_by_start_date runs an unlimited select * from system.hdb_job over the window (harper server/jobs/jobs.ts, v5.3.2 :256) and strips request only after reading it (:70). The job process clears request when a job finishes (server/jobs/jobProcess.ts:34-35, written by the final updateJob at :92). So the large bodies, such as a csv_data_load's whole CSV, are only on queued or running jobs and on jobs whose process died before that update. Jobs now fetches when the tab opens and on Refresh now, and polls only on an interval picked on the tab. While it is off, a refocus or reconnect does not refetch either. The interval is a separate jobsRefresh parameter rather than refresh, because one URL value cannot default to 60s on the charts and to off on Jobs: a tab switch would either start Jobs polling or reset the charts' choice. Every Status navigation carries both unchanged, so the chart tabs behave as before. Reversible: sharing refresh again is a few lines, at the cost of one of those two surprises.

🔧 Changes

✅ Verification

  • Route: component- and hook-level tests against the real query client, router search handling and permission chain. Not exercised in a browser: port 5173 is shared with parallel sessions, and the tab needs a signed-in instance.
  • Tests: searchJobs.test.ts, canListJobs.test.ts (now version-driven: 5.3.0-beta.3 and 5.3.2 allow a listed grant, 5.2.9 and 5.3.0-beta.2 deny it, a loading version waits, an unreadable one is tried), jobFormat.test.ts, JobsTab.test.tsx (a 5.3 allowlist role gets the list, a 5.2 one gets the note with no jobs request, a failed version read is tried), StatusTabs.capability-gating.test.tsx (now 8 tabs, plus two Jobs deep-link cases), getJob.test.ts (the observer sees an ERROR job's structured message as sent and the rejection reads it as text, so both PRs' behaviors hold together), importData.test.tsx (both CSV sources, and the settle-after-unmount case), importProgressToast.test.ts, ImportDataModal.progress.test.tsx and formatDuration.test.ts.
  • Mutation-checked after the port: 10 of 10 guards went red when reverted. They are the version gate, the tried-when-unreadable branch, waiting for the version, the real-allowlist check, reading the version only for a listed grant, the observer seeing the ERROR job, the structured ERROR message read as text, the observer try/catch, the listener isolation, and the uploaded-CSV progress wiring. That last one had no test until this port: it stayed green when reverted, so importData.test.tsx now covers it. The 17 guards checked before the port are unchanged.
  • Auto-refresh off (34adaa96): JobsTab.test.tsx opens Jobs with no interval and sees one request across ten minutes, a refocus and a reconnect. It also shows that Jobs ignores a chart refresh=30000, polls on jobsRefresh=30000, and writes a picked interval to jobsRefresh without touching refresh. StatusTabs.url-sync.test.tsx pins that a switch from a chart tab opens Jobs at off, and that tab, range and interval changes carry both values. The redirect test covers the fourth navigation. statusSearch.test.ts and statusSearchScope.test.ts pin the defaults, that the two values are read independently, and that an explicit Jobs 60s stays in the URL.
  • Mutation-checked (34adaa96): 13 of 13 went red. They are the Jobs default back to 60s, a refocus refetch and a reconnect refetch while off, the query and the toolbar each reading the charts' interval, the picker writing refresh, the interval dropped from the query, the validator reading jobsRefresh from refresh, a tab switch inheriting the charts' interval, and each of the four StatusTabs navigations dropping jobsRefresh. No cross-model round ran on this commit: it changes a default and adds one URL parameter.
  • Gate (Node 24.21.0): on 34adaa96 the husky pre-commit hook's full vitest run passed 470 files (4,464 tests), npx tsc -b, npx oxlint --format stylish . and npx dprint check exited 0, and commitlint passed. The same gate passed on the merge commit (466 files).
  • Harper source (read-only checkout): request and response shapes and permissions were checked in server/jobs/jobs.ts (handleGetJob, handleGetJobsByStartDate, getJobsInDateRange), jobRunner.ts/jobProcess.ts (no mid-run progress; object messages), resources/databases.ts (NON_REPLICATING_SYSTEM_TABLES), and utility/operation_authorization.ts at release_4.1.0, release_4.7.0, v5.0.0, v5.2.9, v5.2.15, v5.3.0-beta.2/beta.3/v5.3.2. The shape is unchanged from 4.1, so no version gate is needed for the request itself.
  • Cross-model review of the port: a closing full round with codex (graded), gemini and the Harper domain adjudicator, with Cursor pruned on purpose, found nothing against the port, and its surviving findings were all adjudicated in earlier rounds (the unbounded 7d poll, cluster node-locality, keepPreviousData across an instance switch, the analytics tick, two test comments). The adjudicator noted that the version-read-failure branch is rarely reachable, because the instance route's loader already requires registration_info. The comments and docs now say so: the branch covers a failed refetch, not a refusal. After that wording fix, one more full round ran on the amended head (a661b95a). It converged with nothing new against the port. Its open major is the unbounded poll again, with a Harper-side cost traced this time (see the auto-refresh decision above).
  • Earlier rounds: in the first two the Cursor legs failed no-receipt (the 1Password SSH agent refused their git fetch) and the domain adjudicator failed auth; the third, at e87b8ed1, ran codex, gemini and domain.
  • gemini-code-assist feedback: its one thread said a click that ends a text selection in a row toggled the row. It was right, and e87b8ed1 addresses it. The check is scoped to the row rather than to any page selection. The test fails with the old handler, and it also fails with gemini's page-wide getSelection().toString() check.

🤖 Generated by Claude Code (Claude Opus); posted via @dawsontoth.

🤖 Generated with Claude Code

Related PRs: #1868 overlaps (merged; same waitForJob and importData lines, ported onto), #1884 overlaps (merged; canListJobs ported onto its gateVerdict), 12 others independent
Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=5; full=2 @ 34adaa9

Review-Attention: study ~9m (decisions: cluster-jobs-scope, tick-on-jobs, window-clamp-client-side, failed-version-tries-grant, do-less-alternative; raised: open major) @ 34adaa9

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new 'Jobs' tab to the instance Status dashboard, allowing users to view background operations such as CSV/S3 loads, exports, bulk deletes, and backups. It features independent polling, permission gating for super users, and node-specific job listings for clusters. Additionally, it integrates a progress toast for tracking data imports and includes comprehensive test coverage. Feedback on the changes suggests a UX improvement for the Jobs table to prevent row toggling when a user is actively selecting text inside a row.

Comment thread src/features/instance/status/jobs/JobsTab.tsx Outdated
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 73.19% 12108 / 16541
🔵 Statements 73.27% 12893 / 17596
🔵 Functions 68% 3216 / 4729
🔵 Branches 68.94% 9534 / 13828
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/databases/functions/importProgressToast.ts 100% 100% 100% 100%
src/features/instance/databases/modals/ImportDataModal.tsx 54.7% 47.32% 68% 54.86% 72, 75-77, 81, 91, 209-235, 239-243, 257-268, 270-274, 276-284, 294-295, 301-306, 332-333, 433-446
src/features/instance/status/statusSearch.ts 100% 100% 100% 100%
src/features/instance/status/analytics/StatusTabs.tsx 98.55% 96.87% 95.45% 98.41% 274
src/features/instance/status/jobs/JobsTab.tsx 94.44% 95.08% 86.36% 96% 67, 123, 275
src/features/instance/status/jobs/canListJobs.ts 100% 100% 100% 100%
src/features/instance/status/jobs/jobFormat.ts 100% 100% 100% 100%
src/integrations/api/instance/database/getJob.ts 100% 100% 100% 100%
src/integrations/api/instance/database/importData.ts 58.06% 21.73% 88.88% 55.55% 40-43, 62-74, 99-111
src/integrations/api/instance/status/searchJobs.ts 100% 85.71% 100% 100%
src/lib/formatDuration.ts 100% 100% 100% 100%
Generated in workflow #2273 for commit 34adaa9 by the Vitest Coverage Report Action

dawsontoth and others added 2 commits October 12, 2026 15:45
…'s job in a toast

Adds a Jobs tab to the instance Status page that lists the jobs Harper ran
in the selected window (search_jobs_by_start_date), newest first, with an
expandable row for each job's full record. It polls on the page's refresh
interval through pollUnlessForbidden, is offered only to roles that can run
the operation, and sits outside the get_analytics capability gate. The
operation takes no limit, so Jobs offers windows up to 7 days and renders
the 200 newest rows.

CSV imports now show their job in a loading toast that updates on every
get_job poll (queued/running and elapsed time) and becomes the result. The
toast is settled from the mutation's own callbacks, so it still resolves if
the modal unmounts mid-import, and a throwing listener cannot turn a
completed import into a reported failure.

Refs #1266

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tails

A drag that selects text in a summary row (the user, type or message) ends in a click on the row, so selecting a job's text opened or closed its details. The row now skips the toggle when the selection is non-empty and starts or ends inside it. The check is scoped to the row rather than to any page selection, so a click right after selecting text elsewhere still toggles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is picked for it

Each Jobs poll makes Harper run an unlimited select over system.hdb_job for the window, so Jobs now fetches when it opens and on Refresh now, and polls only on an interval chosen on the tab. While it is off, a refocus or reconnect does not refetch either.

The interval lives in its own jobsRefresh search param, which defaults to off. It is separate from the charts' refresh because one URL value cannot default to 60s on the charts and to off on Jobs: a tab switch would either start Jobs polling or reset the charts' choice. Every Status navigation carries both unchanged, so the charts' defaults and round trips are as before. Push updates are tracked in HarperFast/harper#3179.

Refs #1266

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth merged commit 945e948 into stage Oct 12, 2026
4 checks passed
@dawsontoth
dawsontoth deleted the claude/1266-status-jobs branch October 12, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant