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

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
@@ -65,6 +65,18 @@ function readBody(req) {
65
65
  * forwarder answers 405 rather than forwarding a GET to a POST-only upstream
66
66
  * and surfacing hq-edge's 404 as a plugin bug.
67
67
  */
68
+ /**
69
+ * Match a dynamic `/status/<jobId>` sub-path. Returns the job id for a single
70
+ * non-empty segment (no nested slashes), else null. The id is opaque — this
71
+ * route forwards it verbatim; hq-edge owns validation and the 404 for unknown
72
+ * ids.
73
+ */
74
+ function matchJobStatusPath(sub) {
75
+ if (!sub.startsWith("/status/")) return null;
76
+ const id = sub.slice(8);
77
+ if (id.length === 0 || id.includes("/") || id.includes("?")) return null;
78
+ return id;
79
+ }
68
80
  function routeFor(sub, method) {
69
81
  if (sub === "/runtime-source") {
70
82
  if (method === "GET") return {
@@ -92,6 +104,15 @@ function routeFor(sub, method) {
92
104
  method: "GET",
93
105
  path: "/api/v1/plugin-updates"
94
106
  } : null;
107
+ if (sub === "/status") return method === "GET" ? {
108
+ method: "GET",
109
+ path: "/api/v1/plugin-updates/status"
110
+ } : null;
111
+ const jobId = matchJobStatusPath(sub);
112
+ if (jobId !== null) return method === "GET" ? {
113
+ method: "GET",
114
+ path: `/api/v1/plugin-updates/status/${encodeURIComponent(jobId)}`
115
+ } : null;
95
116
  if (sub === "/runtime") return method === "GET" ? {
96
117
  method: "GET",
97
118
  path: "/api/v1/runtime-update"
@@ -119,8 +140,9 @@ function routeFor(sub, method) {
119
140
  } : null;
120
141
  return null;
121
142
  }
122
- /** Every sub-path this forwarder serves — used to tell a 404 from a 405. */
123
- const ROUTE_SUB_PATHS = /* @__PURE__ */ new Set([
143
+ /** Every static sub-path this forwarder serves — used to tell a 404 from a
144
+ * 405. The dynamic `/status/<id>` path is matched separately. */
145
+ const STATIC_SUB_PATHS = /* @__PURE__ */ new Set([
124
146
  "",
125
147
  "/report",
126
148
  "/check",
@@ -130,8 +152,17 @@ const ROUTE_SUB_PATHS = /* @__PURE__ */ new Set([
130
152
  "/runtime",
131
153
  "/runtime-request",
132
154
  "/runtime-source",
133
- "/plugin-source"
155
+ "/plugin-source",
156
+ "/status"
134
157
  ]);
158
+ /**
159
+ * Whether a sub-path names a route this forwarder recognizes at all. A
160
+ * recognized sub-path hit with an unsupported method is a 405; an unrecognized
161
+ * one is a 404. `/status/<id>` is recognized for any single-segment id.
162
+ */
163
+ function isKnownSubPath(sub) {
164
+ return STATIC_SUB_PATHS.has(sub) || matchJobStatusPath(sub) !== null;
165
+ }
135
166
  /** Create the prefix-route handler forwarding to hq-edge. */
136
167
  function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIMEOUT_MS) {
137
168
  return async (req, res) => {
@@ -142,7 +173,8 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
142
173
  }
143
174
  const target = routeFor(sub, req.method === "HEAD" ? "GET" : req.method ?? "");
144
175
  if (target === null) {
145
- sendJson(res, sub === "" || ROUTE_SUB_PATHS.has(sub) ? 405 : 404, { error: sub === "" || ROUTE_SUB_PATHS.has(sub) ? `method ${req.method} not allowed on ${sub || "/"}` : "not found" });
176
+ const known = isKnownSubPath(sub);
177
+ sendJson(res, known ? 405 : 404, { error: known ? `method ${req.method} not allowed on ${sub || "/"}` : "not found" });
146
178
  return;
147
179
  }
148
180
  if (req.method !== "GET" && req.method !== "POST" && req.method !== "HEAD") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-tool-plugin-update",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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.6",
35
- "@huaqiu/dsh-sidebar-entry": "0.6.6"
34
+ "@huaqiu/dsh-plugin-log": "0.6.8",
35
+ "@huaqiu/dsh-sidebar-entry": "0.6.8"
36
36
  },
37
37
  "files": [
38
38
  "lib",
package/src/client/api.ts CHANGED
@@ -29,6 +29,15 @@ export interface PluginUpdateItem {
29
29
  ignored?: boolean
30
30
  reasons?: string[]
31
31
  notes?: string[]
32
+ /**
33
+ * User-facing release metadata (schemaVersion-2 manifests). ALL optional and
34
+ * absent for v1 manifests — the view falls back to the package short name
35
+ * and version line when they are missing.
36
+ */
37
+ displayName?: string
38
+ description?: string
39
+ sizeBytes?: number
40
+ changes?: string[]
32
41
  }
33
42
 
34
43
  /**
@@ -49,6 +58,33 @@ export interface RuntimeUpdateEntry {
49
58
  arch?: string
50
59
  /** Exact absolute HTTPS URL of the release archive. */
51
60
  url: string
61
+ /**
62
+ * Optional user-facing metadata (schemaVersion-2 runtime manifest). All
63
+ * absent for v1 manifests; the panel degrades gracefully without them.
64
+ */
65
+ publishedAt?: string
66
+ sizeBytes?: number
67
+ summary?: string
68
+ notesUrl?: string
69
+ notes?: string[]
70
+ breaking?: string[]
71
+ channel?: string
72
+ }
73
+
74
+ /**
75
+ * The EDA host's own report of the last runtime replacement, read from
76
+ * `runtime-update-result.json` by hq-edge. This is the authoritative outcome
77
+ * after HQ Edge exits and the host swaps the runtime — it must be rendered,
78
+ * not discarded. `state` is `succeeded`, `rolled_back` (swap failed but the
79
+ * previous runtime was restored) or `failed` (no confirmed rollback).
80
+ */
81
+ export interface RuntimeLastResult {
82
+ state: 'succeeded' | 'rolled_back' | 'failed'
83
+ targetVersion?: string | null
84
+ installedVersion?: string | null
85
+ at?: string
86
+ errorCode?: string
87
+ message?: string
52
88
  }
53
89
 
54
90
  export interface RuntimeUpdateInfo {
@@ -59,6 +95,8 @@ export interface RuntimeUpdateInfo {
59
95
  updateAvailable: boolean
60
96
  checkedAt?: string | null
61
97
  lastError?: string | null
98
+ /** The host-reported outcome of the last runtime swap; null when none. */
99
+ lastResult?: RuntimeLastResult | null
62
100
  /**
63
101
  * Where the runtime-update manifest is read from. Optional: an older hq-edge
64
102
  * does not send it, and the panel must degrade to a read-only source row.
@@ -110,6 +148,21 @@ export function runtimeLatestVersion(
110
148
  return null
111
149
  }
112
150
 
151
+ /**
152
+ * Set-level release metadata from a schemaVersion-2 plugin manifest. ALL
153
+ * fields optional: v1 manifests send none, and the panel must fall back to
154
+ * showing just the version then.
155
+ */
156
+ export interface ReleaseInfo {
157
+ publishedAt?: string
158
+ channel?: string
159
+ requiresRestart?: boolean
160
+ summary?: string
161
+ notesUrl?: string
162
+ notes?: string[]
163
+ breaking?: string[]
164
+ }
165
+
113
166
  export interface PluginUpdateReport {
114
167
  /** The evaluated release set; null while no manifest has been read. */
115
168
  manifestVersion: string | null
@@ -118,7 +171,13 @@ export interface PluginUpdateReport {
118
171
  actionableCount: number
119
172
  ignoredSet: boolean
120
173
  fetchedAt?: string
174
+ /** When the last plugin discovery ran (ISO); drives the "上次检查" row. */
175
+ checkedAt?: string | null
176
+ /** Non-empty when the last plugin discovery FAILED — the panel must show
177
+ * an error row with a Retry, never the silent "no updates" empty state. */
121
178
  lastError?: string | null
179
+ /** Set-level release metadata; absent for v1 manifests. */
180
+ release?: ReleaseInfo | null
122
181
  runtime: RuntimeUpdateInfo
123
182
  /**
124
183
  * Where the plugin release manifest is read from. Optional: an older
@@ -127,6 +186,198 @@ export interface PluginUpdateReport {
127
186
  pluginSource?: PluginUpdateSource
128
187
  }
129
188
 
189
+ // ── Durable update-job lifecycle (GET /status, GET /status/:id) ────────────
190
+
191
+ /** Lifecycle phases of one durable update job. Terminal phases end the poll. */
192
+ export type UpdatePhase =
193
+ | 'queued'
194
+ | 'resolving'
195
+ | 'downloading'
196
+ | 'verifying'
197
+ | 'staging'
198
+ | 'activating'
199
+ | 'awaiting-host'
200
+ | 'restarting'
201
+ | 'verifying-after-restart'
202
+ | 'done'
203
+ | 'failed'
204
+ | 'rolled-back'
205
+ | 'abandoned'
206
+
207
+ /** Phases after which a job never runs again — polling stops and a result view
208
+ * replaces the active-job view. */
209
+ export const TERMINAL_PHASES: ReadonlySet<UpdatePhase> = new Set<UpdatePhase>([
210
+ 'done',
211
+ 'failed',
212
+ 'rolled-back',
213
+ 'abandoned',
214
+ ])
215
+
216
+ export type UpdateKind = 'plugin-set' | 'runtime'
217
+
218
+ export interface UpdateJobProgress {
219
+ /** 0..100 when the phase has a meaningful percentage; null otherwise. */
220
+ percent: number | null
221
+ bytesDone?: number
222
+ bytesTotal?: number
223
+ bytesPerSecond?: number
224
+ etaSeconds?: number
225
+ }
226
+
227
+ export interface UpdateStep {
228
+ name: string
229
+ state: 'pending' | 'running' | 'installed' | 'skipped' | 'failed'
230
+ percent?: number
231
+ message?: string
232
+ }
233
+
234
+ export interface UpdateJobResult {
235
+ ok: boolean
236
+ installedVersion: string | null
237
+ errorCode?: string
238
+ message?: string
239
+ rolledBack: boolean
240
+ finishedAt: string
241
+ }
242
+
243
+ /**
244
+ * One durable update job, as hq-edge reports it (`GET /status` and
245
+ * `/status/:id`). The UI is a projection of this job: opening the panel
246
+ * resumes whatever the server says is active, rather than owning the
247
+ * lifecycle in a DOM closure.
248
+ */
249
+ export interface UpdateJob {
250
+ id: string
251
+ kind: UpdateKind
252
+ targetVersion: string
253
+ previousVersion: string | null
254
+ startedAt: string
255
+ updatedAt: string
256
+ phase: UpdatePhase
257
+ message?: string
258
+ progress: UpdateJobProgress
259
+ steps?: UpdateStep[]
260
+ result?: UpdateJobResult
261
+ logHint?: string
262
+ }
263
+
264
+ /** `GET /status`: the current active job plus a bounded list of recent ones. */
265
+ export interface UpdateJobStatus {
266
+ activeJob: UpdateJob | null
267
+ recentJobs: UpdateJob[]
268
+ }
269
+
270
+ const PHASE_VALUES: ReadonlySet<string> = new Set<string>([
271
+ 'queued',
272
+ 'resolving',
273
+ 'downloading',
274
+ 'verifying',
275
+ 'staging',
276
+ 'activating',
277
+ 'awaiting-host',
278
+ 'restarting',
279
+ 'verifying-after-restart',
280
+ 'done',
281
+ 'failed',
282
+ 'rolled-back',
283
+ 'abandoned',
284
+ ])
285
+
286
+ function asStringArray(value: unknown): string[] | undefined {
287
+ if (!Array.isArray(value)) return undefined
288
+ const out = value.filter((entry): entry is string => typeof entry === 'string')
289
+ return out.length > 0 ? out : undefined
290
+ }
291
+
292
+ function asFiniteNumber(value: unknown): number | undefined {
293
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
294
+ }
295
+
296
+ /** Defensively normalize one job payload from the wire into {@link UpdateJob}.
297
+ * Unknown/older servers may omit fields; a malformed `phase` falls back to
298
+ * `queued` rather than crashing the view. */
299
+ export function normalizeJob(raw: unknown): UpdateJob | null {
300
+ if (raw === null || typeof raw !== 'object') return null
301
+ const r = raw as Record<string, unknown>
302
+ if (typeof r.id !== 'string' || r.id.length === 0) return null
303
+ const phase = typeof r.phase === 'string' && PHASE_VALUES.has(r.phase)
304
+ ? (r.phase as UpdatePhase)
305
+ : 'queued'
306
+ const progressRaw = (r.progress ?? {}) as Record<string, unknown>
307
+ const percentRaw = asFiniteNumber(progressRaw.percent)
308
+ const progress: UpdateJobProgress = {
309
+ percent: percentRaw === undefined ? null : Math.min(100, Math.max(0, percentRaw)),
310
+ }
311
+ const bytesDone = asFiniteNumber(progressRaw.bytesDone)
312
+ const bytesTotal = asFiniteNumber(progressRaw.bytesTotal)
313
+ const bytesPerSecond = asFiniteNumber(progressRaw.bytesPerSecond)
314
+ const etaSeconds = asFiniteNumber(progressRaw.etaSeconds)
315
+ if (bytesDone !== undefined) progress.bytesDone = bytesDone
316
+ if (bytesTotal !== undefined) progress.bytesTotal = bytesTotal
317
+ if (bytesPerSecond !== undefined) progress.bytesPerSecond = bytesPerSecond
318
+ if (etaSeconds !== undefined) progress.etaSeconds = etaSeconds
319
+
320
+ const job: UpdateJob = {
321
+ id: r.id,
322
+ kind: r.kind === 'runtime' ? 'runtime' : 'plugin-set',
323
+ targetVersion: typeof r.targetVersion === 'string' ? r.targetVersion : '',
324
+ previousVersion:
325
+ typeof r.previousVersion === 'string' ? r.previousVersion : null,
326
+ startedAt: typeof r.startedAt === 'string' ? r.startedAt : '',
327
+ updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : '',
328
+ phase,
329
+ progress,
330
+ }
331
+ if (typeof r.message === 'string' && r.message.length > 0) job.message = r.message
332
+ if (typeof r.logHint === 'string' && r.logHint.length > 0) job.logHint = r.logHint
333
+
334
+ if (Array.isArray(r.steps)) {
335
+ const steps: UpdateStep[] = []
336
+ for (const entry of r.steps) {
337
+ if (entry === null || typeof entry !== 'object') continue
338
+ const s = entry as Record<string, unknown>
339
+ if (typeof s.name !== 'string') continue
340
+ const state =
341
+ s.state === 'running' || s.state === 'installed' || s.state === 'skipped' || s.state === 'failed'
342
+ ? s.state
343
+ : 'pending'
344
+ const step: UpdateStep = { name: s.name, state }
345
+ const pct = asFiniteNumber(s.percent)
346
+ if (pct !== undefined) step.percent = pct
347
+ if (typeof s.message === 'string' && s.message.length > 0) step.message = s.message
348
+ steps.push(step)
349
+ }
350
+ if (steps.length > 0) job.steps = steps
351
+ }
352
+
353
+ if (r.result !== null && typeof r.result === 'object') {
354
+ const res = r.result as Record<string, unknown>
355
+ const result: UpdateJobResult = {
356
+ ok: res.ok === true,
357
+ installedVersion:
358
+ typeof res.installedVersion === 'string' ? res.installedVersion : null,
359
+ rolledBack: res.rolledBack === true,
360
+ finishedAt: typeof res.finishedAt === 'string' ? res.finishedAt : '',
361
+ }
362
+ if (typeof res.errorCode === 'string' && res.errorCode.length > 0) result.errorCode = res.errorCode
363
+ if (typeof res.message === 'string' && res.message.length > 0) result.message = res.message
364
+ job.result = result
365
+ }
366
+ return job
367
+ }
368
+
369
+ /** Normalize a `GET /status` payload; an empty/absent body degrades to "no
370
+ * active job" rather than throwing. */
371
+ export function normalizeJobStatus(raw: unknown): UpdateJobStatus {
372
+ if (raw === null || typeof raw !== 'object') return { activeJob: null, recentJobs: [] }
373
+ const r = raw as Record<string, unknown>
374
+ const activeJob = normalizeJob(r.activeJob)
375
+ const recentJobs = Array.isArray(r.recentJobs)
376
+ ? r.recentJobs.map(normalizeJob).filter((j): j is UpdateJob => j !== null)
377
+ : []
378
+ return { activeJob, recentJobs }
379
+ }
380
+
130
381
  async function request<T>(path: string, init?: RequestInit): Promise<T> {
131
382
  const res = await fetch(`${UPDATE_BASE}${path}`, {
132
383
  cache: 'no-store',
@@ -153,7 +404,14 @@ export function checkUpdates(): Promise<PluginUpdateReport> {
153
404
  }
154
405
 
155
406
  export interface InstallResult {
156
- ok: boolean
407
+ /**
408
+ * New durable contract: `202 { jobId, state: 'accepted' }`. The install is
409
+ * now an async job — poll `/status/:jobId` for progress, don't read success
410
+ * off this response. Legacy boolean fields are kept for older hq-edge.
411
+ */
412
+ jobId?: string
413
+ state?: string
414
+ ok?: boolean
157
415
  installedSetVersion?: string
158
416
  installed?: number
159
417
  skipped?: number
@@ -183,6 +441,8 @@ export function ignoreOffer(setVersion: string): Promise<unknown> {
183
441
  * updating → `busy`), 422 (preflight/manifest/download refused) or 503.
184
442
  */
185
443
  export interface RuntimeUpdateRequestResult {
444
+ /** Durable job id — poll `/status/:jobId` for the accepted job's progress. */
445
+ jobId?: string
186
446
  requestId?: string
187
447
  targetVersion?: string
188
448
  state?: string
@@ -258,3 +518,23 @@ export function setPluginUpdateSource(url: string): Promise<PluginUpdateSource>
258
518
  body: JSON.stringify({ url }),
259
519
  })
260
520
  }
521
+
522
+ /**
523
+ * GET the durable job status: the currently active job (if any) plus a bounded
524
+ * list of recent jobs. This is what the panel reads BEFORE running discovery
525
+ * on open, so an in-flight install/runtime update is resumed instead of
526
+ * forgotten. An older hq-edge without the status route 404s — callers catch
527
+ * that and degrade to "no active job".
528
+ */
529
+ export function fetchJobStatus(): Promise<UpdateJobStatus> {
530
+ return request<unknown>('/status').then(normalizeJobStatus)
531
+ }
532
+
533
+ /** GET one job by id; rejects (upstream 404 `unknown job`) for an unknown id. */
534
+ export function fetchJob(id: string): Promise<UpdateJob> {
535
+ return request<unknown>(`/status/${encodeURIComponent(id)}`).then((raw) => {
536
+ const job = normalizeJob(raw)
537
+ if (job === null) throw new Error('malformed job payload')
538
+ return job
539
+ })
540
+ }