@huaqiu/dsh-tool-plugin-update 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,11 +26,14 @@ import {
26
26
  setPluginUpdateSource,
27
27
  setRuntimeUpdateSource,
28
28
  unignoreOffer,
29
+ TERMINAL_PHASES,
29
30
  type PluginUpdateItem,
30
31
  type PluginUpdateReport,
32
+ type ReleaseInfo,
31
33
  type RuntimeUpdateSource,
34
+ type UpdateJob,
32
35
  } from './api.js'
33
- import type { UpdateCopy } from './copy.js'
36
+ import { formatMb, type UpdateCopy } from './copy.js'
34
37
  import {
35
38
  CHECK_CIRCLE_ICON,
36
39
  CLOSE_ICON,
@@ -44,7 +47,17 @@ import {
44
47
  export interface UpdateStoreLike {
45
48
  report(): PluginUpdateReport | null
46
49
  installing(): boolean
50
+ /** Mark the installing footer state (set by the panel before the restart). */
51
+ setInstalling(flag: boolean): void
52
+ /** The durable job the server currently reports as active (resumed). */
53
+ activeJob(): UpdateJob | null
54
+ /** Bounded recent jobs (newest first), for the result/history view. */
55
+ recentJobs(): UpdateJob[]
47
56
  refresh(): Promise<PluginUpdateReport | null>
57
+ /** Force a fresh update check (used by the failed-check Retry action). */
58
+ check(): Promise<PluginUpdateReport | null>
59
+ /** Re-read `/status` (resume an active job immediately after submitting). */
60
+ fetchStatus(): Promise<void>
48
61
  subscribe(listener: () => void): () => void
49
62
  }
50
63
 
@@ -159,7 +172,7 @@ function packageCard(
159
172
  const card = el('li', 'hqpu-card')
160
173
  card.dataset.pluginName = item.name
161
174
  card.dataset.state = item.state
162
- // The full package name is the card's tooltip; the row shows the short name.
175
+ // The full package name is the card's tooltip; the row shows the display name.
163
176
  card.title = `${item.name} · ${versionLine(item)}`
164
177
 
165
178
  const head = el('div', 'hqpu-card-head')
@@ -170,15 +183,29 @@ function packageCard(
170
183
 
171
184
  const main = el('div', 'hqpu-card-main')
172
185
  const titleRow = el('div', 'hqpu-title-row')
173
- const title = el('span', 'hqpu-card-title', shortName(item.name))
186
+ // Prefer the user-facing display name from a v2 manifest; fall back to the
187
+ // shortened package id (the shell's own naming rule).
188
+ const titleText =
189
+ typeof item.displayName === 'string' && item.displayName.trim().length > 0
190
+ ? item.displayName.trim()
191
+ : shortName(item.name)
192
+ const title = el('span', 'hqpu-card-title', titleText)
174
193
  const tag = el('span', 'hqpu-tag')
175
194
  tag.dataset.tone = stateTone(item)
176
195
  tag.textContent = stateLabel(item, copy)
177
196
  titleRow.append(title, tag)
178
197
 
179
- const desc = el('span', 'hqpu-card-desc', versionLine(item))
180
-
198
+ // Version line, with the download size appended when the manifest knows it.
199
+ const size = formatMb(item.sizeBytes ?? 0)
200
+ const descText = size.length > 0 ? `${versionLine(item)} · ${size}` : versionLine(item)
201
+ const desc = el('span', 'hqpu-card-desc', descText)
181
202
  main.append(titleRow, desc)
203
+
204
+ // Optional one-line description from a v2 manifest.
205
+ if (typeof item.description === 'string' && item.description.trim().length > 0) {
206
+ main.append(el('span', 'hqpu-card-desc', item.description.trim()))
207
+ }
208
+
182
209
  head.append(glyph, main)
183
210
 
184
211
  const end = el('div', 'hqpu-card-end')
@@ -186,7 +213,9 @@ function packageCard(
186
213
  const button = el('button', 'hqpu-btn hqpu-btn--sm hqpu-btn--primary')
187
214
  button.type = 'button'
188
215
  button.disabled = installing
216
+ // Honest label: this installs the WHOLE offered set, not just one card.
189
217
  button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`
218
+ button.title = copy.installOneTip
190
219
  button.addEventListener('click', () => {
191
220
  void runInstall(store, copy)
192
221
  })
@@ -195,12 +224,38 @@ function packageCard(
195
224
  head.append(end)
196
225
  card.append(head)
197
226
 
198
- // A block reason belongs to a blocked offer only.
227
+ // Per-plugin change list (v2 manifest), behind a small expander.
228
+ if (Array.isArray(item.changes) && item.changes.length > 0) {
229
+ const expander = el('div', 'hqpu-changes')
230
+ const toggle = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.release.changesTitle)
231
+ toggle.type = 'button'
232
+ const list = el('ul', 'hqpu-changes-list')
233
+ list.style.display = 'none'
234
+ for (const change of item.changes) {
235
+ if (typeof change === 'string' && change.length > 0) {
236
+ list.append(el('li', '', change))
237
+ }
238
+ }
239
+ toggle.addEventListener('click', () => {
240
+ list.style.display = list.style.display === 'none' ? '' : 'none'
241
+ })
242
+ expander.append(toggle, list)
243
+ card.append(expander)
244
+ }
245
+
246
+ // A block reason belongs to a blocked offer only — render BOTH the mapped
247
+ // code copy and the server's full reasons[]/notes[] (computed but, before
248
+ // now, never shown).
199
249
  if (item.state === 'blocked') {
200
250
  const reason = blockedReason(item, copy)
201
251
  if (reason !== undefined) {
202
252
  card.append(el('p', 'hqpu-card-reason', reason))
203
253
  }
254
+ for (const entry of [...(item.reasons ?? []), ...(item.notes ?? [])]) {
255
+ if (typeof entry === 'string' && entry.length > 0) {
256
+ card.append(el('p', 'hqpu-card-reason', entry))
257
+ }
258
+ }
204
259
  }
205
260
  return card
206
261
  }
@@ -267,6 +322,305 @@ function skeleton(copy: UpdateCopy): HTMLElement {
267
322
  return section
268
323
  }
269
324
 
325
+ // ── Durable job views (active / result / recent) ───────────────────────────
326
+
327
+ /** Format a byte count for the progress line, or null when unknown. */
328
+ function formatBytes(n: number | undefined): string | null {
329
+ if (n === undefined || !Number.isFinite(n) || n < 0) return null
330
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`
331
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`
332
+ if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`
333
+ return `${n} B`
334
+ }
335
+
336
+ /** Format an ETA in seconds to a short human string, or null when unknown. */
337
+ function formatEta(seconds: number | undefined): string | null {
338
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
339
+ if (seconds < 60) return `${Math.round(seconds)} s`
340
+ const minutes = Math.round(seconds / 60)
341
+ if (minutes < 60) return `${minutes} min`
342
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
343
+ }
344
+
345
+ function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
346
+ return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
347
+ }
348
+
349
+ /** The phase in human words, falling back to the raw code. */
350
+ function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
351
+ return copy.job.phase[job.phase] ?? job.phase
352
+ }
353
+
354
+ /** One per-step line for plugin-set installs (and any job that reports steps). */
355
+ function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
356
+ if (!Array.isArray(job.steps) || job.steps.length === 0) return null
357
+ const wrap = el('div', 'hqpu-job-steps')
358
+ wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
359
+ const ol = el('ol', 'hqpu-steps')
360
+ for (const step of job.steps) {
361
+ const li = el('li', `hqpu-step hqpu-step--${step.state}`)
362
+ const dot = el('span', 'hqpu-dot')
363
+ dot.dataset.state =
364
+ step.state === 'installed'
365
+ ? 'done'
366
+ : step.state === 'failed'
367
+ ? 'error'
368
+ : step.state === 'running'
369
+ ? 'warning'
370
+ : 'idle'
371
+ li.append(dot)
372
+ const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
373
+ li.append(el('span', '', text))
374
+ ol.append(li)
375
+ }
376
+ wrap.append(ol)
377
+ return wrap
378
+ }
379
+
380
+ /**
381
+ * The ACTIVE-JOB view: type + target, previous→target, current phase, a
382
+ * determinate bar only when a real percentage exists, bytes/speed/ETA when the
383
+ * server publishes them, and what happens next. Closing the panel never
384
+ * cancels the job — say so.
385
+ */
386
+ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
387
+ const section = el('section', 'hqpu-group')
388
+ section.dataset.group = 'active-job'
389
+
390
+ const head = el('div', 'hqpu-group-head')
391
+ const tag = el('span', 'hqpu-tag')
392
+ tag.dataset.tone = 'info'
393
+ tag.textContent = jobKindLabel(job, copy)
394
+ head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} → ${job.targetVersion}`), tag)
395
+ section.append(head)
396
+
397
+ const card = el('div', 'hqpu-job')
398
+
399
+ const phaseLine = el('p', 'hqpu-status hqpu-status--dot')
400
+ phaseLine.append(el('span', 'hqpu-spinner'))
401
+ const transition =
402
+ job.previousVersion !== null && job.previousVersion.length > 0
403
+ ? `${job.previousVersion} → ${job.targetVersion} · ${phaseLabel(job, copy)}`
404
+ : `${job.targetVersion} · ${phaseLabel(job, copy)}`
405
+ phaseLine.append(el('span', '', transition))
406
+ card.append(phaseLine)
407
+
408
+ if (typeof job.message === 'string' && job.message.length > 0) {
409
+ card.append(el('p', 'hqpu-status', job.message))
410
+ }
411
+
412
+ // Determinate bar only when the server gives a real percentage — never show
413
+ // 100% before the job is terminal.
414
+ const pct = job.progress.percent
415
+ if (pct !== null) {
416
+ const bar = el('div', 'hqpu-progress')
417
+ const fill = el('div', 'hqpu-progress-fill')
418
+ fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
419
+ bar.append(fill)
420
+ card.append(bar)
421
+ }
422
+
423
+ const bits: string[] = []
424
+ if (pct !== null) bits.push(`${Math.round(pct)}%`)
425
+ const done = formatBytes(job.progress.bytesDone)
426
+ const total = formatBytes(job.progress.bytesTotal)
427
+ if (done !== null && total !== null) bits.push(copy.job.bytes(done, total))
428
+ const speed = formatBytes(job.progress.bytesPerSecond)
429
+ if (speed !== null) bits.push(copy.job.speed(speed))
430
+ const eta = formatEta(job.progress.etaSeconds)
431
+ if (eta !== null) bits.push(copy.job.eta(eta))
432
+ if (bits.length > 0) card.append(el('p', 'hqpu-status hqpu-job-meta', bits.join(' · ')))
433
+
434
+ const steps = stepsList(job, copy)
435
+ if (steps !== null) card.append(steps)
436
+
437
+ // What happens next — restart / runtime swap + elevation implications.
438
+ card.append(el('p', 'hqpu-status', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh))
439
+ card.append(el('p', 'hqpu-status', copy.job.closeNote))
440
+ section.append(card)
441
+ return section
442
+ }
443
+
444
+ /**
445
+ * The RESULT view for a terminal job: success / failed / rolled-back /
446
+ * abandoned, previous→installed, per-package steps, errorCode + message, a log
447
+ * correlation hint, and retry guidance (a retry starts a NEW job).
448
+ */
449
+ function resultView(job: UpdateJob, copy: UpdateCopy, onRetry: () => void): HTMLElement {
450
+ const section = el('section', 'hqpu-group')
451
+ section.dataset.group = 'job-result'
452
+
453
+ const tone =
454
+ job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
455
+ const titleText =
456
+ job.phase === 'done'
457
+ ? copy.job.resultDone
458
+ : job.phase === 'failed'
459
+ ? copy.job.resultFailed
460
+ : job.phase === 'rolled-back'
461
+ ? copy.job.resultRolledBack
462
+ : copy.job.resultAbandoned
463
+
464
+ const head = el('div', 'hqpu-group-head')
465
+ const tag = el('span', 'hqpu-tag')
466
+ tag.dataset.tone = tone
467
+ tag.textContent = titleText
468
+ head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
469
+ section.append(head)
470
+
471
+ const card = el('div', 'hqpu-job')
472
+ const res = job.result
473
+ const from = job.previousVersion ?? res?.installedVersion ?? '—'
474
+ const to = res?.installedVersion ?? job.targetVersion
475
+ const finished =
476
+ res !== undefined && res.finishedAt.length > 0
477
+ ? ` · ${new Date(res.finishedAt).toLocaleString()}`
478
+ : ''
479
+ card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
480
+ if (res !== undefined && res.message !== undefined && res.message.length > 0) {
481
+ card.append(el('p', 'hqpu-status', res.message))
482
+ }
483
+ if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
484
+ card.append(el('p', 'hqpu-status', res.errorCode))
485
+ }
486
+ if (typeof job.logHint === 'string' && job.logHint.length > 0) {
487
+ card.append(el('p', 'hqpu-status hqpu-job-meta', copy.job.logHint(job.logHint)))
488
+ }
489
+
490
+ const steps = stepsList(job, copy)
491
+ if (steps !== null) card.append(steps)
492
+
493
+ // Retry starts a NEW update job — never re-click the settled job id.
494
+ if (job.phase === 'failed' || job.phase === 'abandoned') {
495
+ const retry = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.job.retry)
496
+ retry.type = 'button'
497
+ retry.addEventListener('click', onRetry)
498
+ card.append(retry)
499
+ }
500
+ section.append(card)
501
+ return section
502
+ }
503
+
504
+ /** A one-line row for older recent jobs (newest already shown as the card). */
505
+ function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
506
+ const older = jobs.slice(1, 5)
507
+ if (older.length === 0) return null
508
+ const wrap = el('div', 'hqpu-group')
509
+ wrap.dataset.group = 'recent-jobs'
510
+ wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
511
+ const ul = el('ul', 'hqpu-cards')
512
+ for (const job of older) {
513
+ const li = el('li', 'hqpu-status')
514
+ const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
515
+ li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
516
+ ul.append(li)
517
+ }
518
+ wrap.append(ul)
519
+ return wrap
520
+ }
521
+
522
+ /**
523
+ * Check-status banner: a FAILED check shows a clear error row + Retry (never
524
+ * the silent "no updates" empty state); a successful check shows the time;
525
+ * when a check failed but a prior report exists it is labelled stale.
526
+ */
527
+ function checkBanner(
528
+ report: PluginUpdateReport,
529
+ copy: UpdateCopy,
530
+ onRetry: () => void,
531
+ ): HTMLElement | null {
532
+ const failed = typeof report.lastError === 'string' && report.lastError.length > 0
533
+ const checked = typeof report.checkedAt === 'string' && report.checkedAt.length > 0
534
+ if (!failed && !checked) return null
535
+
536
+ const wrap = el('section', 'hqpu-group')
537
+ wrap.dataset.group = 'check-status'
538
+ if (failed) {
539
+ const banner = el('div', 'hqpu-banner')
540
+ banner.dataset.tone = 'danger'
541
+ banner.innerHTML = WARNING_ICON(14)
542
+ banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
543
+ wrap.append(banner)
544
+ if (report.items.length > 0) {
545
+ wrap.append(el('p', 'hqpu-status', copy.staleNote))
546
+ }
547
+ const row = el('div', 'hqpu-actions')
548
+ const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
549
+ retry.type = 'button'
550
+ retry.addEventListener('click', onRetry)
551
+ row.append(retry)
552
+ wrap.append(row)
553
+ return wrap
554
+ }
555
+ wrap.append(
556
+ el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
557
+ )
558
+ return wrap
559
+ }
560
+
561
+ /** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
562
+ function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
563
+ if (release === null || release === undefined) return null
564
+ const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
565
+ const breaking = Array.isArray(release.breaking)
566
+ ? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
567
+ : []
568
+ const hasContent =
569
+ (typeof release.summary === 'string' && release.summary.length > 0) ||
570
+ notes.length > 0 ||
571
+ breaking.length > 0 ||
572
+ typeof release.notesUrl === 'string' ||
573
+ typeof release.publishedAt === 'string'
574
+ if (!hasContent) return null
575
+
576
+ const section = el('section', 'hqpu-group')
577
+ section.dataset.group = 'release'
578
+ section.append(el('h3', 'hqpu-group-title', copy.release.summaryTitle))
579
+ if (typeof release.summary === 'string' && release.summary.length > 0) {
580
+ section.append(el('p', 'hqpu-status', release.summary))
581
+ }
582
+ if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
583
+ section.append(el('p', 'hqpu-status', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())))
584
+ }
585
+ if (release.requiresRestart === true) {
586
+ section.append(el('p', 'hqpu-status', copy.job.willRestartDsh))
587
+ }
588
+ if (notes.length > 0) {
589
+ const ul = el('ul', 'hqpu-release-notes')
590
+ for (const note of notes) ul.append(el('li', '', note as string))
591
+ section.append(ul)
592
+ }
593
+ if (breaking.length > 0) {
594
+ const banner = el('div', 'hqpu-banner')
595
+ banner.dataset.tone = 'danger'
596
+ banner.innerHTML = WARNING_ICON(14)
597
+ const inner = el('span')
598
+ inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
599
+ inner.append(document.createTextNode(breaking.join(' · ')))
600
+ banner.append(inner)
601
+ section.append(banner)
602
+ }
603
+ if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
604
+ const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
605
+ link.href = release.notesUrl
606
+ link.target = '_blank'
607
+ link.rel = 'noreferrer'
608
+ section.append(link)
609
+ }
610
+ return section
611
+ }
612
+
613
+ /** Native disclosure that hides the developer-facing source editors by
614
+ * default (end users should never need to type a manifest URL). */
615
+ function advancedBlock(copy: UpdateCopy, nodes: Array<HTMLElement | null>): HTMLElement {
616
+ const details = el('details', 'hqpu-advanced')
617
+ details.append(el('summary', '', copy.release.advanced))
618
+ for (const node of nodes) {
619
+ if (node !== null) details.append(node)
620
+ }
621
+ return details
622
+ }
623
+
270
624
  /** Mutable editor state for one manifest source (lives in the panel closure). */
271
625
  interface SourceState {
272
626
  editing: boolean
@@ -562,6 +916,10 @@ function runtimeGroup(
562
916
  row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
563
917
  if (runtime.updateAvailable && latestVersion !== null) {
564
918
  row(copy.runtime.latest, latestVersion)
919
+ // v2 manifest metadata: download size before committing to the update.
920
+ const latestEntry = runtime.latest
921
+ const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
922
+ if (size.length > 0) row(copy.runtime.sizeLabel, size)
565
923
  }
566
924
  if (report.manifestVersion !== null) {
567
925
  row(copy.runtime.set, report.manifestVersion, true)
@@ -577,9 +935,10 @@ function runtimeGroup(
577
935
 
578
936
  // The editor: point the runtime at another release channel (a staging
579
937
  // manifest, a local rig) without an env var and a restart. hq-edge persists
580
- // the override per host, so it survives restarts of both processes.
938
+ // the override per host, so it survives restarts of both processes. It is a
939
+ // developer affordance, so it hides behind an "advanced" disclosure.
581
940
  if (source !== null) {
582
- section.append(sourceEditor('runtime', source, copy, actions))
941
+ section.append(advancedBlock(copy, [sourceEditor('runtime', source, copy, actions)]))
583
942
  }
584
943
 
585
944
  // ── Runtime update action row ──
@@ -621,10 +980,45 @@ function runtimeGroup(
621
980
  row.append(start)
622
981
  }
623
982
  section.append(row)
983
+ // Honest duration note: no fixed "one minute" claim.
984
+ section.append(el('p', 'hqpu-status hqpu-job-meta', copy.runtime.durationNote))
624
985
  } else {
625
986
  section.append(el('p', 'hqpu-status', copy.runtime.note))
626
987
  }
627
988
 
989
+ // The host's own report of the last runtime swap (succeeded / rolled_back /
990
+ // failed). Before this it was read from disk by hq-edge and never shown — a
991
+ // rolled-back swap was indistinguishable from "no update ever happened".
992
+ const lastResult = runtime.lastResult
993
+ if (lastResult !== null && lastResult !== undefined) {
994
+ const state = lastResult.state
995
+ const line = el('p', 'hqpu-status hqpu-status--dot')
996
+ line.setAttribute('role', 'status')
997
+ const dot = el('span', 'hqpu-dot')
998
+ dot.dataset.state = state === 'succeeded' ? 'done' : state === 'failed' ? 'error' : 'warning'
999
+ const head =
1000
+ state === 'succeeded'
1001
+ ? copy.runtime.lastResult.succeeded
1002
+ : state === 'failed'
1003
+ ? copy.runtime.lastResult.failed
1004
+ : copy.runtime.lastResult.rolledBack
1005
+ const parts = [head]
1006
+ if (lastResult.targetVersion) parts.push(`→ ${lastResult.targetVersion}`)
1007
+ if (lastResult.installedVersion) parts.push(`(${lastResult.installedVersion})`)
1008
+ if (lastResult.at) parts.push(new Date(lastResult.at).toLocaleString())
1009
+ line.append(dot, el('span', '', parts.join(' ')))
1010
+ section.append(line)
1011
+ if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
1012
+ section.append(el('p', 'hqpu-status', lastResult.message))
1013
+ }
1014
+ if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
1015
+ section.append(el('p', 'hqpu-status hqpu-job-meta', lastResult.errorCode))
1016
+ }
1017
+ if (state !== 'succeeded') {
1018
+ section.append(el('p', 'hqpu-status hqpu-job-meta', copy.runtime.lastResult.elevation))
1019
+ }
1020
+ }
1021
+
628
1022
  if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
629
1023
  const line = el('p', 'hqpu-status hqpu-status--dot')
630
1024
  line.setAttribute('role', 'status')
@@ -642,14 +1036,26 @@ function renderList(
642
1036
  copy: UpdateCopy,
643
1037
  installing: boolean,
644
1038
  store: UpdateStoreLike,
1039
+ onRetryCheck: () => void,
645
1040
  ): void {
646
1041
  section.textContent = ''
647
1042
  if (report === null) {
648
1043
  section.append(skeleton(copy))
649
1044
  return
650
1045
  }
1046
+ // Check-status banner: a failed check shows an error row + Retry (never the
1047
+ // silent "no updates" empty state); a successful check shows the time.
1048
+ const banner = checkBanner(report, copy, onRetryCheck)
1049
+ if (banner !== null) section.append(banner)
1050
+ // Set-level release metadata (v2 manifest): summary, notes, breaking.
1051
+ const release = releaseHeader(report.release, copy)
1052
+ if (release !== null) section.append(release)
651
1053
  if (report.items.length === 0) {
652
- section.append(el('div', 'hqpu-empty', copy.empty))
1054
+ // A successful check with zero updates — distinct from a failed check,
1055
+ // which already rendered its error row above.
1056
+ if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
1057
+ section.append(el('div', 'hqpu-empty', copy.empty))
1058
+ }
653
1059
  return
654
1060
  }
655
1061
 
@@ -681,7 +1087,9 @@ function renderRuntime(
681
1087
  section.append(runtimeGroup(report, copy, actions))
682
1088
  }
683
1089
 
684
- /** The plugin-source group, above the plugin groups. No-op without the field. */
1090
+ /** The plugin-source group, above the plugin groups, hidden behind an advanced
1091
+ * disclosure (manifest-URL editing is a developer affordance). No-op without
1092
+ * the field. */
685
1093
  function renderPluginSource(
686
1094
  section: HTMLElement,
687
1095
  report: PluginUpdateReport | null,
@@ -691,10 +1099,10 @@ function renderPluginSource(
691
1099
  section.textContent = ''
692
1100
  if (report === null) return
693
1101
  const node = pluginSourceGroup(report, copy, actions)
694
- if (node !== null) section.append(node)
1102
+ if (node !== null) section.append(advancedBlock(copy, [node]))
695
1103
  }
696
1104
 
697
- /** Run the install transaction, then keep polling until the state settles. */
1105
+ /** Run the install transaction (a new durable job), then let the store poll it. */
698
1106
  async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<void> {
699
1107
  const report = store.report()
700
1108
  if (report === null) return
@@ -704,17 +1112,23 @@ async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<voi
704
1112
  showToast(copy.checkFailed, 'error')
705
1113
  return
706
1114
  }
1115
+ // Flip the installing flag BEFORE the request: this is what disables the
1116
+ // action buttons and shows the "DSH will restart" footer state (previously
1117
+ // dead code — runInstall never called it).
1118
+ store.setInstalling(true)
707
1119
  try {
708
1120
  const result = await installSet(report.manifestVersion)
709
1121
  if (result.error !== undefined) {
1122
+ store.setInstalling(false)
710
1123
  showToast(`${copy.installError}: ${result.error}`, 'error')
711
1124
  return
712
1125
  }
713
- // The set activates atomically and DSH restarts. Keep polling the backend
714
- // state rather than trusting the response alone (spec §7.1).
1126
+ // 202 accepted: a durable job now owns the lifecycle. The store's /status
1127
+ // poller renders progress and clears the flag when the job settles.
715
1128
  showToast(copy.installSuccess, 'success')
716
- void pollUntilSettled(store)
1129
+ void store.fetchStatus()
717
1130
  } catch (err: unknown) {
1131
+ store.setInstalling(false)
718
1132
  showToast(
719
1133
  `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
