@huaqiu/dsh-tool-plugin-update 0.6.16 → 0.6.18

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.
package/lib/index.mjs CHANGED
@@ -35,6 +35,12 @@ function parsePath(req) {
35
35
  if (!pathname.startsWith("/api/v1/huaqiu/plugin-update")) return null;
36
36
  return pathname.slice(28);
37
37
  }
38
+ /** The raw `?…` of the incoming request ('' when there is none). */
39
+ function parseQuery(req) {
40
+ const url = req.url ?? "";
41
+ const q = url.indexOf("?");
42
+ return q >= 0 ? url.slice(q) : "";
43
+ }
38
44
  /** Read the request body (bounded) as UTF-8 text. */
39
45
  function readBody(req) {
40
46
  return new Promise((resolve, reject) => {
@@ -108,6 +114,11 @@ function routeFor(sub, method) {
108
114
  method: "GET",
109
115
  path: "/api/v1/plugin-updates/status"
110
116
  } : null;
117
+ if (sub === "/activity") return method === "GET" ? {
118
+ method: "GET",
119
+ path: "/api/v1/plugin-updates/activity",
120
+ passQuery: true
121
+ } : null;
111
122
  const jobId = matchJobStatusPath(sub);
112
123
  if (jobId !== null) return method === "GET" ? {
113
124
  method: "GET",
@@ -163,7 +174,8 @@ const STATIC_SUB_PATHS = /* @__PURE__ */ new Set([
163
174
  "/runtime-request",
164
175
  "/runtime-source",
165
176
  "/plugin-source",
166
- "/status"
177
+ "/status",
178
+ "/activity"
167
179
  ]);
168
180
  /**
169
181
  * Whether a sub-path names a route this forwarder recognizes at all. A
@@ -206,8 +218,9 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
206
218
  const budget = target.timeoutMs ?? timeoutMs;
207
219
  const controller = new AbortController();
208
220
  const timer = setTimeout(() => controller.abort(), budget);
221
+ const upstreamUrl = `${base}${target.path}${target.passQuery === true ? parseQuery(req) : ""}`;
209
222
  try {
210
- const upstream = await fetch(`${base}${target.path}`, {
223
+ const upstream = await fetch(upstreamUrl, {
211
224
  method: target.method,
212
225
  headers: {
213
226
  "content-type": "application/json; charset=utf-8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-tool-plugin-update",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -31,8 +31,8 @@
31
31
  "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.2"
32
32
  },
33
33
  "dependencies": {
34
- "@huaqiu/dsh-plugin-log": "0.6.16",
35
- "@huaqiu/dsh-sidebar-entry": "0.6.16"
34
+ "@huaqiu/dsh-sidebar-entry": "0.6.18",
35
+ "@huaqiu/dsh-plugin-log": "0.6.18"
36
36
  },
37
37
  "files": [
38
38
  "lib",
package/src/client/api.ts CHANGED
@@ -286,6 +286,28 @@ export interface UpdateJobStatus {
286
286
  recentJobs: UpdateJob[]
287
287
  }
288
288
 
289
+ /**
290
+ * `GET /activity`: the re-attach snapshot.
291
+ *
292
+ * The panel can be updating its own package, which restarts the DSH process
293
+ * that serves this plugin — the page then has to rebuild its state from
294
+ * scratch. `bootId` changes when that process is recreated, `stalled` says the
295
+ * active job has stopped advancing (so the UI must stop animating a progress
296
+ * bar that will never move), and `setVersion` is what is actually installed.
297
+ */
298
+ export interface UpdateActivity {
299
+ bootId: string
300
+ startedAt: string
301
+ now: string
302
+ activeJob: UpdateJob | null
303
+ /** The job named by `?job=`, settled or not; null when not asked/unknown. */
304
+ job: UpdateJob | null
305
+ phaseAgeMs: number
306
+ stalled: boolean
307
+ setVersion: string | null
308
+ actionableCount: number
309
+ }
310
+
289
311
  const PHASE_VALUES: ReadonlySet<string> = new Set<string>([
290
312
  'queued',
291
313
  'resolving',
@@ -566,3 +588,37 @@ export function fetchJob(id: string): Promise<UpdateJob> {
566
588
  return job
567
589
  })
568
590
  }
591
+
592
+ /**
593
+ * Normalize `GET /activity`. Unknown/missing fields degrade to a usable
594
+ * snapshot: a `bootId` empty string simply never triggers a re-attach, and an
595
+ * unparseable age reads as 0 rather than "stalled".
596
+ */
597
+ export function normalizeActivity(raw: unknown): UpdateActivity {
598
+ const r = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
599
+ const str = (v: unknown): string => (typeof v === 'string' ? v : '')
600
+ const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
601
+ return {
602
+ bootId: str(r.bootId),
603
+ startedAt: str(r.startedAt),
604
+ now: str(r.now),
605
+ activeJob: normalizeJob(r.activeJob),
606
+ job: normalizeJob(r.job),
607
+ phaseAgeMs: Math.max(0, num(r.phaseAgeMs)),
608
+ stalled: r.stalled === true,
609
+ setVersion: typeof r.setVersion === 'string' && r.setVersion.length > 0 ? r.setVersion : null,
610
+ actionableCount: num(r.actionableCount),
611
+ }
612
+ }
613
+
614
+ /**
615
+ * GET the re-attach snapshot, optionally naming the job being watched.
616
+ *
617
+ * Used instead of `/status` whenever the panel is following a job it started:
618
+ * it answers "what happened to MY job" across a DSH restart, and each read lets
619
+ * hq-edge settle a job that its own process could not finish.
620
+ */
621
+ export function fetchActivity(jobId?: string): Promise<UpdateActivity> {
622
+ const query = jobId ? `?job=${encodeURIComponent(jobId)}` : ''
623
+ return request<unknown>(`/activity${query}`).then(normalizeActivity)
624
+ }
@@ -185,6 +185,7 @@ const zh = {
185
185
  ctaNoSet: '暂无可安装的更新',
186
186
  footerCount: (n: number) => `准备安装 ${n} 项兼容插件`,
187
187
  footerBusy: '后台处理中,请勿关闭 DSH',
188
+ footerConfirming: '正在确认安装结果…',
188
189
  footerFailed: '无法确认插件版本兼容性',
189
190
  footerAllGood: '所有插件与运行时均正常',
190
191
  footerRuntimeOnly: '运行时更新需在上方单独确认',
@@ -218,6 +219,8 @@ const zh = {
218
219
  jobRuntimeTitle: (v: string) => `正在升级 HQ Edge 运行时 (v${v})`,
219
220
  jobSteps: ['下载', '校验', '替换', '重启'],
220
221
  activeCloseNote: '关闭此面板不会取消更新,完成后将自动重启。',
222
+ stalledNote: '进度已停止回执,正在向 HQ Edge 确认安装结果…',
223
+ stalledAction: '立即确认',
221
224
  // Runtime result alert
222
225
  rollbackTitle: '上次 HQ Edge 运行时更新已自动回滚',
223
226
  failedTitle: '上次 HQ Edge 运行时更新失败',
@@ -401,6 +404,7 @@ const en: UpdateCopy = {
401
404
  ctaNoSet: 'Nothing to install yet',
402
405
  footerCount: (n: number) => `Ready to install ${n} compatible plugin${n === 1 ? '' : 's'}`,
403
406
  footerBusy: 'Running in the background — keep DSH open',
407
+ footerConfirming: 'Confirming the install result…',
404
408
  footerFailed: 'Cannot confirm plugin compatibility',
405
409
  footerAllGood: 'All plugins and the runtime are current',
406
410
  footerRuntimeOnly: 'Confirm the runtime upgrade on its card',
@@ -431,6 +435,8 @@ const en: UpdateCopy = {
431
435
  jobRuntimeTitle: (v: string) => `Upgrading HQ Edge runtime (v${v})`,
432
436
  jobSteps: ['Download', 'Verify', 'Swap', 'Restart'],
433
437
  activeCloseNote: 'Closing this panel does not cancel the update; DSH restarts when it finishes.',
438
+ stalledNote: 'Progress has stopped reporting — confirming the result with HQ Edge…',
439
+ stalledAction: 'Check now',
434
440
  rollbackTitle: 'Last HQ Edge runtime update rolled back automatically',
435
441
  failedTitle: 'Last HQ Edge runtime update failed',
436
442
  rollbackBody: (oldVer: string, code: string) =>
@@ -30,10 +30,12 @@ import {
30
30
  } from '@huaqiu/dsh-sidebar-entry'
31
31
  import {
32
32
  checkUpdates,
33
+ fetchActivity,
33
34
  fetchJobStatus,
34
35
  fetchReport,
35
36
  TERMINAL_PHASES,
36
37
  type PluginUpdateReport,
38
+ type UpdateActivity,
37
39
  type UpdateJob,
38
40
  type UpdateJobStatus,
39
41
  } from './api.js'
@@ -1227,6 +1229,45 @@ const VIEW_CSS_TEXT = `
1227
1229
  line-height: 14px;
1228
1230
  color: var(--hq-faint);
1229
1231
  }
1232
+ /* A stalled job: the card keeps its shape but stops claiming progress. The
1233
+ * pulse stops, the bar dims, and the note line carries the retry. */
1234
+ .hqpu-active[data-stalled='1'] .hqpu-pulse-ring {
1235
+ animation: none;
1236
+ opacity: 0;
1237
+ }
1238
+ .hqpu-active[data-stalled='1'] .hqpu-pulse-dot::before {
1239
+ background: var(--hq-amber);
1240
+ }
1241
+ .hqpu-active[data-stalled='1'] .hqpu-active-percent {
1242
+ color: var(--hq-amber-ink);
1243
+ }
1244
+ .hqpu-active[data-stalled='1'] .hqpu-progress-fill {
1245
+ background: color-mix(in srgb, var(--hq-amber) 55%, var(--hq-border));
1246
+ transition: none;
1247
+ }
1248
+ /* The active step stops blinking too: nothing is advancing. */
1249
+ .hqpu-active[data-stalled='1'] .hqpu-job-step--active {
1250
+ color: var(--hq-amber-ink);
1251
+ animation: none;
1252
+ }
1253
+ .hqpu-active-stalled {
1254
+ display: flex;
1255
+ align-items: baseline;
1256
+ justify-content: space-between;
1257
+ gap: 8px;
1258
+ padding: 6px 8px;
1259
+ border-radius: 6px;
1260
+ background: color-mix(in srgb, var(--hq-amber) 10%, transparent);
1261
+ font-size: var(--hq-fs-micro);
1262
+ line-height: 14px;
1263
+ color: var(--hq-amber-ink);
1264
+ }
1265
+ .hqpu-active-stalled .hqpu-link {
1266
+ flex: none;
1267
+ font-size: var(--hq-fs-micro);
1268
+ color: var(--hq-amber-ink);
1269
+ text-decoration: underline;
1270
+ }
1230
1271
  .hqpu-job-steps4 {
1231
1272
  display: grid;
1232
1273
  grid-template-columns: repeat(4, 1fr);
@@ -1849,8 +1890,14 @@ export interface ClientContext {
1849
1890
  effect?(fn: () => (() => void) | void, label?: string): void
1850
1891
  }
1851
1892
 
1852
- /** In-memory update-report store shared by the badge and the modal. */
1853
- class UpdateStore implements UpdateStoreLike {
1893
+ /**
1894
+ * In-memory update-report store shared by the badge and the modal.
1895
+ *
1896
+ * Exported for tests: the re-attach logic (watch a job by id, notice that the
1897
+ * server was replaced, stop claiming progress) is the part that cannot be
1898
+ * exercised through the view alone.
1899
+ */
1900
+ export class UpdateStore implements UpdateStoreLike {
1854
1901
  private current: PluginUpdateReport | null = null
1855
1902
  private installingFlag = false
1856
1903
  /** The durable job the server currently reports as active (resumed across
@@ -1862,6 +1909,12 @@ class UpdateStore implements UpdateStoreLike {
1862
1909
  private pollTimer: number | null = null
1863
1910
  private pollBackoffMs = 1_000
1864
1911
  private pollInFlight = false
1912
+ /** The job this panel started (or adopted) and is following by id. */
1913
+ private watchedJobId: string | null = null
1914
+ /** hq-edge process identity from the last `/activity` read. */
1915
+ private activityBootId: string | null = null
1916
+ private stalledFlag = false
1917
+ private phaseAgeMsValue = 0
1865
1918
 
1866
1919
  report(): PluginUpdateReport | null {
1867
1920
  return this.current
@@ -1879,6 +1932,27 @@ class UpdateStore implements UpdateStoreLike {
1879
1932
  return [...this.recent]
1880
1933
  }
1881
1934
 
1935
+ /** True when the server says the active job stopped advancing. */
1936
+ stalled(): boolean {
1937
+ return this.stalledFlag
1938
+ }
1939
+
1940
+ /** Milliseconds since the active job last advanced (server clock). */
1941
+ phaseAgeMs(): number {
1942
+ return this.phaseAgeMsValue
1943
+ }
1944
+
1945
+ /**
1946
+ * Follow a job by id. The panel calls this with the `jobId` an accept
1947
+ * response handed back, so every later read asks about THAT job — the only
1948
+ * way to learn its outcome when the install replaced this very plugin (the
1949
+ * page comes back to a server that has no "active job" left to report).
1950
+ */
1951
+ watchJob(id: string | null): void {
1952
+ this.watchedJobId = id
1953
+ if (id !== null) this.schedulePoll()
1954
+ }
1955
+
1882
1956
  subscribe(listener: () => void): () => void {
1883
1957
  this.listeners.add(listener)
1884
1958
  return () => {
@@ -1912,37 +1986,98 @@ class UpdateStore implements UpdateStoreLike {
1912
1986
  const wasActive = this.active !== null
1913
1987
  this.active = status.activeJob
1914
1988
  this.recent = status.recentJobs
1915
- if (wasActive && this.active === null) {
1916
- // The active job settled (moved to recent/terminal) — the installing
1917
- // footer state must clear on its own, without the panel's closure.
1989
+ if (this.active !== null) this.watchedJobId = this.active.id
1990
+ if (wasActive && this.active === null) this.observeSettled()
1991
+ this.emit()
1992
+ this.schedulePoll()
1993
+ }
1994
+
1995
+ /**
1996
+ * Adopt a `/activity` snapshot.
1997
+ *
1998
+ * Two things only this read can tell us:
1999
+ *
2000
+ * - `bootId` changed → hq-edge was recreated underneath the page. That is
2001
+ * what an install of this plugin's own package looks like from here: the
2002
+ * DSH process serving this route was replaced. Nothing in memory can be
2003
+ * trusted, so the installing flag is cleared and the report re-read.
2004
+ * - `stalled` → the active job has stopped advancing. The progress bar must
2005
+ * stop pretending; the panel shows a "confirming" note with a retry.
2006
+ */
2007
+ private applyActivity(activity: UpdateActivity): void {
2008
+ const rebooted = this.activityBootId !== null && this.activityBootId !== activity.bootId
2009
+ this.activityBootId = activity.bootId
2010
+ this.stalledFlag = activity.stalled
2011
+ this.phaseAgeMsValue = activity.phaseAgeMs
2012
+
2013
+ const wasActive = this.active !== null
2014
+ this.active = activity.activeJob
2015
+ if (activity.job !== null) this.upsertRecent(activity.job)
2016
+
2017
+ if (rebooted) {
1918
2018
  this.installingFlag = false
1919
- // …and the report is now stale: it still names the versions this job
1920
- // just installed. hq-edge refreshes its own copy before it settles the
1921
- // job, so one cheap read of `/report` is enough to bring the list (and
1922
- // the badge) down to zero. Without it the badge kept counting updates
1923
- // that no longer exist until the 60 s idle poll came round.
2019
+ this.stalledFlag = false
2020
+ this.phaseAgeMsValue = 0
1924
2021
  void this.refresh()
1925
2022
  }
2023
+ if (wasActive && this.active === null) this.observeSettled()
1926
2024
  this.emit()
1927
2025
  this.schedulePoll()
1928
2026
  }
1929
2027
 
1930
2028
  /**
1931
- * Fetch the durable job status. Called on mount, on panel open (BEFORE
2029
+ * The job we were following reached a terminal phase (or vanished from the
2030
+ * active slot). Clear the transient flags and re-read the report — it still
2031
+ * names the versions that job installed.
2032
+ */
2033
+ private observeSettled(): void {
2034
+ this.installingFlag = false
2035
+ this.stalledFlag = false
2036
+ this.phaseAgeMsValue = 0
2037
+ this.watchedJobId = null
2038
+ void this.refresh()
2039
+ }
2040
+
2041
+ /** Keep one job fresh in the local history without duplicating it. */
2042
+ private upsertRecent(job: UpdateJob): void {
2043
+ const rest = this.recent.filter((entry) => entry.id !== job.id)
2044
+ this.recent = [job, ...rest].slice(0, 20)
2045
+ }
2046
+
2047
+ /**
2048
+ * Fetch the durable job state. Called on mount, on panel open (BEFORE
1932
2049
  * discovery) and by the poll loop. A failure (older hq-edge with no
1933
2050
  * `/status`, or a transient HTTP error) is swallowed: the last known job is
1934
2051
  * kept and the poller backs off and retries.
1935
2052
  */
1936
2053
  async fetchStatus(): Promise<void> {
1937
2054
  try {
1938
- const status = await fetchJobStatus()
2055
+ await this.pollOnce()
1939
2056
  this.pollBackoffMs = 1_000
1940
- this.applyStatus(status)
1941
2057
  } catch {
1942
2058
  this.schedulePoll()
1943
2059
  }
1944
2060
  }
1945
2061
 
2062
+ /**
2063
+ * One read. While a job is being followed, ask `/activity` about THAT job
2064
+ * (survives the restart an update of this plugin causes); otherwise fall back
2065
+ * to the plain `/status` list. An older hq-edge without `/activity` 404s and
2066
+ * the status read still answers.
2067
+ */
2068
+ private async pollOnce(): Promise<void> {
2069
+ const watched = this.watchedJobId ?? this.active?.id ?? null
2070
+ if (watched !== null) {
2071
+ try {
2072
+ this.applyActivity(await fetchActivity(watched))
2073
+ return
2074
+ } catch {
2075
+ /* fall through to /status: an older hq-edge has no /activity */
2076
+ }
2077
+ }
2078
+ this.applyStatus(await fetchJobStatus())
2079
+ }
2080
+
1946
2081
  /** ~1 s poll while a job is active; stop on terminal; back off on error
1947
2082
  * without discarding the last known job; never overlap requests. */
1948
2083
  private schedulePoll(): void {
@@ -1967,9 +2102,8 @@ class UpdateStore implements UpdateStoreLike {
1967
2102
  }
1968
2103
  this.pollInFlight = true
1969
2104
  try {
1970
- const status = await fetchJobStatus()
2105
+ await this.pollOnce()
1971
2106
  this.pollBackoffMs = 1_000
1972
- this.applyStatus(status)
1973
2107
  } catch {
1974
2108
  this.pollBackoffMs = Math.min(5_000, Math.max(1_000, this.pollBackoffMs * 2))
1975
2109
  this.schedulePoll()
@@ -81,6 +81,16 @@ export interface UpdateStoreLike {
81
81
  check(): Promise<PluginUpdateReport | null>
82
82
  /** Re-read `/status` (resume an active job immediately after submitting). */
83
83
  fetchStatus(): Promise<void>
84
+ /**
85
+ * Follow a job by id after an accept response. Optional so a minimal store
86
+ * (tests, older hosts) still satisfies the contract; the real store uses it
87
+ * to keep asking about THIS job across a DSH restart.
88
+ */
89
+ watchJob?(id: string | null): void
90
+ /** Server-reported "the active job stopped advancing". */
91
+ stalled?(): boolean
92
+ /** Milliseconds since the active job last advanced (server clock). */
93
+ phaseAgeMs?(): number
84
94
  subscribe(listener: () => void): () => void
85
95
  }
86
96
 
@@ -438,11 +448,23 @@ function checkBanner(
438
448
  * The active job card: title (with the target version) + percent, a
439
449
  * determinate bar when the server publishes one, phase + telemetry, the honest
440
450
  * "closing does not cancel" note, and the 4-step pipeline.
451
+ *
452
+ * `stalled` marks a job the server has stopped advancing. That is the normal
453
+ * shape of "the set being installed contains this very plugin": activating it
454
+ * restarts DSH, so the process that owned the job is gone and the last thing
455
+ * the page saw was 100 % on the final step. Instead of animating a bar that
456
+ * will never move, the card says it is confirming the outcome and offers a
457
+ * retry — and the next `/activity` read settles it from the installed set.
441
458
  */
442
- function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
459
+ function activeJobView(
460
+ job: UpdateJob,
461
+ copy: UpdateCopy,
462
+ opts: { stalled?: boolean; onRecheck?: () => void } = {},
463
+ ): HTMLElement {
443
464
  const section = el('section', 'hqpu-group')
444
465
  section.dataset.group = 'active-job'
445
466
  const card = el('div', 'hqpu-active')
467
+ if (opts.stalled === true) card.dataset.stalled = '1'
446
468
 
447
469
  const top = el('div', 'hqpu-active-top')
448
470
  const titleWrap = el('div', 'hqpu-active-title')
@@ -508,6 +530,15 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
508
530
  ),
509
531
  )
510
532
 
533
+ if (opts.stalled === true) {
534
+ const stalled = el('div', 'hqpu-active-stalled')
535
+ stalled.append(el('span', '', copy.ui.stalledNote))
536
+ const recheck = linkButton('hqpu-link', copy.ui.stalledAction, () => opts.onRecheck?.())
537
+ recheck.setAttribute('data-stalled-recheck', '')
538
+ stalled.append(recheck)
539
+ card.append(stalled)
540
+ }
541
+
511
542
  const current = stepIndex(job.phase)
512
543
  const stepsRow = el('div', 'hqpu-job-steps4')
513
544
  copy.ui.jobSteps.forEach((label: string, index: number) => {
@@ -1266,6 +1297,9 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1266
1297
  // relaunch. Latch it, so a store refresh cannot re-offer the button for
1267
1298
  // a server that is already gone.
1268
1299
  runtimeAccepted = true
1300
+ // The runtime swap restarts HQ Edge itself; follow the job it accepted so
1301
+ // the panel can report the outcome after the runtime is back.
1302
+ store.watchJob?.(result.jobId ?? null)
1269
1303
  showToast(copy.runtime.updateAccepted, 'success')
1270
1304
  void store.fetchStatus()
1271
1305
  } catch (err: unknown) {
@@ -1443,7 +1477,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1443
1477
  jobSection.textContent = ''
1444
1478
  const active = store.activeJob()
1445
1479
  if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
1446
- jobSection.append(activeJobView(active, copy))
1480
+ jobSection.append(
1481
+ activeJobView(active, copy, {
1482
+ stalled: store.stalled?.() === true,
1483
+ onRecheck: () => {
1484
+ void store.fetchStatus()
1485
+ },
1486
+ }),
1487
+ )
1447
1488
  }
1448
1489
  }
1449
1490
 
@@ -1634,7 +1675,11 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1634
1675
  if (busy) {
1635
1676
  ctaLabel.textContent = copy.ui.ctaInstalling
1636
1677
  cta.disabled = true
1637
- footCount.textContent = copy.ui.footerBusy
1678
+ // A stalled job is still "busy" — the install may have completed and only
1679
+ // the report is missing — so the footer must not promise progress it
1680
+ // cannot see. The card above carries the retry.
1681
+ footCount.textContent =
1682
+ store.stalled?.() === true ? copy.ui.footerConfirming : copy.ui.footerBusy
1638
1683
  } else if (report === null) {
1639
1684
  ctaLabel.textContent = copy.ui.ctaNoSet
1640
1685
  cta.disabled = true
@@ -1880,8 +1925,11 @@ async function runInstall(
1880
1925
  showToast(`${copy.installError}: ${result.error}`, 'error')
1881
1926
  return
1882
1927
  }
1883
- // 202 accepted: a durable job now owns the lifecycle. The store's /status
1884
- // poller renders progress and clears the flag when the job settles.
1928
+ // 202 accepted: a durable job now owns the lifecycle. Follow it BY ID —
1929
+ // that is what survives the restart when this very plugin is in the set
1930
+ // being installed (the server then has no "active job" to hand back, only
1931
+ // the record of the id we asked about).
1932
+ store.watchJob?.(result.jobId ?? null)
1885
1933
  showToast(copy.installSuccess, 'success')
1886
1934
  void store.fetchStatus()
1887
1935
  } catch (err: unknown) {
package/src/routes.ts CHANGED
@@ -36,12 +36,21 @@
36
36
  * GET /plugin-update/status → GET /api/v1/plugin-updates/status
37
37
  * GET /plugin-update/status/<id>
38
38
  * → GET /api/v1/plugin-updates/status/<id>
39
+ * GET /plugin-update/activity?job=<id>
40
+ * → GET /api/v1/plugin-updates/activity?job=<id>
39
41
  *
40
42
  * The status routes expose the durable update-job lifecycle (active job +
41
43
  * recent jobs). They are read-only passthroughs: an unknown job id upstream
42
44
  * answers `404 { error: 'unknown job' }`, which this forwarder passes through
43
45
  * verbatim rather than turning into a plugin-side 500.
44
46
  *
47
+ * `/activity` is the re-attach read: an install that includes this very plugin
48
+ * restarts DSH, so the browser loses this route for a moment and cannot trust
49
+ * its in-memory job. The answer carries the hq-edge process identity, the named
50
+ * job (settled or not), a stall flag and the installed set version, and each
51
+ * read also settles a job orphaned by that restart. Its `?job=` is the one
52
+ * query this forwarder passes upstream.
53
+ *
45
54
  * The runtime-update request is user-confirmed in this plugin's review panel:
46
55
  * hq-edge validates the version against a fresh manifest, downloads and
47
56
  * verifies the bundle, stages it, then hands it to the EDA host and answers
@@ -93,6 +102,13 @@ function parsePath(req: IncomingMessage): string | null {
93
102
  return pathname.slice(UPDATE_ROUTE_PREFIX.length)
94
103
  }
95
104
 
105
+ /** The raw `?…` of the incoming request ('' when there is none). */
106
+ function parseQuery(req: IncomingMessage): string {
107
+ const url = req.url ?? ''
108
+ const q = url.indexOf('?')
109
+ return q >= 0 ? url.slice(q) : ''
110
+ }
111
+
96
112
  /** Read the request body (bounded) as UTF-8 text. */
97
113
  function readBody(req: IncomingMessage): Promise<string> {
98
114
  return new Promise((resolve, reject) => {
@@ -141,7 +157,7 @@ function matchJobStatusPath(sub: string): string | null {
141
157
  function routeFor(
142
158
  sub: string,
143
159
  method: string,
144
- ): { method: string; path: string; timeoutMs?: number } | null {
160
+ ): { method: string; path: string; timeoutMs?: number; passQuery?: boolean } | null {
145
161
  if (sub === '/runtime-source') {
146
162
  if (method === 'GET') return { method: 'GET', path: '/api/v1/runtime-update/source' }
147
163
  if (method === 'POST') return { method: 'POST', path: '/api/v1/runtime-update/source' }
@@ -160,6 +176,15 @@ function routeFor(
160
176
  ? { method: 'GET', path: '/api/v1/plugin-updates/status' }
161
177
  : null
162
178
  }
179
+ // Re-attach snapshot: process identity + the named job + stall detection.
180
+ // It is the read the panel uses while updating its OWN package (that install
181
+ // restarts DSH, so the page loses this forwarder for a moment and must be
182
+ // able to rebuild its state afterwards).
183
+ if (sub === '/activity') {
184
+ return method === 'GET'
185
+ ? { method: 'GET', path: '/api/v1/plugin-updates/activity', passQuery: true }
186
+ : null
187
+ }
163
188
  const jobId = matchJobStatusPath(sub)
164
189
  if (jobId !== null) {
165
190
  return method === 'GET'
@@ -224,6 +249,7 @@ const STATIC_SUB_PATHS = new Set([
224
249
  '/runtime-source',
225
250
  '/plugin-source',
226
251
  '/status',
252
+ '/activity',
227
253
  ])
228
254
 
229
255
  /**
@@ -280,8 +306,12 @@ export function createUpdateForwarder(
280
306
  const budget = target.timeoutMs ?? timeoutMs
281
307
  const controller = new AbortController()
282
308
  const timer = setTimeout(() => controller.abort(), budget)
309
+ // Only `/activity` carries a query today (`?job=`). Every other route maps
310
+ // to a fixed upstream path, so forwarding the query blindly would leak
311
+ // unrelated client params into hq-edge.
312
+ const upstreamUrl = `${base}${target.path}${target.passQuery === true ? parseQuery(req) : ''}`
283
313
  try {
284
- const upstream = await fetch(`${base}${target.path}`, {
314
+ const upstream = await fetch(upstreamUrl, {
285
315
  method: target.method,
286
316
  headers: {
287
317
  'content-type': 'application/json; charset=utf-8',