✨ Show the export queued, then progress as a percentage

The widget could never say queued: the creation answers the row
status (`pending`) and the widget only knew `queued`, so every export
opened on the generic exporting wording. The widget now names a fresh
job queued, and only the first milestone moves it to running — the
worker sends that first breath right after the claim, before the
session roundtrip. The figure is a percentage once the worker said how
much there is, and nothing while it has not: a milestone without
counters names the stage but no longer blanks the figures, and the
queued and terminal states show no counter at all.

AI-assisted-by: glm-5.3-flash
This commit is contained in:
Andrey Antukh 2026-10-07 14:02:17 +00:00
parent a63140d309
commit 74f8d563b4
5 changed files with 72 additions and 26 deletions

View File

@ -360,15 +360,19 @@
;; registered a terminate callback for this job
(jobs/mark-cancelled job-id))]
(p/catch
(p/let [session' (api/create-job-session job-id)
(p/let [beats (start-beats! job-id stop-cmd!)
beats! (:beat! beats)
_ (reset! stop-watchdog! (with-watchdog! beats! (:last beats)))
;; the first breath right after the claim, before the
;; session roundtrip: the widget moves from queued to
;; exporting on it, and a cancel that landed meanwhile
;; raises here instead of after wasted work
_ (check-beat! (beats! :preparing {} :force? true))
session' (api/create-job-session job-id)
_ (reset! session session')
token (:session-token session')
_ (l/info :hint "render session minted"
:job-id (str job-id))
beats (start-beats! job-id stop-cmd!)
beats! (:beat! beats)
_ (reset! stop-watchdog! (with-watchdog! beats! (:last beats)))
_ (check-beat! (beats! :preparing {} :force? true))
plan (make-plan job-id token params)
resource (run-prepared! beats! cancelled plan)
_ (@stop-watchdog!)

View File

@ -256,9 +256,9 @@
respond! (fake-fetch! (answer-with params sid stages))
restore! (fake-render! "rendered!")]
(p/let [_ (worker/process! (run-claim job-id))]
(t/testing "the run went by claim, session, beats, settle"
(t/is (= ["claim-job" "create-job-session"
"report-job-progress" "report-job-progress" "report-job-progress"
(t/testing "the run went by claim, first breath, session, beats, settle"
(t/is (= ["claim-job" "report-job-progress" "create-job-session"
"report-job-progress" "report-job-progress"
"complete-job"]
(steps-of))))
(t/testing "the settle carried the real artifact, named and typed"

View File

@ -154,7 +154,9 @@
(defn- initialize-export-status
"The state a just created job starts at: the widget shows it queued,
with no figures yet — the counter comes with the first milestone the
worker publishes, and counts itself down from the items meanwhile."
worker publishes, and counts itself down from the items meanwhile.
The creation answers the row status (`pending`): the widget names it
queued, because nobody works on the job yet."
[exports cmd {:keys [id status]}]
(ptk/reify ::initialize-export-status
ptk/UpdateEvent
@ -169,7 +171,7 @@
:last-update (ct/now)
:cmd cmd
:job-id id
:status status}))))
:status (if (= "pending" status) "queued" status)}))))
(defn- counter-of
"The figures of one milestone: `objects` counts the shapes and
@ -181,8 +183,11 @@
(defn- add-milestone
"One beat of the job into the widget: what has landed and what is
coming, named by the stage the run is in. A milestone that arrives
once the widget settled from a cancel or an error is ignored."
coming, named by the stage the run is in. The status moves to running
on the first beat, figures or not. A milestone without figures (the
first breath of the worker) names the stage but keeps the figures the
widget already holds. A milestone that arrives once the widget
settled from a cancel or an error is ignored."
[payload]
(let [figures (counter-of (:counters payload))]
(ptk/reify ::add-milestone
@ -190,13 +195,15 @@
(update [_ state]
(if-not (get-in state [:export :in-progress])
state
(update state :export assoc
:status "running"
:stage (:stage payload)
:last-update (ct/now)
:progress (:current figures)
:total (or (:total figures)
(get-in state [:export :total]))))))))
(-> (update state :export assoc
:status "running"
:stage (:stage payload)
:last-update (ct/now))
(cond-> (some? figures)
(update :export assoc
:progress (:current figures)
:total (or (:total figures)
(get-in state [:export :total]))))))))))
(defn- add-outcome
"The last row of the job as the end of the widget. A completed job

View File

@ -246,11 +246,18 @@
(not cancelling?))
total (or (:total state) (count items))
complete? (= progress total)
;; The figure the detail names: a percentage when the worker
;; said how much there is, nothing when it has not said yet
;; (queued, or a milestone without counters).
percent (when (and (number? progress)
(number? total)
(pos? total))
(js/Math.round (* 100 (/ progress total))))
circ (* 2 Math/PI 12)
pct (if (zero? total) circ (- circ (* circ (/ progress total))))
pwidth
(if error?
(if (or error? (zero? total))
280
(/ (* progress 280) total))
@ -338,16 +345,22 @@
[:button {:class (stl/css :retry-btn)
:on-click cancel-export}
(tr "workspace.options.cancel-export")]
[:span {:class (stl/css :progress)}
(dm/str progress " / " total)]]
(when (some? percent)
[:span {:class (stl/css :progress)}
(dm/str percent "%")])]
;; A counter for work that is being abandoned says nothing useful.
(or cancelling? cancelled?)
;; A counter for work that is waiting, going away, or gone
;; says nothing useful.
(or queued? cancelling? cancelled?)
nil
:else
;; The running figure, once the worker said how much there is.
(some? percent)
[:span {:class (stl/css :progress)}
(dm/str progress " / " total)])]
(dm/str percent "%")]
:else
nil)]
[:button {:class (stl/css :progress-close-button)
:on-click toggle-detail-visibility}

View File

@ -71,6 +71,28 @@
(t/is (= (export-with-name (str (:object-id export)))
(de/normalize-export (assoc export :name "")))))
(t/deftest initialize-export-status-shows-a-fresh-job-as-queued
;; the creation answers the row status ("pending"): nobody works on
;; the job yet, so the widget names it queued until the first
;; milestone moves it to running
(let [state (ptk/update (#'de/initialize-export-status
[export] nil {:id (uuid/next) :status "pending"})
{})]
(t/is (= "queued" (get-in state [:export :status])))
(t/is (true? (get-in state [:export :in-progress])))
(t/is (= 0 (get-in state [:export :progress])))))
(t/deftest add-milestone-without-figures-keeps-the-figures
;; the first breath of the worker (:preparing) carries no counters:
;; it moves the widget to running without blanking the figures
(let [before {:export {:in-progress true :status "queued"
:progress 2 :total 5}}
after (ptk/update (#'de/add-milestone {:stage :preparing :counters {}})
before)]
(t/is (= "running" (get-in after [:export :status])))
(t/is (= 2 (get-in after [:export :progress])))
(t/is (= 5 (get-in after [:export :total])))))
(t/deftest request-simple-export-runs-one-assets-job
(t/async done
(let [export (export-with-name "")