dsh-plugin-weapp-gateway 0.8.23 → 0.8.34
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/LICENSE +21 -21
- package/PROTOCOL.md +1545 -1486
- package/README.md +503 -464
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
- package/bin/setup-ip.mjs +474 -474
- package/cordis.patch.yml +50 -50
- package/docs/agent-team-app-integration.md +945 -0
- package/docs/blog-mobile-gateway.md +542 -542
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
- package/docs/dsh-rc2-mobile-integration.md +155 -155
- package/docs/file-upload-app-integration.md +250 -0
- package/docs/multi-gateway-app-integration.md +141 -125
- package/docs/multi-gateway-phase1-acceptance.md +179 -179
- package/docs/multi-gateway-todo.md +137 -137
- package/docs/remote-gateway-refactor-plan.md +52 -52
- package/docs/runtime-architecture.architecture.json +264 -264
- package/docs/runtime-architecture.html +15001 -15001
- package/docs/runtime-architecture.visual-check.html +32 -32
- package/docs/runtime-architecture.visual-check.json +548 -548
- package/docs/session-agent-preset-app-integration.md +93 -93
- package/docs/turn-outline-app-integration.md +196 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -294
- package/docs/voice-input-implementation.md +219 -219
- package/docs/workspace-changes-mobile-integration.md +32 -32
- package/helper/dsh_mobile_gateway_helper.py +227 -227
- package/lib/account-bridge.mjs +57 -57
- package/lib/client.js +1409 -1361
- package/lib/cloudflare-tunnel.mjs +314 -314
- package/lib/cloudflared-binary.mjs +135 -135
- package/lib/context-bridge.mjs +3338 -1715
- package/lib/cost-meter-bridge.mjs +794 -794
- package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
- package/lib/devices.js +273 -245
- package/lib/dsh-host-adapter.mjs +508 -426
- package/lib/file-upload.mjs +205 -0
- package/lib/gateway-probe.mjs +6 -6
- package/lib/gateway-state.mjs +57 -57
- package/lib/gateway-tools.mjs +36 -36
- package/lib/history-cache.mjs +66 -0
- package/lib/index.mjs +4390 -3803
- package/lib/plugin-management.mjs +251 -233
- package/lib/public-endpoint-check.mjs +61 -61
- package/lib/relay-connector.mjs +213 -181
- package/lib/relay-state.mjs +83 -76
- package/lib/session-follower.mjs +140 -136
- package/lib/session-resume-bridge.mjs +86 -0
- package/lib/session-target.mjs +29 -0
- package/lib/speech-input.mjs +192 -192
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
- package/lib/vendor/cost-meter/net.mjs +183 -183
- package/lib/vendor/cost-meter/opendesign.mjs +204 -204
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
- package/lib/web-restart.mjs +47 -47
- package/lib/wire-json.mjs +7 -7
- package/lib/workspace-changes-bridge.mjs +32 -32
- package/package.json +54 -54
- package/relay/agent-connector.js +588 -569
- package/relay/vendor/tweetnacl.js +2391 -2391
package/lib/relay-state.mjs
CHANGED
|
@@ -1,76 +1,83 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import crypto from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
// Pairing payload shapes. `direct` keeps the historical QR that advertises the
|
|
6
|
-
// gateway's own ws/wss endpoints; `relay` emits the outbound-relay payload the
|
|
7
|
-
// WeChat mini-program consumes. Both stay supported
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
// Pairing payload shapes. `direct` keeps the historical QR that advertises the
|
|
6
|
+
// gateway's own ws/wss endpoints; `relay` emits the outbound-relay payload the
|
|
7
|
+
// WeChat mini-program consumes. Both stay supported; selecting one only changes
|
|
8
|
+
// the payload emitted for newly generated pairing codes.
|
|
9
|
+
export const PAIRING_MODES = ['direct', 'relay']
|
|
10
|
+
|
|
11
|
+
// Pairing mode chooses the payload for the next QR; a registered relay is an
|
|
12
|
+
// independent live route and must stay up while direct clients are paired.
|
|
13
|
+
export function shouldRunRelayConnector(state) {
|
|
14
|
+
return state?.pairingMode === 'relay' || state?.relay != null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NODE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
18
|
+
const BASE64 = /^[A-Za-z0-9+/]{20,}={0,2}$/
|
|
19
|
+
|
|
20
|
+
const isRelay = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
21
|
+
&& typeof value.relay === 'string' && /^wss?:\/\//.test(value.relay)
|
|
22
|
+
&& typeof value.nodeId === 'string' && NODE_ID.test(value.nodeId)
|
|
23
|
+
&& typeof value.agentPubKey === 'string' && BASE64.test(value.agentPubKey)
|
|
24
|
+
&& typeof value.gatewayName === 'string' && value.gatewayName.length > 0
|
|
25
|
+
&& value.gatewayName.length <= 80
|
|
26
|
+
&& Number.isSafeInteger(value.updatedAt)
|
|
27
|
+
|
|
28
|
+
// A dedicated file keeps the relay registration independent of device
|
|
29
|
+
// revocation and of the gateway identity. A malformed file must fail startup
|
|
30
|
+
// rather than silently drop a registration the operator relies on.
|
|
31
|
+
export function createRelayState(file) {
|
|
32
|
+
const validate = (value) => {
|
|
33
|
+
if (!value || value.version !== 1) throw new Error('invalid relay state')
|
|
34
|
+
if (!PAIRING_MODES.includes(value.pairingMode)) throw new Error('invalid pairing mode')
|
|
35
|
+
if (value.relay !== null && !isRelay(value.relay)) throw new Error('invalid relay registration')
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
const read = () => validate(JSON.parse(fs.readFileSync(file, 'utf8')))
|
|
39
|
+
const write = (value, initial = false) => {
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
41
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
44
|
+
if (initial) fs.linkSync(tmp, file) // Never overwrite a concurrently created file.
|
|
45
|
+
else fs.renameSync(tmp, file)
|
|
46
|
+
} finally {
|
|
47
|
+
fs.rmSync(tmp, { force: true })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let state
|
|
51
|
+
try {
|
|
52
|
+
try {
|
|
53
|
+
state = read()
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code !== 'ENOENT') throw error
|
|
56
|
+
// `direct` is the historical behaviour, so an existing installation that
|
|
57
|
+
// has never seen a relay keeps producing exactly the QR it always did.
|
|
58
|
+
state = { version: 1, pairingMode: 'direct', relay: null }
|
|
59
|
+
try { write(state, true) } catch (cause) {
|
|
60
|
+
if (cause.code !== 'EEXIST') throw cause
|
|
61
|
+
state = read()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
fs.chmodSync(file, 0o600)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new Error(`failed to load relay state ${file}: ${error.message}`, { cause: error })
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
get pairingMode() { return state.pairingMode },
|
|
70
|
+
get relay() { return state.relay },
|
|
71
|
+
setPairingMode(mode) {
|
|
72
|
+
if (!PAIRING_MODES.includes(mode)) throw new TypeError('invalid pairing mode')
|
|
73
|
+
const next = { ...state, pairingMode: mode }
|
|
74
|
+
write(next)
|
|
75
|
+
state = next
|
|
76
|
+
},
|
|
77
|
+
setRelay(relay) {
|
|
78
|
+
const next = { ...state, relay: relay === null ? null : { ...relay, updatedAt: Date.now() } }
|
|
79
|
+
write(next)
|
|
80
|
+
state = next
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/session-follower.mjs
CHANGED
|
@@ -1,136 +1,140 @@
|
|
|
1
|
-
import crypto from 'node:crypto'
|
|
2
|
-
import { readSessionSnapshot } from './dsh-host-adapter.mjs'
|
|
3
|
-
|
|
4
|
-
function integer(value, minimum = 0) {
|
|
5
|
-
return Number.isSafeInteger(value) && value >= minimum
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function requireAttempt(attempt) {
|
|
9
|
-
if (!attempt || typeof attempt.attemptId !== 'string' || !attempt.attemptId
|
|
10
|
-
|| !integer(attempt.turn) || !integer(attempt.step)
|
|
11
|
-
|| !integer(attempt.startedAfterSeq, -1)) throw new Error('invalid assistant attempt')
|
|
12
|
-
return attempt
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Validate the two independent clocks. No transient frame owns a Session seq.
|
|
16
|
-
export function createFollowDecoder(sessionId) {
|
|
17
|
-
let cursor
|
|
18
|
-
let revision
|
|
19
|
-
let active
|
|
20
|
-
return frame => {
|
|
21
|
-
if (cursor === undefined) {
|
|
22
|
-
const history = readSessionSnapshot(frame, sessionId)
|
|
23
|
-
const baseline = frame.assistantStream
|
|
24
|
-
if (!baseline || !integer(baseline.revision)) throw new Error('missing assistant stream baseline')
|
|
25
|
-
if (baseline.activeAttempt !== undefined) {
|
|
26
|
-
const attempt = requireAttempt(baseline.activeAttempt)
|
|
27
|
-
if (!integer(attempt.nextIndex) || !Array.isArray(attempt.stream)
|
|
28
|
-
|| attempt.startedAfterSeq > history.cursor) throw new Error('invalid assistant baseline')
|
|
29
|
-
active = { ...attempt }
|
|
30
|
-
}
|
|
31
|
-
cursor = history.cursor
|
|
32
|
-
revision = baseline.revision
|
|
33
|
-
return { type: 'snapshot', history, assistantStream: baseline }
|
|
34
|
-
}
|
|
35
|
-
if (frame?.type === 'event') {
|
|
36
|
-
if (!frame.event || frame.event.seq !== cursor + 1) throw new Error('session event sequence gap')
|
|
37
|
-
cursor = frame.event.seq
|
|
38
|
-
return frame
|
|
39
|
-
}
|
|
40
|
-
if (frame?.type !== 'assistant-stream') throw new Error('unexpected session follow frame')
|
|
41
|
-
const next = frame.frame
|
|
42
|
-
if (!next || next.revision !== revision + 1) throw new Error('assistant stream revision gap')
|
|
43
|
-
if (next.type === 'start') {
|
|
44
|
-
requireAttempt(next)
|
|
45
|
-
if (active || next.startedAfterSeq > cursor) throw new Error('unexpected assistant start')
|
|
46
|
-
active = { ...next, nextIndex: 0 }
|
|
47
|
-
} else {
|
|
48
|
-
if (!active || next.attemptId !== active.attemptId || next.index !== active.nextIndex) {
|
|
49
|
-
throw new Error('assistant stream attempt/index gap')
|
|
50
|
-
}
|
|
51
|
-
if (next.type === 'chunk') {
|
|
52
|
-
if (!integer(next.time) || !next.chunk || typeof next.chunk.type !== 'string') {
|
|
53
|
-
throw new Error('invalid assistant chunk')
|
|
54
|
-
}
|
|
55
|
-
active.nextIndex += 1
|
|
56
|
-
} else if (next.type === 'end') {
|
|
57
|
-
const outcome = next.outcome
|
|
58
|
-
if (!outcome || (outcome.kind !== 'abandoned' && (outcome.kind !== 'committed'
|
|
59
|
-
|| !['assistant/message', 'assistant/attempt'].includes(outcome.eventType)
|
|
60
|
-
|| !integer(outcome.seq) || outcome.seq > cursor || outcome.seq <= active.startedAfterSeq))) {
|
|
61
|
-
throw new Error('invalid assistant settlement')
|
|
62
|
-
}
|
|
63
|
-
} else throw new Error('unexpected assistant frame')
|
|
64
|
-
}
|
|
65
|
-
revision = next.revision
|
|
66
|
-
const normalized = { ...next, turn: active.turn, step: active.step }
|
|
67
|
-
if (next.type === 'end') active = undefined
|
|
68
|
-
return { type: 'assistant-stream', frame: normalized }
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function delay(ms, signal) {
|
|
73
|
-
return new Promise(resolve => {
|
|
74
|
-
if (signal.aborted) return resolve()
|
|
75
|
-
const finish = () => {
|
|
76
|
-
clearTimeout(timer)
|
|
77
|
-
signal.removeEventListener('abort', finish)
|
|
78
|
-
resolve()
|
|
79
|
-
}
|
|
80
|
-
const timer = setTimeout(finish, ms)
|
|
81
|
-
signal.addEventListener('abort', finish, { once: true })
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// One follow per opted-in connection. Each opening owns a new stream identity
|
|
86
|
-
// and atomic snapshot, including a reconnecting Agent's active attempt.
|
|
87
|
-
export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 }) {
|
|
88
|
-
let current
|
|
89
|
-
const stop = () => {
|
|
90
|
-
const previous = current
|
|
91
|
-
current = undefined
|
|
92
|
-
previous?.abort.abort()
|
|
93
|
-
}
|
|
94
|
-
const start = (
|
|
95
|
-
stop()
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { readSessionSnapshot } from './dsh-host-adapter.mjs'
|
|
3
|
+
|
|
4
|
+
function integer(value, minimum = 0) {
|
|
5
|
+
return Number.isSafeInteger(value) && value >= minimum
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function requireAttempt(attempt) {
|
|
9
|
+
if (!attempt || typeof attempt.attemptId !== 'string' || !attempt.attemptId
|
|
10
|
+
|| !integer(attempt.turn) || !integer(attempt.step)
|
|
11
|
+
|| !integer(attempt.startedAfterSeq, -1)) throw new Error('invalid assistant attempt')
|
|
12
|
+
return attempt
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Validate the two independent clocks. No transient frame owns a Session seq.
|
|
16
|
+
export function createFollowDecoder(sessionId) {
|
|
17
|
+
let cursor
|
|
18
|
+
let revision
|
|
19
|
+
let active
|
|
20
|
+
return frame => {
|
|
21
|
+
if (cursor === undefined) {
|
|
22
|
+
const history = readSessionSnapshot(frame, sessionId)
|
|
23
|
+
const baseline = frame.assistantStream
|
|
24
|
+
if (!baseline || !integer(baseline.revision)) throw new Error('missing assistant stream baseline')
|
|
25
|
+
if (baseline.activeAttempt !== undefined) {
|
|
26
|
+
const attempt = requireAttempt(baseline.activeAttempt)
|
|
27
|
+
if (!integer(attempt.nextIndex) || !Array.isArray(attempt.stream)
|
|
28
|
+
|| attempt.startedAfterSeq > history.cursor) throw new Error('invalid assistant baseline')
|
|
29
|
+
active = { ...attempt }
|
|
30
|
+
}
|
|
31
|
+
cursor = history.cursor
|
|
32
|
+
revision = baseline.revision
|
|
33
|
+
return { type: 'snapshot', history, assistantStream: baseline }
|
|
34
|
+
}
|
|
35
|
+
if (frame?.type === 'event') {
|
|
36
|
+
if (!frame.event || frame.event.seq !== cursor + 1) throw new Error('session event sequence gap')
|
|
37
|
+
cursor = frame.event.seq
|
|
38
|
+
return frame
|
|
39
|
+
}
|
|
40
|
+
if (frame?.type !== 'assistant-stream') throw new Error('unexpected session follow frame')
|
|
41
|
+
const next = frame.frame
|
|
42
|
+
if (!next || next.revision !== revision + 1) throw new Error('assistant stream revision gap')
|
|
43
|
+
if (next.type === 'start') {
|
|
44
|
+
requireAttempt(next)
|
|
45
|
+
if (active || next.startedAfterSeq > cursor) throw new Error('unexpected assistant start')
|
|
46
|
+
active = { ...next, nextIndex: 0 }
|
|
47
|
+
} else {
|
|
48
|
+
if (!active || next.attemptId !== active.attemptId || next.index !== active.nextIndex) {
|
|
49
|
+
throw new Error('assistant stream attempt/index gap')
|
|
50
|
+
}
|
|
51
|
+
if (next.type === 'chunk') {
|
|
52
|
+
if (!integer(next.time) || !next.chunk || typeof next.chunk.type !== 'string') {
|
|
53
|
+
throw new Error('invalid assistant chunk')
|
|
54
|
+
}
|
|
55
|
+
active.nextIndex += 1
|
|
56
|
+
} else if (next.type === 'end') {
|
|
57
|
+
const outcome = next.outcome
|
|
58
|
+
if (!outcome || (outcome.kind !== 'abandoned' && (outcome.kind !== 'committed'
|
|
59
|
+
|| !['assistant/message', 'assistant/attempt'].includes(outcome.eventType)
|
|
60
|
+
|| !integer(outcome.seq) || outcome.seq > cursor || outcome.seq <= active.startedAfterSeq))) {
|
|
61
|
+
throw new Error('invalid assistant settlement')
|
|
62
|
+
}
|
|
63
|
+
} else throw new Error('unexpected assistant frame')
|
|
64
|
+
}
|
|
65
|
+
revision = next.revision
|
|
66
|
+
const normalized = { ...next, turn: active.turn, step: active.step }
|
|
67
|
+
if (next.type === 'end') active = undefined
|
|
68
|
+
return { type: 'assistant-stream', frame: normalized }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function delay(ms, signal) {
|
|
73
|
+
return new Promise(resolve => {
|
|
74
|
+
if (signal.aborted) return resolve()
|
|
75
|
+
const finish = () => {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
signal.removeEventListener('abort', finish)
|
|
78
|
+
resolve()
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(finish, ms)
|
|
81
|
+
signal.addEventListener('abort', finish, { once: true })
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One follow per opted-in connection. Each opening owns a new stream identity
|
|
86
|
+
// and atomic snapshot, including a reconnecting Agent's active attempt.
|
|
87
|
+
export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 }) {
|
|
88
|
+
let current
|
|
89
|
+
const stop = () => {
|
|
90
|
+
const previous = current
|
|
91
|
+
current = undefined
|
|
92
|
+
previous?.abort.abort()
|
|
93
|
+
}
|
|
94
|
+
const start = (targetValue, subscriptionId, afterSeq) => {
|
|
95
|
+
stop()
|
|
96
|
+
const target = typeof targetValue === 'string' ? { sessionId: targetValue } : targetValue
|
|
97
|
+
const sessionId = target?.sessionId
|
|
98
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new Error('session follower requires a sessionId')
|
|
99
|
+
const state = { abort: new AbortController() }
|
|
100
|
+
current = state
|
|
101
|
+
const task = (async () => {
|
|
102
|
+
let failures = 0
|
|
103
|
+
while (current === state && !state.abort.signal.aborted) {
|
|
104
|
+
const attemptAbort = new AbortController()
|
|
105
|
+
const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
|
|
106
|
+
const streamId = crypto.randomUUID()
|
|
107
|
+
const context = { ...target, sessionId, subscriptionId, streamId,
|
|
108
|
+
...(Number.isSafeInteger(afterSeq) && afterSeq >= 0 ? { afterSeq } : {}) }
|
|
109
|
+
const decode = createFollowDecoder(sessionId)
|
|
110
|
+
let iterator
|
|
111
|
+
let permanent = false
|
|
112
|
+
try {
|
|
113
|
+
const stream = await api.openSessionStream(target.parentSessionId ? target : sessionId, signal)
|
|
114
|
+
iterator = stream[Symbol.asyncIterator]()
|
|
115
|
+
while (current === state && !signal.aborted) {
|
|
116
|
+
const item = await iterator.next()
|
|
117
|
+
if (current !== state || signal.aborted) break
|
|
118
|
+
if (item.done) throw new Error('session follow ended')
|
|
119
|
+
const decoded = decode(item.value)
|
|
120
|
+
onFrame(decoded, context)
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (current !== state || signal.aborted) break
|
|
124
|
+
permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
|
|
125
|
+
|| (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
|
|
126
|
+
&& /refuses this format .*Session/.test(error.message || ''))
|
|
127
|
+
onError(error, { ...context, retrying: !permanent })
|
|
128
|
+
} finally {
|
|
129
|
+
// Abort before return: the producer may be waiting for another event.
|
|
130
|
+
attemptAbort.abort()
|
|
131
|
+
try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
|
|
132
|
+
}
|
|
133
|
+
if (permanent) break
|
|
134
|
+
await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
|
|
135
|
+
}
|
|
136
|
+
})()
|
|
137
|
+
return task
|
|
138
|
+
}
|
|
139
|
+
return { start, stop }
|
|
140
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
2
|
+
maxSessions: 32,
|
|
3
|
+
maxEventsPerSession: 2048,
|
|
4
|
+
maxBytesPerSession: 512 * 1024,
|
|
5
|
+
maxReplayEvents: 256,
|
|
6
|
+
maxReplayBytes: 256 * 1024,
|
|
7
|
+
maxAgeMs: 60 * 60 * 1000,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function byteLength(value) {
|
|
11
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Keep only bounded wire-safe events. Recovery succeeds only when the entire
|
|
15
|
+
// sequence interval is present; otherwise callers must trust a fresh snapshot.
|
|
16
|
+
export function createSessionReplay(options = {}) {
|
|
17
|
+
const limits = { ...DEFAULT_LIMITS, ...options }
|
|
18
|
+
const sessions = new Map()
|
|
19
|
+
|
|
20
|
+
function prune(now = Date.now()) {
|
|
21
|
+
for (const [sessionId, entry] of sessions) {
|
|
22
|
+
if (now - entry.touchedAt > limits.maxAgeMs) sessions.delete(sessionId)
|
|
23
|
+
}
|
|
24
|
+
while (sessions.size > limits.maxSessions) sessions.delete(sessions.keys().next().value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function append(sessionId, frame) {
|
|
28
|
+
if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(frame?.seq) || frame.seq < 0) return false
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
prune(now)
|
|
31
|
+
let entry = sessions.get(sessionId)
|
|
32
|
+
if (!entry) entry = { events: [], bytes: 0, touchedAt: now }
|
|
33
|
+
const copy = { ...frame }
|
|
34
|
+
const bytes = byteLength(copy)
|
|
35
|
+
if (bytes > limits.maxBytesPerSession) return false
|
|
36
|
+
const last = entry.events[entry.events.length - 1]
|
|
37
|
+
if (!last || frame.seq > last.seq) {
|
|
38
|
+
entry.events.push({ seq: frame.seq, frame: copy, bytes })
|
|
39
|
+
} else {
|
|
40
|
+
const existing = entry.events.findIndex(item => item.seq === frame.seq)
|
|
41
|
+
if (existing >= 0) {
|
|
42
|
+
entry.bytes -= entry.events[existing].bytes
|
|
43
|
+
entry.events.splice(existing, 1)
|
|
44
|
+
}
|
|
45
|
+
let low = 0, high = entry.events.length
|
|
46
|
+
while (low < high) {
|
|
47
|
+
const middle = (low + high) >> 1
|
|
48
|
+
if (entry.events[middle].seq < frame.seq) low = middle + 1
|
|
49
|
+
else high = middle
|
|
50
|
+
}
|
|
51
|
+
entry.events.splice(low, 0, { seq: frame.seq, frame: copy, bytes })
|
|
52
|
+
}
|
|
53
|
+
entry.bytes += bytes
|
|
54
|
+
entry.touchedAt = now
|
|
55
|
+
while (entry.events.length > limits.maxEventsPerSession || entry.bytes > limits.maxBytesPerSession) {
|
|
56
|
+
const removed = entry.events.shift()
|
|
57
|
+
entry.bytes -= removed.bytes
|
|
58
|
+
}
|
|
59
|
+
sessions.delete(sessionId)
|
|
60
|
+
sessions.set(sessionId, entry)
|
|
61
|
+
prune(now)
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function recover(sessionId, afterSeq, throughSeq) {
|
|
66
|
+
const invalid = { status: 'snapshot', events: [] }
|
|
67
|
+
if (!Number.isSafeInteger(afterSeq) || afterSeq < 0 || !Number.isSafeInteger(throughSeq) || throughSeq < afterSeq) return invalid
|
|
68
|
+
if (afterSeq === throughSeq) return { status: 'replayed', events: [], fromSeq: afterSeq, toSeq: throughSeq }
|
|
69
|
+
if (throughSeq - afterSeq > limits.maxReplayEvents) return invalid
|
|
70
|
+
prune()
|
|
71
|
+
const entry = sessions.get(sessionId)
|
|
72
|
+
if (!entry) return invalid
|
|
73
|
+
const picked = entry.events.filter(item => item.seq > afterSeq && item.seq <= throughSeq)
|
|
74
|
+
if (picked.length !== throughSeq - afterSeq) return invalid
|
|
75
|
+
for (let i = 0; i < picked.length; i += 1) {
|
|
76
|
+
if (picked[i].seq !== afterSeq + i + 1) return invalid
|
|
77
|
+
}
|
|
78
|
+
const events = picked.map(item => item.frame)
|
|
79
|
+
if (byteLength(events) > limits.maxReplayBytes) return invalid
|
|
80
|
+
entry.touchedAt = Date.now()
|
|
81
|
+
return { status: 'replayed', events, fromSeq: afterSeq, toSeq: throughSeq }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { append, recover }
|
|
85
|
+
}
|
|
86
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 移动端保留 sessionId 为当前视图 ID,父子关系只作为显式路由地址。
|
|
2
|
+
export function parseSessionTarget(msg, { optional = false } = {}) {
|
|
3
|
+
const fail = message => ({ error: { kind: 'error', code: 'bad-request', requestType: msg.type, message,
|
|
4
|
+
...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}) } })
|
|
5
|
+
const sessionId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''
|
|
6
|
+
const hasChildAddress = msg.parentSessionId !== undefined || msg.subagentMode !== undefined
|
|
7
|
+
if (!sessionId && (!optional || hasChildAddress)) return fail('sessionId must be a non-empty string')
|
|
8
|
+
if (!hasChildAddress) return { value: sessionId ? { sessionId } : {} }
|
|
9
|
+
const parentSessionId = typeof msg.parentSessionId === 'string' ? msg.parentSessionId.trim() : ''
|
|
10
|
+
if (!parentSessionId || parentSessionId === sessionId) return fail('parentSessionId must identify a different direct parent')
|
|
11
|
+
if (!['continuable', 'one-shot', 'unknown'].includes(msg.subagentMode)) {
|
|
12
|
+
return fail('subagentMode must be continuable, one-shot, or unknown')
|
|
13
|
+
}
|
|
14
|
+
return { value: { sessionId, parentSessionId, subagentMode: msg.subagentMode } }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionAddress(target) {
|
|
18
|
+
return target.parentSessionId === undefined
|
|
19
|
+
? { kind: 'session', sessionId: target.sessionId }
|
|
20
|
+
: { kind: 'subagent', parentSessionId: target.parentSessionId,
|
|
21
|
+
childSessionId: target.sessionId, mode: target.subagentMode }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const TEAM_PROJECTION_KINDS = {
|
|
25
|
+
agentTeam: 'agent-team-updated',
|
|
26
|
+
subagentCatalog: 'subagents-updated',
|
|
27
|
+
subagent: 'subagent-identity-updated',
|
|
28
|
+
subagentTiming: 'subagent-timing-updated',
|
|
29
|
+
}
|