720
1134
  'error',
@@ -806,29 +1220,6 @@ function renderActions(
806
1220
  }
807
1221
  }
808
1222
 
809
- /** After an install, poll the backend until the update report settles
810
- * (installing flag clears or DSH restarts under us — the next load re-reads
811
- * the fresh state). */
812
- function pollUntilSettled(store: UpdateStoreLike): void {
813
- let attempts = 0
814
- const tick = (): void => {
815
- void store
816
- .refresh()
817
- .then((report) => {
818
- if (store.installing()) return
819
- if (report !== null && report.ignoredSet === false && attempts >= 2) return
820
- })
821
- .catch(() => undefined)
822
- .finally(() => {
823
- attempts += 1
824
- if (attempts < 10) {
825
- window.setTimeout(tick, 2000)
826
- }
827
- })
828
- }
829
- tick()
830
- }
831
-
832
1223
  /**
833
1224
  * Open (or focus) the update panel. Re-renders from the store; when the store
834
1225
  * is stale the panel first refreshes the report.
@@ -883,11 +1274,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
883
1274
  // ── Scrolling body: the centred column ──
884
1275
  const scroll = el('div', 'hqpu-scroll')
885
1276
  const column = el('div', 'hqpu-column')
1277
+ // The durable-job view leads: an active install/runtime update is resumed on
1278
+ // open BEFORE anything else, and a recent terminal result is shown here too.
1279
+ const jobSection = el('div', 'hqpu-stack')
886
1280
  const pluginSourceSection = el('div', 'hqpu-stack')
887
1281
  const listSection = el('div', 'hqpu-stack')
888
1282
  const runtimeSection = el('div', 'hqpu-stack')
889
1283
  const toastSection = el('div', 'hqpu-toast-host')
890
- column.append(pluginSourceSection, listSection, runtimeSection, toastSection)
1284
+ column.append(jobSection, pluginSourceSection, listSection, runtimeSection, toastSection)
891
1285
  scroll.append(column)
892
1286
  panel.append(scroll)
893
1287
 
@@ -1061,6 +1455,9 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1061
1455
  // server that is already gone.
1062
1456
  runtimeAccepted = true
1063
1457
  showToast(copy.runtime.updateAccepted, 'success')
1458
+ // Adopt the new durable job immediately so the active-job view renders
1459
+ // (it polls from here; this process is about to be swapped away anyway).
1460
+ void store.fetchStatus()
1064
1461
  } catch (err: unknown) {
1065
1462
  showToast(
1066
1463
  `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
@@ -1072,16 +1469,52 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1072
1469
  }
1073
1470
  }
1074
1471
 
1472
+ /** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
1473
+ * re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
1474
+ * settled job id. */
1475
+ const retryJob = (kind: 'plugin-set' | 'runtime'): void => {
1476
+ if (kind === 'runtime') {
1477
+ runtimeArmed = true
1478
+ render()
1479
+ } else {
1480
+ void runInstall(store, copy)
1481
+ }
1482
+ }
1483
+
1484
+ const renderJobSection = (): void => {
1485
+ jobSection.textContent = ''
1486
+ const active = store.activeJob()
1487
+ if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
1488
+ // An active job leads the panel — resumed on open, polled to its end.
1489
+ jobSection.append(activeJobView(active, copy))
1490
+ return
1491
+ }
1492
+ const recent = store.recentJobs()
1493
+ const latest = recent[0]
1494
+ if (latest !== undefined && TERMINAL_PHASES.has(latest.phase)) {
1495
+ jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)))
1496
+ const older = recentJobsList(recent, copy)
1497
+ if (older !== null) jobSection.append(older)
1498
+ }
1499
+ }
1500
+
1075
1501
  const render = (): void => {
1076
1502
  const report = store.report()
1077
- const installing = store.installing()
1503
+ // The footer "installing" state is owned by the store flag OR any active
1504
+ // durable job — buttons stay disabled while either is true.
1505
+ const jobActive =
1506
+ store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
1507
+ const installing = store.installing() || jobActive
1078
1508
  const stale = report === null
1079
1509
  refreshBtn.disabled = stale
1510
+ renderJobSection()
1080
1511
  if (stale) {
1081
1512
  listSection.textContent = ''
1082
1513
  listSection.append(skeleton(copy))
1083
1514
  } else {
1084
- renderList(listSection, report, copy, installing, store)
1515
+ renderList(listSection, report, copy, installing, store, () => {
1516
+ void store.check()
1517
+ })
1085
1518
  }
1086
1519
  renderPluginSource(pluginSourceSection, report, copy, pluginActions)
1087
1520
  renderRuntime(runtimeSection, report, copy, runtimeActions)