dsh-plugin-mobile-gateway 0.7.2 → 0.7.4
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/PROTOCOL.md +38 -14
- package/README.md +43 -5
- package/cordis.patch.yml +8 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-rc2-mobile-integration.md +151 -0
- package/docs/multi-gateway-app-integration.md +124 -0
- package/docs/multi-gateway-phase1-acceptance.md +179 -0
- package/docs/multi-gateway-todo.md +137 -0
- package/docs/runtime-architecture.architecture.json +264 -0
- package/docs/runtime-architecture.html +15001 -0
- package/docs/runtime-architecture.visual-check.html +32 -0
- package/docs/runtime-architecture.visual-check.json +548 -0
- package/docs/typert-remote-gateway-feature-checklist.md +19 -21
- package/lib/client.js +30 -21
- package/lib/dsh-host-adapter.mjs +54 -81
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +223 -72
- package/lib/session-follower.mjs +134 -0
- package/package.json +3 -3
|
@@ -0,0 +1,134 @@
|
|
|
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 = (sessionId, subscriptionId) => {
|
|
95
|
+
stop()
|
|
96
|
+
const state = { abort: new AbortController() }
|
|
97
|
+
current = state
|
|
98
|
+
const task = (async () => {
|
|
99
|
+
let failures = 0
|
|
100
|
+
while (current === state && !state.abort.signal.aborted) {
|
|
101
|
+
const attemptAbort = new AbortController()
|
|
102
|
+
const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
|
|
103
|
+
const streamId = crypto.randomUUID()
|
|
104
|
+
const context = { sessionId, subscriptionId, streamId }
|
|
105
|
+
const decode = createFollowDecoder(sessionId)
|
|
106
|
+
let iterator
|
|
107
|
+
let permanent = false
|
|
108
|
+
try {
|
|
109
|
+
const stream = await api.openSessionStream(sessionId, signal)
|
|
110
|
+
iterator = stream[Symbol.asyncIterator]()
|
|
111
|
+
while (current === state && !signal.aborted) {
|
|
112
|
+
const item = await iterator.next()
|
|
113
|
+
if (current !== state || signal.aborted) break
|
|
114
|
+
if (item.done) throw new Error('session follow ended')
|
|
115
|
+
const decoded = decode(item.value)
|
|
116
|
+
onFrame(decoded, context)
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (current !== state || signal.aborted) break
|
|
120
|
+
permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
|
|
121
|
+
onError(error, { ...context, retrying: !permanent })
|
|
122
|
+
} finally {
|
|
123
|
+
// Abort before return: the producer may be waiting for another event.
|
|
124
|
+
attemptAbort.abort()
|
|
125
|
+
try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
|
|
126
|
+
}
|
|
127
|
+
if (permanent) break
|
|
128
|
+
await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
|
|
129
|
+
}
|
|
130
|
+
})()
|
|
131
|
+
return task
|
|
132
|
+
}
|
|
133
|
+
return { start, stop }
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.4",
|
|
4
|
+
"description": "Updated for dsh 0.1.5-rc.1/2 with dedicated realtime streams, reconnect recovery, history format and cursor validation, and fixes for command attachments and session state sync",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
|
|
29
|
+
"test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|