@linxin666/dsh-remote-web-ui 0.1.14 → 0.1.16
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/README.i18n.yaml +2 -2
- package/README.md +9 -5
- package/README.zh.md +1 -1
- package/lib/client.js +84 -23
- package/lib/client.js.map +1 -1
- package/lib/index.js +173 -18
- package/lib/mobile.js +44 -25
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +30 -8
- package/lib/types/client/UpdatePanel.js +2 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +97 -20
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +36 -10
- package/lib/types/mobile/mux.d.ts +13 -4
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +50 -30
- package/lib/types/mobile/mux.test.js +75 -6
- package/lib/types/mobile-api.d.ts +2 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +4 -1
- package/lib/types/routes.d.ts +17 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +49 -10
- package/lib/types/update.d.ts +44 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +118 -8
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/UpdatePanel.tsx +2 -0
- package/src/client/locales.ts +4 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/index.ts +34 -10
- package/src/mobile/mux.test.ts +84 -6
- package/src/mobile/mux.ts +46 -25
- package/src/mobile-api.ts +6 -1
- package/src/routes.ts +51 -10
- package/src/update.ts +128 -9
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
1
2
|
/**
|
|
2
3
|
* Staged form model behind the plugin settings card. A card stages what the
|
|
3
4
|
* user types and writes it only when they save — the settings write is a
|
|
4
5
|
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
5
|
-
* screen exactly what a save would store.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* screen exactly what a save would store. Family-shared slice inlined into
|
|
7
|
+
* each plugin's client bundle; mirrors the official ui-plugin-config
|
|
8
|
+
* card-store pattern.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
@@ -19,6 +20,14 @@ export type FieldWrite =
|
|
|
19
20
|
export interface FieldSpec {
|
|
20
21
|
/** Field name inside the namespace section. */
|
|
21
22
|
field: string
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
|
+
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
+
* never compared against the draft on save; the field lands when the scope
|
|
27
|
+
* reports the write succeeded (its secret-set marker under the bridge), so
|
|
28
|
+
* a successful secret save is not misreported as failed.
|
|
29
|
+
*/
|
|
30
|
+
secret?: boolean
|
|
22
31
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
23
32
|
format: (value: unknown) => string
|
|
24
33
|
/**
|
|
@@ -60,6 +69,12 @@ export interface CardShell {
|
|
|
60
69
|
saving: boolean
|
|
61
70
|
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
62
71
|
failed: boolean
|
|
72
|
+
/**
|
|
73
|
+
* The rejection code/message the Host returned for the last failed save,
|
|
74
|
+
* surfaced next to the generic failure text. Undefined while no save has
|
|
75
|
+
* failed (or the failure carried no server reason).
|
|
76
|
+
*/
|
|
77
|
+
failedReason?: string
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
/** The write actions the card's slot entry injects. */
|
|
@@ -86,12 +101,63 @@ interface StagedEdit {
|
|
|
86
101
|
interface PlannedWrite {
|
|
87
102
|
/** Field this entry writes. */
|
|
88
103
|
field: string
|
|
104
|
+
/** The durable write this entry performs, described for a batched scope. */
|
|
105
|
+
op: BatchedWrite
|
|
89
106
|
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
90
107
|
run: (() => Promise<boolean>) | undefined
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
/**
|
|
94
|
-
export
|
|
110
|
+
/** One durable write a batched settings scope performs. */
|
|
111
|
+
export interface BatchedWrite {
|
|
112
|
+
/** Field this entry writes. */
|
|
113
|
+
field: string
|
|
114
|
+
/** set stores a value; unset drops the leaf. */
|
|
115
|
+
op: 'set' | 'unset'
|
|
116
|
+
/** Value for op set (absent for unset). */
|
|
117
|
+
value?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Per-field outcome of one batched scope write. */
|
|
121
|
+
export interface BatchedFieldResult {
|
|
122
|
+
/** Field this entry writes. */
|
|
123
|
+
field: string
|
|
124
|
+
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
+
landed: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
+
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
+
* batched refusal fails the whole save rather than per-field.
|
|
132
|
+
*/
|
|
133
|
+
export interface BatchResult {
|
|
134
|
+
/** Whether the whole mutate was accepted. */
|
|
135
|
+
ok: boolean
|
|
136
|
+
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
+
fields: BatchedFieldResult[]
|
|
138
|
+
/** Host rejection code (mutate refused). */
|
|
139
|
+
code?: string
|
|
140
|
+
/** Host rejection message (mutate refused). */
|
|
141
|
+
message?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
+
interface BatchedSettingsScope {
|
|
146
|
+
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
+
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
151
|
+
export interface NumberConstraints {
|
|
152
|
+
/** The accepted value must be a whole number. */
|
|
153
|
+
integer?: boolean
|
|
154
|
+
/** The accepted value must be at least this. */
|
|
155
|
+
min?: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
|
|
159
|
+
export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
|
|
160
|
+
const { integer = false, min } = constraints
|
|
95
161
|
return {
|
|
96
162
|
field,
|
|
97
163
|
format: value => typeof value === 'number' ? String(value) : '',
|
|
@@ -99,7 +165,10 @@ export function numberField(field: string): FieldSpec {
|
|
|
99
165
|
const trimmed = text.trim()
|
|
100
166
|
if (trimmed === '') return { kind: 'clear' }
|
|
101
167
|
const parsed = Number(trimmed)
|
|
102
|
-
|
|
168
|
+
if (!Number.isFinite(parsed)) return undefined
|
|
169
|
+
if (integer && !Number.isInteger(parsed)) return undefined
|
|
170
|
+
if (min !== undefined && parsed < min) return undefined
|
|
171
|
+
return { kind: 'set', value: parsed }
|
|
103
172
|
},
|
|
104
173
|
}
|
|
105
174
|
}
|
|
@@ -116,19 +185,43 @@ export function textField(field: string): FieldSpec {
|
|
|
116
185
|
}
|
|
117
186
|
}
|
|
118
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
|
+
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
+
* but a save never compares the redacted value back and relies on the scope
|
|
192
|
+
* reporting the write landed.
|
|
193
|
+
*/
|
|
194
|
+
export function secretField(field: string): FieldSpec {
|
|
195
|
+
return { ...textField(field), secret: true }
|
|
196
|
+
}
|
|
197
|
+
|
|
119
198
|
/** A boolean field, edited through true/false draft text. */
|
|
120
199
|
export function booleanField(field: string): FieldSpec {
|
|
121
200
|
return {
|
|
122
201
|
field,
|
|
123
202
|
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
124
203
|
parse: (text) => {
|
|
125
|
-
|
|
126
|
-
if (
|
|
204
|
+
const trimmed = text.trim()
|
|
205
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
206
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
207
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
127
208
|
return undefined
|
|
128
209
|
},
|
|
129
210
|
}
|
|
130
211
|
}
|
|
131
212
|
|
|
213
|
+
/** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
|
|
214
|
+
export function choiceField(field: string, choices: readonly string[]): FieldSpec {
|
|
215
|
+
return {
|
|
216
|
+
field,
|
|
217
|
+
format: value => typeof value === 'string' && choices.includes(value) ? value : '',
|
|
218
|
+
parse: (text) => {
|
|
219
|
+
if (text === '') return { kind: 'clear' }
|
|
220
|
+
return choices.includes(text) ? { kind: 'set', value: text } : undefined
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
132
225
|
/**
|
|
133
226
|
* Stages one card's edits over one settings namespace and writes them on save.
|
|
134
227
|
*
|
|
@@ -143,6 +236,7 @@ export class CardForm<T> {
|
|
|
143
236
|
private readonly listeners = new Set<() => void>()
|
|
144
237
|
private saving = false
|
|
145
238
|
private failed = false
|
|
239
|
+
private failedReason: string | undefined
|
|
146
240
|
|
|
147
241
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
148
242
|
constructor(
|
|
@@ -172,6 +266,7 @@ export class CardForm<T> {
|
|
|
172
266
|
invalid: plan.some(item => item.run === undefined),
|
|
173
267
|
saving: this.saving,
|
|
174
268
|
failed: this.failed,
|
|
269
|
+
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
175
270
|
}
|
|
176
271
|
}
|
|
177
272
|
|
|
@@ -202,6 +297,7 @@ export class CardForm<T> {
|
|
|
202
297
|
if (this.staged.size === 0 && !this.failed) return
|
|
203
298
|
this.staged.clear()
|
|
204
299
|
this.failed = false
|
|
300
|
+
this.failedReason = undefined
|
|
205
301
|
this.publish()
|
|
206
302
|
},
|
|
207
303
|
}
|
|
@@ -209,25 +305,57 @@ export class CardForm<T> {
|
|
|
209
305
|
|
|
210
306
|
/**
|
|
211
307
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
308
|
+
*
|
|
309
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
310
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
311
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
312
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
313
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
314
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
212
315
|
* @returns settlement after every write and the read-back.
|
|
213
316
|
*/
|
|
214
317
|
async save(): Promise<void> {
|
|
215
318
|
const plan = this.plan()
|
|
216
|
-
const
|
|
217
|
-
if (plan.length === 0 || this.saving ||
|
|
319
|
+
const valid = plan.filter(item => item.run !== undefined)
|
|
320
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
321
|
+
const plannedWrites = valid.map(item => item.op)
|
|
322
|
+
// Snapshot the fields this save writes, so edits staged while it is in
|
|
323
|
+
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
324
|
+
const fields = new Set(plan.map(item => item.field))
|
|
218
325
|
this.saving = true
|
|
219
326
|
this.failed = false
|
|
327
|
+
this.failedReason = undefined
|
|
220
328
|
this.publish()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
329
|
+
const landed = new Set<string>()
|
|
330
|
+
const batch = this.batchedScope()
|
|
331
|
+
if (batch !== undefined) {
|
|
332
|
+
const result = await batch.mutate(plannedWrites)
|
|
333
|
+
if (result.ok) {
|
|
334
|
+
for (const field of result.fields) {
|
|
335
|
+
if (field.landed) landed.add(field.field)
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
this.failedReason = result.message
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
for (const item of valid) {
|
|
342
|
+
if (await item.run!()) landed.add(item.field)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const field of fields) {
|
|
346
|
+
if (landed.has(field)) this.staged.delete(field)
|
|
224
347
|
}
|
|
225
|
-
if (landed) this.staged.clear()
|
|
226
348
|
this.saving = false
|
|
227
|
-
this.failed =
|
|
349
|
+
this.failed = landed.size !== fields.size
|
|
228
350
|
this.publish()
|
|
229
351
|
}
|
|
230
352
|
|
|
353
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
354
|
+
private batchedScope(): BatchedSettingsScope | undefined {
|
|
355
|
+
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
356
|
+
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
231
359
|
/**
|
|
232
360
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
233
361
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -240,14 +368,14 @@ export class CardForm<T> {
|
|
|
240
368
|
for (const [field, staged] of this.staged) {
|
|
241
369
|
const spec = this.specOf(field)
|
|
242
370
|
if (staged.clear) {
|
|
243
|
-
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
371
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
244
372
|
continue
|
|
245
373
|
}
|
|
246
374
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
247
375
|
const write = spec.parse(staged.text)
|
|
248
|
-
if (write === undefined) plan.push({ field, run: undefined })
|
|
249
|
-
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
250
|
-
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
376
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
|
|
377
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
378
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
|
|
251
379
|
}
|
|
252
380
|
return plan
|
|
253
381
|
}
|
|
@@ -259,12 +387,18 @@ export class CardForm<T> {
|
|
|
259
387
|
|
|
260
388
|
private async store(field: string, value: unknown): Promise<boolean> {
|
|
261
389
|
await this.scope.set(field, value)
|
|
390
|
+
// A redacted secret never appears in the user layer read-back; judging it
|
|
391
|
+
// by value would misreport a successful secret save as failed. The bridge
|
|
392
|
+
// reports secret writes through its secret-set markers (batch path); on
|
|
393
|
+
// the per-field path the scope resolved, so the write is landed.
|
|
394
|
+
if (this.specOf(field).secret) return true
|
|
262
395
|
return this.userLayer()?.[field] === value
|
|
263
396
|
}
|
|
264
397
|
|
|
265
398
|
private stage(field: string, edit: StagedEdit): void {
|
|
266
399
|
this.staged.set(field, edit)
|
|
267
400
|
this.failed = false
|
|
401
|
+
this.failedReason = undefined
|
|
268
402
|
this.publish()
|
|
269
403
|
}
|
|
270
404
|
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
fetchLatestVersion,
|
|
28
28
|
resolveAnchorManifest,
|
|
29
29
|
resolveUpdateTarget,
|
|
30
|
-
|
|
30
|
+
runUpdateVerified,
|
|
31
31
|
type UpdateRunResult,
|
|
32
32
|
} from './update.ts'
|
|
33
33
|
import { makeUpdateRoutes } from './update-routes.ts'
|
|
@@ -238,19 +238,26 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
238
238
|
}
|
|
239
239
|
// ── remote update ────────────────────────────────────────────────────────
|
|
240
240
|
// The dsh-web-ui self-update surface: probe the npm registry for family
|
|
241
|
-
// releases and run `pnpm update` in the owning profile. Resolutions
|
|
242
|
-
// on the host process's own module graph, so the update always
|
|
243
|
-
// profile the running web GUI was booted from. The
|
|
244
|
-
//
|
|
245
|
-
//
|
|
241
|
+
// releases and run `pnpm update --latest` in the owning profile. Resolutions
|
|
242
|
+
// anchor on the host process's own module graph, so the update always
|
|
243
|
+
// targets the profile the running web GUI was booted from. The anchor path
|
|
244
|
+
// is re-resolved per operation: pnpm removes the old version's .pnpm
|
|
245
|
+
// directory on update, so a boot-time captured path would fail to read
|
|
246
|
+
// after a successful update; versions are re-read from disk per check.
|
|
246
247
|
const requireFromHost = createRequire(import.meta.url)
|
|
247
|
-
const
|
|
248
|
+
const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(specifier => {
|
|
249
|
+
try {
|
|
250
|
+
return requireFromHost.resolve(specifier)
|
|
251
|
+
} catch {
|
|
252
|
+
return undefined
|
|
253
|
+
}
|
|
254
|
+
})
|
|
248
255
|
const updateRoutes = makeUpdateRoutes({
|
|
249
256
|
// Control endpoints are host-surface only: a LAN/phone origin must never
|
|
250
257
|
// trigger a real install on this machine.
|
|
251
258
|
fence: request => isTrustedApiRequest(request, []),
|
|
252
259
|
check: () => checkUpdates({
|
|
253
|
-
anchorManifestPath,
|
|
260
|
+
anchorManifestPath: resolveAnchorPath(),
|
|
254
261
|
resolve: specifier => {
|
|
255
262
|
try {
|
|
256
263
|
return requireFromHost.resolve(specifier)
|
|
@@ -261,7 +268,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
261
268
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
262
269
|
}),
|
|
263
270
|
run: async (): Promise<UpdateRunResult> => {
|
|
264
|
-
const target = resolveUpdateTarget({ anchorManifestPath })
|
|
271
|
+
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
|
|
265
272
|
if ('error' in target) {
|
|
266
273
|
const code = target.error
|
|
267
274
|
return {
|
|
@@ -272,7 +279,24 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
272
279
|
errorCode: code,
|
|
273
280
|
}
|
|
274
281
|
}
|
|
275
|
-
|
|
282
|
+
// Verify the versions actually moved after a green pnpm exit: the pnpm
|
|
283
|
+
// 11 minimumReleaseAge gate can silently keep the installed versions
|
|
284
|
+
// (same-day releases), which a plain exit-0 check would report as
|
|
285
|
+
// success — the user then restarts and nothing changed.
|
|
286
|
+
return runUpdateVerified({
|
|
287
|
+
run: { profileDir: target.profileDir, packages: target.packages },
|
|
288
|
+
check: {
|
|
289
|
+
anchorManifestPath: resolveAnchorPath(),
|
|
290
|
+
resolve: specifier => {
|
|
291
|
+
try {
|
|
292
|
+
return requireFromHost.resolve(specifier)
|
|
293
|
+
} catch {
|
|
294
|
+
return undefined
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
298
|
+
},
|
|
299
|
+
})
|
|
276
300
|
},
|
|
277
301
|
})
|
|
278
302
|
const routes = [
|
package/src/mobile/mux.test.ts
CHANGED
|
@@ -80,8 +80,9 @@ describe('MuxClient polling fallback', () => {
|
|
|
80
80
|
await vi.advanceTimersByTimeAsync(400)
|
|
81
81
|
expect(pollLatest).not.toHaveBeenCalled()
|
|
82
82
|
|
|
83
|
-
//
|
|
84
|
-
|
|
83
|
+
// The single tick (400 ms) crosses the 800 ms stall threshold at 1200 ms:
|
|
84
|
+
// the same tick then runs the first poll and emits seq 0.
|
|
85
|
+
await vi.advanceTimersByTimeAsync(800) // 1200ms total -> first stall crossing tick
|
|
85
86
|
expect(pollLatest).toHaveBeenCalledWith('s1')
|
|
86
87
|
expect(frames).toHaveLength(1)
|
|
87
88
|
expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
|
|
@@ -104,7 +105,7 @@ describe('MuxClient polling fallback', () => {
|
|
|
104
105
|
client.start()
|
|
105
106
|
client.observe('s1')
|
|
106
107
|
|
|
107
|
-
await vi.advanceTimersByTimeAsync(
|
|
108
|
+
await vi.advanceTimersByTimeAsync(1200) // first poll -> 3 events
|
|
108
109
|
expect(frames).toHaveLength(3)
|
|
109
110
|
|
|
110
111
|
await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
|
|
@@ -119,7 +120,7 @@ describe('MuxClient polling fallback', () => {
|
|
|
119
120
|
client.start()
|
|
120
121
|
client.observe('s1')
|
|
121
122
|
|
|
122
|
-
await vi.advanceTimersByTimeAsync(
|
|
123
|
+
await vi.advanceTimersByTimeAsync(1200)
|
|
123
124
|
expect(pollLatest).toHaveBeenCalled()
|
|
124
125
|
|
|
125
126
|
client.observe(undefined)
|
|
@@ -136,7 +137,7 @@ describe('MuxClient polling fallback', () => {
|
|
|
136
137
|
client.start()
|
|
137
138
|
client.observe('s1')
|
|
138
139
|
|
|
139
|
-
await vi.advanceTimersByTimeAsync(
|
|
140
|
+
await vi.advanceTimersByTimeAsync(1200)
|
|
140
141
|
expect(pollLatest).toHaveBeenCalled()
|
|
141
142
|
|
|
142
143
|
client.stop()
|
|
@@ -156,7 +157,7 @@ describe('MuxClient polling fallback', () => {
|
|
|
156
157
|
client.observe('s1')
|
|
157
158
|
|
|
158
159
|
// Stall into polling.
|
|
159
|
-
await vi.advanceTimersByTimeAsync(
|
|
160
|
+
await vi.advanceTimersByTimeAsync(1200)
|
|
160
161
|
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
161
162
|
|
|
162
163
|
// A live mux frame proves SSE delivers again -> fallback stops.
|
|
@@ -169,4 +170,81 @@ describe('MuxClient polling fallback', () => {
|
|
|
169
170
|
expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
|
|
170
171
|
client.stop()
|
|
171
172
|
})
|
|
173
|
+
|
|
174
|
+
it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
|
|
175
|
+
const { factory } = makeSources()
|
|
176
|
+
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
|
177
|
+
try {
|
|
178
|
+
const pages = [pageOf([0]), pageOf([0, 1])]
|
|
179
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
180
|
+
const client = new MuxClient('/m/api/events.mux', {
|
|
181
|
+
sourceFactory: factory,
|
|
182
|
+
pollLatest,
|
|
183
|
+
stallThresholdMs: 1500,
|
|
184
|
+
pollIntervalMs: 2000,
|
|
185
|
+
})
|
|
186
|
+
client.start()
|
|
187
|
+
client.observe('s1')
|
|
188
|
+
|
|
189
|
+
// Exactly one interval is ever created: the single tick scheduler.
|
|
190
|
+
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
|
191
|
+
|
|
192
|
+
// The tick fires below the stall threshold without polling.
|
|
193
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
194
|
+
expect(pollLatest).not.toHaveBeenCalled()
|
|
195
|
+
|
|
196
|
+
// The same interval arms the fallback once silence passes the threshold
|
|
197
|
+
// (tick at 2000 crosses 1500) and runs the first poll immediately.
|
|
198
|
+
await vi.advanceTimersByTimeAsync(1100) // 2100ms total
|
|
199
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
200
|
+
expect(setIntervalSpy).toHaveBeenCalledTimes(1) // still one timer
|
|
201
|
+
|
|
202
|
+
// Subsequent polls ride the same tick at the poll cadence (2000 ms).
|
|
203
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
204
|
+
expect(pollLatest).toHaveBeenCalledTimes(2)
|
|
205
|
+
client.stop()
|
|
206
|
+
} finally {
|
|
207
|
+
setIntervalSpy.mockRestore()
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('paces polls on the tick only after the stall phase ends', async () => {
|
|
212
|
+
const { factory } = makeSources()
|
|
213
|
+
const pages = [pageOf([0]), pageOf([0, 1]), pageOf([0, 1, 2]), pageOf([0, 1, 2, 3])]
|
|
214
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
215
|
+
const client = new MuxClient('/m/api/events.mux', {
|
|
216
|
+
sourceFactory: factory,
|
|
217
|
+
pollLatest,
|
|
218
|
+
stallThresholdMs: 800,
|
|
219
|
+
pollIntervalMs: 400,
|
|
220
|
+
})
|
|
221
|
+
client.start()
|
|
222
|
+
client.observe('s1')
|
|
223
|
+
|
|
224
|
+
// At 1200 ms the stall threshold is crossed on this single 400 ms tick.
|
|
225
|
+
await vi.advanceTimersByTimeAsync(1200)
|
|
226
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
227
|
+
|
|
228
|
+
// Each subsequent 400 ms tick is a poll, matching the poll cadence.
|
|
229
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
230
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
231
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
232
|
+
expect(pollLatest).toHaveBeenCalledTimes(4)
|
|
233
|
+
client.stop()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('observe() already in the stall window starts the single-tick poller immediately', async () => {
|
|
237
|
+
const { factory } = makeSources()
|
|
238
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
239
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
240
|
+
client.start()
|
|
241
|
+
// Advance well past the stall threshold while nothing is observed.
|
|
242
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
243
|
+
expect(pollLatest).not.toHaveBeenCalled()
|
|
244
|
+
|
|
245
|
+
// Observing a session now is already in the stall window: poll right away.
|
|
246
|
+
client.observe('s1')
|
|
247
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
248
|
+
client.stop()
|
|
249
|
+
})
|
|
172
250
|
})
|
package/src/mobile/mux.ts
CHANGED
|
@@ -66,6 +66,12 @@ type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
|
|
|
66
66
|
|
|
67
67
|
const DEFAULT_POLL_INTERVAL_MS = 3000
|
|
68
68
|
const DEFAULT_STALL_THRESHOLD_MS = 12000
|
|
69
|
+
/**
|
|
70
|
+
* Stall-check granularity: the single scheduler tick runs at least this
|
|
71
|
+
* often so fallback arms within a second of the stall threshold passing,
|
|
72
|
+
* while the poll cadence itself stays {@link MuxClientOptions.pollIntervalMs}.
|
|
73
|
+
*/
|
|
74
|
+
const STALL_CHECK_MS = 1000
|
|
69
75
|
/** Poll window: enough recent events to cover a few seconds of agent output. */
|
|
70
76
|
const DEFAULT_POLL_PAGE_SIZE = 50
|
|
71
77
|
|
|
@@ -98,9 +104,11 @@ export class MuxClient {
|
|
|
98
104
|
private sseAlive = false
|
|
99
105
|
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
100
106
|
private readonly pollWatermark = new Map<string, number>()
|
|
101
|
-
|
|
102
|
-
private
|
|
107
|
+
/** Single scheduler tick: both the stall check and the poll cadence ride this one interval. */
|
|
108
|
+
private tickTimer: ReturnType<typeof setInterval> | undefined
|
|
103
109
|
private polling = false
|
|
110
|
+
/** Epoch ms of the next due poll while polling (kept on the same tick timer). */
|
|
111
|
+
private nextPollAt = 0
|
|
104
112
|
|
|
105
113
|
/**
|
|
106
114
|
* @param url - the mobile events endpoint (browser-relative).
|
|
@@ -120,16 +128,17 @@ export class MuxClient {
|
|
|
120
128
|
this.stopped = false
|
|
121
129
|
this.lastDataAt = this.now()
|
|
122
130
|
if (this.source === undefined) this.connect()
|
|
123
|
-
this.
|
|
131
|
+
this.startTick()
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
/** Close for good. */
|
|
127
135
|
stop(): void {
|
|
128
136
|
this.stopped = true
|
|
129
|
-
this.
|
|
137
|
+
this.stopTick()
|
|
130
138
|
this.stopPolling()
|
|
131
139
|
this.closeSource()
|
|
132
140
|
this.observeSessionId = undefined
|
|
141
|
+
this.nextPollAt = 0
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
/** Subscribe to validated frames; returns an unsubscribe function. */
|
|
@@ -176,40 +185,52 @@ export class MuxClient {
|
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
187
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The single scheduler tick is the only interval this client owns. One
|
|
190
|
+
* timer (at the finer of the stall-check and poll cadences) both arms the
|
|
191
|
+
* polling fallback once the stall threshold passes and drives each poll at
|
|
192
|
+
* {@link MuxClientOptions.pollIntervalMs} — one timer instead of two.
|
|
193
|
+
*/
|
|
194
|
+
private startTick(): void {
|
|
195
|
+
if (this.tickTimer !== undefined) return
|
|
196
|
+
const cadence = Math.min(this.pollIntervalMs, STALL_CHECK_MS)
|
|
197
|
+
this.tickTimer = setInterval(() => { this.tick() }, cadence)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private stopTick(): void {
|
|
201
|
+
if (this.tickTimer !== undefined) {
|
|
202
|
+
clearInterval(this.tickTimer)
|
|
203
|
+
this.tickTimer = undefined
|
|
204
|
+
}
|
|
191
205
|
}
|
|
192
206
|
|
|
193
|
-
private
|
|
194
|
-
if (this.
|
|
195
|
-
|
|
196
|
-
|
|
207
|
+
private tick(): void {
|
|
208
|
+
if (this.stopped) return
|
|
209
|
+
if (this.observeSessionId === undefined) return
|
|
210
|
+
if (this.polling) {
|
|
211
|
+
// The stall phase has ended; the same tick now paces the polls.
|
|
212
|
+
if (this.now() >= this.nextPollAt) {
|
|
213
|
+
this.nextPollAt = this.now() + this.pollIntervalMs
|
|
214
|
+
void this.pollTick()
|
|
215
|
+
}
|
|
216
|
+
return
|
|
197
217
|
}
|
|
218
|
+
// A live SSE channel only goes silent while the agent idles; never poll
|
|
219
|
+
// against it. Fallback arms again only via onerror or a stream that has
|
|
220
|
+
// never delivered.
|
|
221
|
+
if (this.sseAlive) return
|
|
222
|
+
if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
|
|
198
223
|
}
|
|
199
224
|
|
|
200
225
|
private startPolling(): void {
|
|
201
226
|
if (this.polling || this.stopped) return
|
|
202
227
|
this.polling = true
|
|
228
|
+
this.nextPollAt = this.now() + this.pollIntervalMs
|
|
203
229
|
void this.pollTick()
|
|
204
|
-
this.pollTimer = setInterval(() => { void this.pollTick() }, this.pollIntervalMs)
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
private stopPolling(): void {
|
|
208
233
|
this.polling = false
|
|
209
|
-
if (this.pollTimer !== undefined) {
|
|
210
|
-
clearInterval(this.pollTimer)
|
|
211
|
-
this.pollTimer = undefined
|
|
212
|
-
}
|
|
213
234
|
}
|
|
214
235
|
|
|
215
236
|
/**
|
package/src/mobile-api.ts
CHANGED
|
@@ -49,6 +49,8 @@ const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
|
|
|
49
49
|
|
|
50
50
|
/** One session.list page (thin phones load incrementally). */
|
|
51
51
|
const SESSION_PAGE_SIZE = 20
|
|
52
|
+
/** SSE keep-alive ping cadence for the live mux stream (single connection). */
|
|
53
|
+
const DEFAULT_EVENTS_HEARTBEAT_MS = 15_000
|
|
52
54
|
|
|
53
55
|
/** Encode one list position as an opaque continuation cursor. */
|
|
54
56
|
function sessionListCursor(updatedAt: number, sessionId: string): string {
|
|
@@ -78,6 +80,8 @@ export interface MobileApiDeps {
|
|
|
78
80
|
apiProxy: ApiProxy
|
|
79
81
|
/** The resolved mobile composer preference (live per request). */
|
|
80
82
|
mobileEnterToSend: () => boolean
|
|
83
|
+
/** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
|
|
84
|
+
eventsHeartbeatMs?: number
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/** Mobile API route paths. */
|
|
@@ -97,6 +101,7 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`
|
|
|
97
101
|
*/
|
|
98
102
|
export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
99
103
|
const { service, apiProxy, mobileEnterToSend } = deps
|
|
104
|
+
const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS
|
|
100
105
|
|
|
101
106
|
/** The phone gate: a live paired-device cookie, or nothing else proceeds. */
|
|
102
107
|
const gateOk = (req: IncomingMessage): boolean => {
|
|
@@ -190,7 +195,7 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
190
195
|
} catch {
|
|
191
196
|
// The write failed; the close handler tears the subscription down.
|
|
192
197
|
}
|
|
193
|
-
},
|
|
198
|
+
}, eventsHeartbeatMs)
|
|
194
199
|
const onClose = (): void => {
|
|
195
200
|
if (closed) return
|
|
196
201
|
closed = true
|