dsh-cherry-bridge 0.2.0 → 0.2.2
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.en.md +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +808 -47
- package/docs/e2e-checklist.md +72 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +106 -22
- package/src/connection.ts +187 -2
- package/src/deviceStore.ts +314 -1
- package/src/diagnostics.ts +6 -5
- package/src/httpApi.ts +234 -27
- package/src/index.ts +18 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +158 -14
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
package/src/subscriptions.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
|
|
|
2
2
|
import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
|
|
3
3
|
import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
|
|
4
4
|
import { failureText } from './text.js'
|
|
5
|
+
import { remoteLimits } from '@cherrystudio/remote-protocol'
|
|
5
6
|
import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -40,6 +41,27 @@ const DEFAULT_CHECKPOINT_MS = 300_000
|
|
|
40
41
|
*/
|
|
41
42
|
const MAX_RECOVERY_ATTEMPTS = 4
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
|
|
46
|
+
* bytes it has sent since our last `agent.subscriptions.ack` stay inside
|
|
47
|
+
* `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
|
|
48
|
+
* the subscription still live — and a held batch can be a run's terminal.
|
|
49
|
+
*
|
|
50
|
+
* This layer used to ack ONLY from `cherry_events`, so a waiter (which by
|
|
51
|
+
* design consumes without acking) parked on a buffer that stopped advancing
|
|
52
|
+
* just under one window: a real 240 s wait answered `timeout` with the finished
|
|
53
|
+
* text, its cursor frozen at 666 events ≈ 262144 bytes.
|
|
54
|
+
*
|
|
55
|
+
* The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
|
|
56
|
+
* memory; acking does not lose them) once a quarter of the peer's window has
|
|
57
|
+
* accumulated. A retry cooldown keeps a refusing peer from turning every batch
|
|
58
|
+
* into a round trip.
|
|
59
|
+
*/
|
|
60
|
+
/** First retry delay after a refused auto-ack; doubles per attempt. */
|
|
61
|
+
const AUTO_ACK_RETRY_MS = 3_000
|
|
62
|
+
/** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
|
|
63
|
+
const AUTO_ACK_RETRY_MAX_MS = 30_000
|
|
64
|
+
|
|
43
65
|
export type ReadResult = { events: AgentEvent[]; nextCursor: AgentCursor } | { error: 'cursor-out-of-window' }
|
|
44
66
|
|
|
45
67
|
export interface SessionSubscriptionsOptions {
|
|
@@ -60,6 +82,20 @@ export interface SessionSubscriptionsOptions {
|
|
|
60
82
|
* default 300_000). Only bounds recovery retries; the peer is authoritative.
|
|
61
83
|
*/
|
|
62
84
|
checkpointMs?: number
|
|
85
|
+
/**
|
|
86
|
+
* The peer's hello-advertised un-acked delivery window in bytes (protocol
|
|
87
|
+
* `remoteLimits.unackedBytes`, 262144 in the pinned package). Past that much
|
|
88
|
+
* delivered-but-unacknowledged traffic the desktop HOLDS its event stream
|
|
89
|
+
* (issue #212), so the layer acks its buffer at a quarter of this value.
|
|
90
|
+
*
|
|
91
|
+
* Omitted falls back to the pinned protocol constant. A peer that does not
|
|
92
|
+
* advertise the key is treated as the pinned generation — the same
|
|
93
|
+
* conservative rule the other negotiated limits follow.
|
|
94
|
+
*
|
|
95
|
+
* A FUNCTION is read at every watermark check, so a layer that outlives a
|
|
96
|
+
* reconnect picks up the new hello's value instead of pinning the first one.
|
|
97
|
+
*/
|
|
98
|
+
unackedBytes?: number | (() => number | undefined)
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/** Peer-side subscription phases (mirrors the real peer's internal state). */
|
|
@@ -108,6 +144,31 @@ interface SessionState {
|
|
|
108
144
|
cursor: AgentCursor
|
|
109
145
|
buffer: CachedEvent[]
|
|
110
146
|
bytes: number
|
|
147
|
+
/**
|
|
148
|
+
* Bytes appended since the last ack we sent (issue #212). Drives
|
|
149
|
+
* {@link SessionSubscriptions.#maybeAutoAck}; kept separate from `bytes`,
|
|
150
|
+
* which measures what the CACHE holds rather than what the peer may still
|
|
151
|
+
* consider un-acknowledged.
|
|
152
|
+
*/
|
|
153
|
+
bytesSinceAck: number
|
|
154
|
+
/** One auto-ack in flight at a time; a second would only duplicate a cursor. */
|
|
155
|
+
ackInFlight: boolean
|
|
156
|
+
/** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
|
|
157
|
+
ackRetryAt: number
|
|
158
|
+
/**
|
|
159
|
+
* Current retry delay: `AUTO_ACK_RETRY_MS` after the first refusal, doubled
|
|
160
|
+
* on every further one up to `AUTO_ACK_RETRY_MAX_MS`. Reset on a success, so
|
|
161
|
+
* a peer that recovers is back to prompt retries.
|
|
162
|
+
*/
|
|
163
|
+
ackRetryDelay: number
|
|
164
|
+
/**
|
|
165
|
+
* The ARMED retry behind `ackRetryAt`. An ack failure is the one case where
|
|
166
|
+
* no further event will arrive to re-trigger the watermark check — the peer
|
|
167
|
+
* is holding at its window — so the retry cannot wait for traffic (issue
|
|
168
|
+
* #212's own failure mode, one ack later). Cleared by {@link #installReplay},
|
|
169
|
+
* {@link #installCheckpoint} and {@link SessionSubscriptions.dispose}.
|
|
170
|
+
*/
|
|
171
|
+
ackRetryTimer?: ReturnType<typeof setTimeout>
|
|
111
172
|
/**
|
|
112
173
|
* The oldest stream position this session still TRACKS: the baseline the
|
|
113
174
|
* subscription started from, advanced whenever old events are dropped. It is
|
|
@@ -189,6 +250,8 @@ export class SessionSubscriptions {
|
|
|
189
250
|
readonly #limit: number
|
|
190
251
|
readonly #perSessionCacheBytes: number
|
|
191
252
|
readonly #checkpointMs: number
|
|
253
|
+
/** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
|
|
254
|
+
readonly #unackedSource: number | (() => number | undefined) | undefined
|
|
192
255
|
readonly #sessions = new Map<string, SessionState>()
|
|
193
256
|
/**
|
|
194
257
|
* Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
|
|
@@ -211,6 +274,7 @@ export class SessionSubscriptions {
|
|
|
211
274
|
this.#limit = Math.max(1, Math.floor(positive(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)))
|
|
212
275
|
this.#perSessionCacheBytes = positive(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
|
|
213
276
|
this.#checkpointMs = positive(opts.checkpointMs, DEFAULT_CHECKPOINT_MS)
|
|
277
|
+
this.#unackedSource = opts.unackedBytes
|
|
214
278
|
}
|
|
215
279
|
|
|
216
280
|
/**
|
|
@@ -316,6 +380,7 @@ export class SessionSubscriptions {
|
|
|
316
380
|
// session that will not stay subscribed.
|
|
317
381
|
console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`)
|
|
318
382
|
await this.#closeSubscription(existing.subscriptionId)
|
|
383
|
+
this.#cancelAckRetry(existing)
|
|
319
384
|
this.#rejectWaiters(existing, new Error(`session ${sessionId} re-subscribed`))
|
|
320
385
|
this.#sessions.delete(sessionId)
|
|
321
386
|
}
|
|
@@ -424,13 +489,24 @@ export class SessionSubscriptions {
|
|
|
424
489
|
/**
|
|
425
490
|
* Advances the peer's view of this session: `agent.subscriptions.ack` with
|
|
426
491
|
* the delivered cursor. The peer only accepts a cursor inside the window it
|
|
427
|
-
* actually delivered (an out-of-window ack is its CONFLICT)
|
|
428
|
-
*
|
|
492
|
+
* actually delivered (an out-of-window ack is its CONFLICT).
|
|
493
|
+
*
|
|
494
|
+
* Two callers, one contract — "this position is accounted for, keep sending":
|
|
495
|
+
* `cherry_events` passes the cursor a CONSUMER has processed, and
|
|
496
|
+
* {@link #maybeAutoAck} passes the BUFFER frontier because the events are
|
|
497
|
+
* retained here (see that method for why an ack cannot lose replay).
|
|
429
498
|
*/
|
|
430
499
|
async ack(sessionId: string, cursor?: AgentCursor): Promise<void> {
|
|
431
500
|
const state = this.#sessions.get(sessionId)
|
|
432
501
|
if (state === undefined) return
|
|
433
|
-
|
|
502
|
+
// COALESCED TO THE FRONTIER. The peer keeps ONE `lastAck` per subscription
|
|
503
|
+
// and refuses a lower position, so an explicit ack carrying what a consumer
|
|
504
|
+
// has processed would lose a race against the layer's own ack and be
|
|
505
|
+
// answered CONFLICT — which the caller treats as a broken subscription.
|
|
506
|
+
// The buffer already holds everything up to `state.cursor`, so acking the
|
|
507
|
+
// frontier is both safe (an ack only frees flow control) and monotone.
|
|
508
|
+
const requested = cursor === undefined ? state.cursor : cursor
|
|
509
|
+
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
|
|
434
510
|
await this.#rpc.request('agent.subscriptions.ack', {
|
|
435
511
|
subscriptionId: state.subscriptionId,
|
|
436
512
|
cursor: applied,
|
|
@@ -484,6 +560,7 @@ export class SessionSubscriptions {
|
|
|
484
560
|
this.#closed = true
|
|
485
561
|
const ids = [...this.#sessions.values()].map((state) => state.subscriptionId)
|
|
486
562
|
for (const state of this.#sessions.values()) {
|
|
563
|
+
this.#cancelAckRetry(state)
|
|
487
564
|
const error = new Error('SessionSubscriptions disposed')
|
|
488
565
|
for (const waiter of state.waiters.splice(0)) waiter.reject(error)
|
|
489
566
|
}
|
|
@@ -590,7 +667,10 @@ export class SessionSubscriptions {
|
|
|
590
667
|
*/
|
|
591
668
|
#installReplay(sessionId: string, descriptor: Extract<SubscribeDescriptor, { mode: 'replay' }>): void {
|
|
592
669
|
const previous = this.#sessions.get(sessionId)
|
|
593
|
-
if (previous !== undefined)
|
|
670
|
+
if (previous !== undefined) {
|
|
671
|
+
this.#cancelAckRetry(previous)
|
|
672
|
+
this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
|
|
673
|
+
}
|
|
594
674
|
this.#sessions.delete(sessionId)
|
|
595
675
|
// Re-inserting moves the session to the MRU end of the Map's order.
|
|
596
676
|
this.#sessions.set(sessionId, {
|
|
@@ -598,6 +678,11 @@ export class SessionSubscriptions {
|
|
|
598
678
|
cursor: descriptor.highWatermark,
|
|
599
679
|
buffer: [],
|
|
600
680
|
bytes: 0,
|
|
681
|
+
bytesSinceAck: 0,
|
|
682
|
+
ackInFlight: false,
|
|
683
|
+
ackRetryAt: 0,
|
|
684
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
685
|
+
ackRetryTimer: undefined,
|
|
601
686
|
windowStart: Number(descriptor.highWatermark.seq),
|
|
602
687
|
// A fresh window: nothing has been dropped from it, so the baseline is a
|
|
603
688
|
// position this layer may serve (issue #2).
|
|
@@ -723,13 +808,21 @@ export class SessionSubscriptions {
|
|
|
723
808
|
items: Array<{ kind: string; value: unknown }>,
|
|
724
809
|
): void {
|
|
725
810
|
const previous = this.#sessions.get(sessionId)
|
|
726
|
-
if (previous !== undefined)
|
|
811
|
+
if (previous !== undefined) {
|
|
812
|
+
this.#cancelAckRetry(previous)
|
|
813
|
+
this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
|
|
814
|
+
}
|
|
727
815
|
this.#sessions.delete(sessionId)
|
|
728
816
|
this.#sessions.set(sessionId, {
|
|
729
817
|
subscriptionId: descriptor.subscriptionId,
|
|
730
818
|
cursor: descriptor.checkpoint.cursor,
|
|
731
819
|
buffer: [],
|
|
732
820
|
bytes: 0,
|
|
821
|
+
bytesSinceAck: 0,
|
|
822
|
+
ackInFlight: false,
|
|
823
|
+
ackRetryAt: 0,
|
|
824
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
825
|
+
ackRetryTimer: undefined,
|
|
733
826
|
windowStart: Number(descriptor.checkpoint.cursor.seq),
|
|
734
827
|
// A fresh window: nothing has been dropped from it, so the baseline is a
|
|
735
828
|
// position this layer may serve (issue #2).
|
|
@@ -801,6 +894,7 @@ export class SessionSubscriptions {
|
|
|
801
894
|
`${sessionId}`,
|
|
802
895
|
)
|
|
803
896
|
// The entry is removed outright, so no `live` flag needs clearing.
|
|
897
|
+
this.#cancelAckRetry(state)
|
|
804
898
|
this.#rejectWaiters(state, new Error(`session ${sessionId} evicted by LRU`))
|
|
805
899
|
this.#sessions.delete(sessionId)
|
|
806
900
|
// AWAITED: the peer must observe the close before the next subscribe.
|
|
@@ -907,6 +1001,7 @@ export class SessionSubscriptions {
|
|
|
907
1001
|
const bytes = eventBytes(event)
|
|
908
1002
|
state.buffer.push({ cursor, event, bytes })
|
|
909
1003
|
state.bytes += bytes
|
|
1004
|
+
state.bytesSinceAck += bytes
|
|
910
1005
|
state.cursor = cursor
|
|
911
1006
|
appended += 1
|
|
912
1007
|
}
|
|
@@ -915,6 +1010,106 @@ export class SessionSubscriptions {
|
|
|
915
1010
|
// Waking on a wholly stale batch would make a parked waiter re-judge state
|
|
916
1011
|
// that has not moved; only real progress is a wake.
|
|
917
1012
|
if (appended > 0) this.#wakeWaiters(state)
|
|
1013
|
+
// The wake comes FIRST: a waiter that can settle from what just arrived
|
|
1014
|
+
// must not wait behind an ack round trip.
|
|
1015
|
+
if (appended > 0) this.#maybeAutoAck(sessionId, state)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
|
|
1020
|
+
* window, so the peer keeps delivering (issue #212).
|
|
1021
|
+
*
|
|
1022
|
+
* WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
|
|
1023
|
+
* a caller has consumed". That is safe against the pinned peer because an ack
|
|
1024
|
+
* does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
|
|
1025
|
+
* un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
|
|
1026
|
+
* retention lives on the journal (`replayBytes`/`replayMs`) and is not
|
|
1027
|
+
* touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
|
|
1028
|
+
* below the acked position is still served — by the local buffer here and by
|
|
1029
|
+
* the journal there. The ack's own doc comment states the same contract.
|
|
1030
|
+
*
|
|
1031
|
+
* The ack is fire-and-forget with its failure SURFACED (a refused ack must be
|
|
1032
|
+
* visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
|
|
1033
|
+
* holding at its window no further event arrives, so a retry that waited for
|
|
1034
|
+
* traffic would never fire and the wait would time out exactly as in issue
|
|
1035
|
+
* #212.
|
|
1036
|
+
*/
|
|
1037
|
+
#maybeAutoAck(sessionId: string, state: SessionState): void {
|
|
1038
|
+
if (state.bytesSinceAck < this.#ackWatermark()) return
|
|
1039
|
+
if (state.ackInFlight) return
|
|
1040
|
+
if (Date.now() < state.ackRetryAt) return
|
|
1041
|
+
state.ackInFlight = true
|
|
1042
|
+
// The bytes covered by THIS ack. Events that arrive while it is in flight
|
|
1043
|
+
// stay counted, so the next window starts from the right place.
|
|
1044
|
+
const covered = state.bytesSinceAck
|
|
1045
|
+
void this.ack(sessionId)
|
|
1046
|
+
.then(() => {
|
|
1047
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
|
|
1048
|
+
// A peer that answers ack again gets prompt retries next time.
|
|
1049
|
+
state.ackRetryDelay = AUTO_ACK_RETRY_MS
|
|
1050
|
+
})
|
|
1051
|
+
.catch((error: unknown) => {
|
|
1052
|
+
// THIS attempt's delay, then the next one doubles: a peer that refuses
|
|
1053
|
+
// every ack (a wedged controller, a link about to drop) must not log a
|
|
1054
|
+
// line every few seconds for the life of the layer.
|
|
1055
|
+
const delay = state.ackRetryDelay
|
|
1056
|
+
state.ackRetryAt = Date.now() + delay
|
|
1057
|
+
state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS)
|
|
1058
|
+
console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`)
|
|
1059
|
+
// ARMED HERE, not on the next batch: a peer holding at its window sends
|
|
1060
|
+
// nothing more, so traffic-driven retry is no retry at all.
|
|
1061
|
+
this.#armAckRetry(sessionId, state, delay)
|
|
1062
|
+
})
|
|
1063
|
+
.finally(() => {
|
|
1064
|
+
state.ackInFlight = false
|
|
1065
|
+
// RE-JUDGE: batches can arrive WHILE the ack was in flight (the peer
|
|
1066
|
+
// flushes on the ack, so this is the common case), and those batches
|
|
1067
|
+
// were refused a trigger by `ackInFlight`. Without this second look the
|
|
1068
|
+
// layer could sit with bytes over the watermark and no event left to
|
|
1069
|
+
// trigger it — the peer holding, exactly as in issue #212.
|
|
1070
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return
|
|
1071
|
+
this.#maybeAutoAck(sessionId, state)
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Quarter of the peer's window, read from the CURRENT source so a layer that
|
|
1077
|
+
* outlives a reconnect follows the new hello instead of its first answer.
|
|
1078
|
+
* Falls back to the pinned protocol constant when nothing is advertised.
|
|
1079
|
+
*/
|
|
1080
|
+
#ackWatermark(): number {
|
|
1081
|
+
const source = this.#unackedSource
|
|
1082
|
+
const advertised = typeof source === 'function' ? source() : source
|
|
1083
|
+
return Math.max(1, Math.floor(positive(advertised, remoteLimits.unackedBytes) / 4))
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Disarms the retry, if any (idempotent; safe on a state being replaced). */
|
|
1087
|
+
#cancelAckRetry(state: SessionState): void {
|
|
1088
|
+
if (state.ackRetryTimer === undefined) return
|
|
1089
|
+
clearTimeout(state.ackRetryTimer)
|
|
1090
|
+
state.ackRetryTimer = undefined
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* One timer-driven retry after a refused ack. At most one is armed per
|
|
1095
|
+
* session; it no-ops when the state it was armed for is no longer current
|
|
1096
|
+
* (a re-subscribe, a checkpoint recovery or `dispose`).
|
|
1097
|
+
*/
|
|
1098
|
+
#armAckRetry(sessionId: string, state: SessionState, delayMs: number): void {
|
|
1099
|
+
if (this.#closed) return
|
|
1100
|
+
if (state.ackRetryTimer !== undefined) return
|
|
1101
|
+
const timer = setTimeout(() => {
|
|
1102
|
+
state.ackRetryTimer = undefined
|
|
1103
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return
|
|
1104
|
+
// CLEARED HERE, not left to the wall clock: a clock stepped backwards
|
|
1105
|
+
// would otherwise make `#maybeAutoAck` return early and arm nothing — a
|
|
1106
|
+
// retry silently lost while the peer holds, which is #212 again.
|
|
1107
|
+
state.ackRetryAt = 0
|
|
1108
|
+
this.#maybeAutoAck(sessionId, state)
|
|
1109
|
+
}, delayMs)
|
|
1110
|
+
// Never hold the process open on a retry that is only about flow control.
|
|
1111
|
+
timer.unref?.()
|
|
1112
|
+
state.ackRetryTimer = timer
|
|
918
1113
|
}
|
|
919
1114
|
|
|
920
1115
|
/**
|
|
@@ -937,6 +1132,7 @@ export class SessionSubscriptions {
|
|
|
937
1132
|
`[cherry-remote] subscription for session ${sessionId} reset by the peer ` +
|
|
938
1133
|
`(${String(params['reason'] ?? 'unspecified')}); re-subscribe required`,
|
|
939
1134
|
)
|
|
1135
|
+
this.#cancelAckRetry(state)
|
|
940
1136
|
this.#rejectWaiters(state, new Error(`session ${sessionId} reset by the peer`))
|
|
941
1137
|
this.#sessions.delete(sessionId)
|
|
942
1138
|
return
|
package/src/tools.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { DeviceUnavailableError, RpcDeadlineExceededError } from './connection.js'
|
|
3
3
|
import type { DeviceConnection, DeviceState } from './connection.js'
|
|
4
4
|
import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
|
|
5
|
-
import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
5
|
+
import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
6
6
|
import {
|
|
7
7
|
DEVICE_SETTLE_WAIT_MS,
|
|
8
8
|
INTERACTIONS_LIST_PAGE_CLAMP,
|
|
@@ -111,6 +111,13 @@ export interface CherryDeviceView {
|
|
|
111
111
|
enabled: boolean
|
|
112
112
|
hosts: string[]
|
|
113
113
|
port: number
|
|
114
|
+
/**
|
|
115
|
+
* The addresses the operator has FORGOTTEN (issue #185): not dialed, and not
|
|
116
|
+
* re-learned from the desktop's advertisements until they are remembered.
|
|
117
|
+
* Always present on a view (empty when nothing was ever forgotten) so a
|
|
118
|
+
* caller never has to distinguish "absent" from "none".
|
|
119
|
+
*/
|
|
120
|
+
forgottenHosts: string[]
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
/** The shared `{device, state}` verdict (spec §6: 设备离线). */
|
|
@@ -1216,8 +1223,11 @@ export interface CherryToolsDeps {
|
|
|
1216
1223
|
* `DeviceConnection` captures its record at construction, so a pooled
|
|
1217
1224
|
* instance can never observe an edited address or a flipped `enabled` flag.
|
|
1218
1225
|
*
|
|
1219
|
-
* `cherry_unpair`
|
|
1220
|
-
*
|
|
1226
|
+
* `cherry_unpair` evicts (drop + close) and then removes the archive entry,
|
|
1227
|
+
* so no live socket outlives its record, and `cherry_devices`' `forget` /
|
|
1228
|
+
* `remember` actions evict ON A CHANGE for the same reason: a pooled
|
|
1229
|
+
* connection captured the pre-write record, so a forget that only rewrote the
|
|
1230
|
+
* archive would keep dialing the address (and locally re-learning it).
|
|
1221
1231
|
*/
|
|
1222
1232
|
evict(deviceId: string): void | Promise<void>
|
|
1223
1233
|
/**
|
|
@@ -1642,6 +1652,59 @@ function optionalString(value: unknown): string | undefined {
|
|
|
1642
1652
|
return typeof value === 'string' && value !== '' ? value : undefined
|
|
1643
1653
|
}
|
|
1644
1654
|
|
|
1655
|
+
/**
|
|
1656
|
+
* The `hosts` argument of `cherry_devices`' write actions (issue #185): a
|
|
1657
|
+
* non-empty list of non-empty host names, with no case-variant duplicate.
|
|
1658
|
+
*
|
|
1659
|
+
* Each rule is a state the store would have to interpret: an empty name can
|
|
1660
|
+
* never match a dialed host, and two spellings of one host would make "is this
|
|
1661
|
+
* address forgotten?" depend on which entry a comparison hit first. Values are
|
|
1662
|
+
* TRIMMED here (the model-facing surface is where a stray space is caught);
|
|
1663
|
+
* the archive then stores the spelling it already had for a dialed host.
|
|
1664
|
+
*/
|
|
1665
|
+
function hostListArgument(value: unknown): string[] | Record<string, unknown> {
|
|
1666
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1667
|
+
return { error: 'invalid-hosts', message: '`hosts` must be a non-empty array of host names' }
|
|
1668
|
+
}
|
|
1669
|
+
const hosts: string[] = []
|
|
1670
|
+
const seen = new Set<string>()
|
|
1671
|
+
for (const entry of value) {
|
|
1672
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
1673
|
+
return { error: 'invalid-hosts', message: '`hosts` entries must be non-empty strings' }
|
|
1674
|
+
}
|
|
1675
|
+
let host = entry.trim()
|
|
1676
|
+
// The archive stores BARE hosts, and the PATCH path unwraps a bracketed
|
|
1677
|
+
// IPv6 literal (`normalizeHost`). This face must agree: a mark for
|
|
1678
|
+
// `[fd00::1]` would never match the `fd00::1` a dial or an advertisement
|
|
1679
|
+
// carries, so it would be a mark with no effect. The ceiling mirrors the
|
|
1680
|
+
// PATCH path's `HOST_MAX`.
|
|
1681
|
+
if (host.startsWith('[') && host.endsWith(']')) {
|
|
1682
|
+
try {
|
|
1683
|
+
new URL(`http://${host}/`)
|
|
1684
|
+
} catch {
|
|
1685
|
+
return {
|
|
1686
|
+
error: 'invalid-hosts',
|
|
1687
|
+
message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`,
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
host = host.slice(1, -1)
|
|
1691
|
+
}
|
|
1692
|
+
if (host.length > 256) {
|
|
1693
|
+
return { error: 'invalid-hosts', message: '`hosts` entry exceeds 256 characters' }
|
|
1694
|
+
}
|
|
1695
|
+
const key = host.toLowerCase()
|
|
1696
|
+
if (seen.has(key)) {
|
|
1697
|
+
return {
|
|
1698
|
+
error: 'invalid-hosts',
|
|
1699
|
+
message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`,
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
seen.add(key)
|
|
1703
|
+
hosts.push(host)
|
|
1704
|
+
}
|
|
1705
|
+
return hosts
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1645
1708
|
/**
|
|
1646
1709
|
* The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
|
|
1647
1710
|
* in the exact shape Cherry Studio answers it: a `rejected` receipt whose
|
|
@@ -2729,25 +2792,100 @@ export class CherryToolEngine {
|
|
|
2729
2792
|
description:
|
|
2730
2793
|
'List every paired Cherry Studio desktop with its connection state ' +
|
|
2731
2794
|
'(connected/connecting/offline/disabled/identity-mismatch). An optional `device` ' +
|
|
2732
|
-
'narrows the list to one device (display name prefix or deviceId).'
|
|
2795
|
+
'narrows the list to one device (display name prefix or deviceId). ' +
|
|
2796
|
+
'`action: "forget"` removes the named `hosts` from that device\'s dial candidates AND ' +
|
|
2797
|
+
'stops the desktop\'s advertisements from adding them back; `action: "remember"` lifts ' +
|
|
2798
|
+
'that mark, so the next handshake may learn them again. Both write actions require ' +
|
|
2799
|
+
'`device` and `hosts`.',
|
|
2733
2800
|
parameters: {
|
|
2734
2801
|
type: 'object',
|
|
2735
2802
|
properties: {
|
|
2736
2803
|
device: { type: 'string', description: 'Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name.' },
|
|
2804
|
+
action: {
|
|
2805
|
+
type: 'string',
|
|
2806
|
+
enum: ['list', 'forget', 'remember'],
|
|
2807
|
+
description:
|
|
2808
|
+
'What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE ' +
|
|
2809
|
+
"device's forgotten addresses and require `device` and `hosts`.",
|
|
2810
|
+
},
|
|
2811
|
+
hosts: {
|
|
2812
|
+
type: 'array',
|
|
2813
|
+
items: { type: 'string' },
|
|
2814
|
+
description:
|
|
2815
|
+
'For `forget`/`remember`: the host names to act on, matched case-insensitively. ' +
|
|
2816
|
+
'`forget` refuses to remove the last remaining address.',
|
|
2817
|
+
},
|
|
2737
2818
|
},
|
|
2738
2819
|
additionalProperties: false,
|
|
2739
2820
|
},
|
|
2740
2821
|
execute: async (args) => {
|
|
2822
|
+
const action = optionalString(args['action']) ?? 'list'
|
|
2823
|
+
if (action !== 'list' && action !== 'forget' && action !== 'remember') {
|
|
2824
|
+
return {
|
|
2825
|
+
error: 'invalid-action',
|
|
2826
|
+
message: '`action` accepts exactly "list", "forget" or "remember"',
|
|
2827
|
+
} satisfies Record<string, unknown>
|
|
2828
|
+
}
|
|
2741
2829
|
const selector = optionalString(args['device'])
|
|
2742
|
-
|
|
2743
|
-
|
|
2830
|
+
if (action === 'list') {
|
|
2831
|
+
const quarantined = this.#quarantineVerdict()
|
|
2832
|
+
if (quarantined !== undefined) return quarantined
|
|
2833
|
+
if (selector === undefined) {
|
|
2834
|
+
const records = this.#deps.store.list()
|
|
2835
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
|
|
2836
|
+
}
|
|
2837
|
+
const selected = this.#selectRecords(selector)
|
|
2838
|
+
if (!Array.isArray(selected)) return selected
|
|
2839
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
|
|
2840
|
+
}
|
|
2744
2841
|
if (selector === undefined) {
|
|
2745
|
-
|
|
2746
|
-
|
|
2842
|
+
return {
|
|
2843
|
+
error: 'device-required',
|
|
2844
|
+
message:
|
|
2845
|
+
'`action` "forget"/"remember" needs `device`: a selector that names exactly one device',
|
|
2846
|
+
} satisfies Record<string, unknown>
|
|
2747
2847
|
}
|
|
2848
|
+
const hosts = hostListArgument(args['hosts'])
|
|
2849
|
+
if (!Array.isArray(hosts)) return hosts
|
|
2850
|
+
const quarantined = this.#quarantineVerdict()
|
|
2851
|
+
if (quarantined !== undefined) return quarantined
|
|
2748
2852
|
const selected = this.#selectRecords(selector)
|
|
2749
2853
|
if (!Array.isArray(selected)) return selected
|
|
2750
|
-
|
|
2854
|
+
const record = selected[0]!
|
|
2855
|
+
let removed: string[] = []
|
|
2856
|
+
let marked: string[] = []
|
|
2857
|
+
let restored: string[] = []
|
|
2858
|
+
try {
|
|
2859
|
+
if (action === 'forget') {
|
|
2860
|
+
;({ removed, marked } = this.#deps.store.forgetEndpoints(record.deviceId, hosts))
|
|
2861
|
+
} else {
|
|
2862
|
+
;({ restored } = this.#deps.store.rememberEndpoints(record.deviceId, hosts))
|
|
2863
|
+
}
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
if (error instanceof LastCandidateHostError) {
|
|
2866
|
+
return {
|
|
2867
|
+
error: 'last-address',
|
|
2868
|
+
device: record.deviceId,
|
|
2869
|
+
message: error.message,
|
|
2870
|
+
} satisfies Record<string, unknown>
|
|
2871
|
+
}
|
|
2872
|
+
throw error
|
|
2873
|
+
}
|
|
2874
|
+
const changed = removed.length > 0 || marked.length > 0 || restored.length > 0
|
|
2875
|
+
// ADDRESS MAINTENANCE reaches the LIVE connection, exactly like the HTTP
|
|
2876
|
+
// PATCH's eviction: a pooled `DeviceConnection` captures its record at
|
|
2877
|
+
// construction, so an instance built before this write would keep
|
|
2878
|
+
// dialing the forgotten host and locally re-adding it on every
|
|
2879
|
+
// reconnect. Evicting drops that socket and makes the next `connect()`
|
|
2880
|
+
// rebuild from the archive we just wrote.
|
|
2881
|
+
if (changed) await this.#deps.evict(record.deviceId)
|
|
2882
|
+
// The resolved record is STALE (hosts/forgottenHosts moved), so the
|
|
2883
|
+
// view is built from the archive's current row, never from the object
|
|
2884
|
+
// the selector handed us.
|
|
2885
|
+
const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId)
|
|
2886
|
+
if (fresh === undefined) return this.#notFoundVerdict(selector)
|
|
2887
|
+
const device = await this.#deviceView(fresh)
|
|
2888
|
+
return action === 'forget' ? { device, removed, marked } : { device, restored }
|
|
2751
2889
|
},
|
|
2752
2890
|
})
|
|
2753
2891
|
}
|
|
@@ -2814,6 +2952,7 @@ export class CherryToolEngine {
|
|
|
2814
2952
|
enabled: record.enabled,
|
|
2815
2953
|
hosts: record.hosts,
|
|
2816
2954
|
port: record.port,
|
|
2955
|
+
forgottenHosts: record.forgottenHosts ?? [],
|
|
2817
2956
|
}
|
|
2818
2957
|
}
|
|
2819
2958
|
|
|
@@ -3938,9 +4077,13 @@ export class CherryToolEngine {
|
|
|
3938
4077
|
} satisfies CherryCursorOutOfWindow
|
|
3939
4078
|
}
|
|
3940
4079
|
|
|
3941
|
-
// ACK AFTER DELIVERY (controller ruling ④):
|
|
3942
|
-
//
|
|
3943
|
-
//
|
|
4080
|
+
// ACK AFTER DELIVERY (controller ruling ④): this EXPLICIT ack reports
|
|
4081
|
+
// the position the caller has just been handed. The layer ALSO acks its
|
|
4082
|
+
// own buffer on a byte watermark (issue #212, subscriptions
|
|
4083
|
+
// `#maybeAutoAck`), because the peer holds the stream past its un-acked
|
|
4084
|
+
// window. The peer keeps ONE `lastAck` and refuses a lower position, so
|
|
4085
|
+
// both sites coalesce to the buffer frontier — see `ack()`.
|
|
4086
|
+
// A failing ack is logged, never allowed to swallow the delivered events.
|
|
3944
4087
|
// A FAILING ACK ALSO RE-ENSURES THE SESSION (v0.1.3 UltraReview P3-3).
|
|
3945
4088
|
// The ack is the peer's verdict on the SUBSCRIPTION, not on the
|
|
3946
4089
|
// delivery: a refused ack says the cursor is outside the window this
|
|
@@ -5902,8 +6045,9 @@ export class CherryToolEngine {
|
|
|
5902
6045
|
* dimension and deliberately does NOT settle a wait (Task 13 asserts the two
|
|
5903
6046
|
* layers separately).
|
|
5904
6047
|
*
|
|
5905
|
-
* Waiters
|
|
5906
|
-
*
|
|
6048
|
+
* Waiters do not ack what they fold. The subscription LAYER acks its own
|
|
6049
|
+
* buffer on a byte watermark (issue #212) so the peer keeps delivering past
|
|
6050
|
+
* its un-acked window; that ack is about flow control, not consumption.
|
|
5907
6051
|
*
|
|
5908
6052
|
* A wait can also end because the LINK went away — the plugin was torn down,
|
|
5909
6053
|
* the device was unpaired, or the connection closed. That is not a timeout and
|
package/ui/AGENTS.md
CHANGED
|
@@ -18,6 +18,27 @@ with its source. Loaded automatically when a file tool touches `ui/**`.
|
|
|
18
18
|
- **HTTP lives in `api.ts`** (same-origin `/cherry-remote/api`); a component
|
|
19
19
|
never fetches directly. Both seams are injected for tests
|
|
20
20
|
(`eventSourceFactory`, `scopeClient`), so no test stands up a server.
|
|
21
|
+
- **A run always ends, and the deadline is not a verdict.** The server publishes
|
|
22
|
+
a terminal stage on every outcome, but the stream has no replay, so one lost
|
|
23
|
+
frame would otherwise park the wizard on 「配对进行中」 with the submit button
|
|
24
|
+
disabled and no cancel. The run deadline (`runLifetimeMs`, same injection
|
|
25
|
+
style as `codeLifetimeMs`) restores the form and re-reads the device list;
|
|
26
|
+
its copy points at that list instead of claiming failure, and it keeps
|
|
27
|
+
`claimId` so a terminal frame that arrives late still wins. Arm it on the
|
|
28
|
+
`waiting` phase only — never at mount.
|
|
29
|
+
- **The bundle carries the SOURCE COMMENTS.** `ui/vite.config.ts` sets
|
|
30
|
+
`minify: false`, so the docblocks that hold most of this page's reasoning ship
|
|
31
|
+
inside `lib/client.js`: a comment-only edit in `ui/src/**` changes the
|
|
32
|
+
artifact's bytes and needs the rebuild in the same commit. Two follow-ons from
|
|
33
|
+
that: `artifactFreshness` compares against `git show HEAD:<path>`, so it only
|
|
34
|
+
goes green once the rebuild is COMMITTED — running the full suite before
|
|
35
|
+
committing reports it red for the same reason its sibling case ("neither check
|
|
36
|
+
mutates the committed artifacts") is red. Neither red is a real failure.
|
|
37
|
+
- **A per-host action is INERT while a whole-list editor is open.** The address
|
|
38
|
+
editor's draft is sacred (the card does not reseed it while an editor is
|
|
39
|
+
open), so a 忘记/恢复 chosen under it would be re-sent by the next 保存地址 — and
|
|
40
|
+
that PATCH clears the mark for any host it names. Hence
|
|
41
|
+
`disabled={busy || editor === 'address'}` on both controls, pinned by `#204m`.
|
|
21
42
|
- **jsdom only.** `tests/unit/ui.test.ts` and `uiScopePanel.test.ts` render the
|
|
22
43
|
page; a browser-only API needs a fake in the test, not a guard in the page.
|
|
23
44
|
|