dsh-plugin-mobile-link 0.6.0 → 0.6.5
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.md +8 -1
- package/cordis.patch.yml +12 -0
- package/lib/diagnostics.js +172 -0
- package/lib/hello.js +4 -0
- package/lib/history/filter-scan.js +145 -0
- package/lib/history/records.js +28 -0
- package/lib/history/session-snapshot.js +119 -0
- package/lib/history.js +62 -16
- package/lib/index.js +16 -0
- package/lib/journal.js +279 -0
- package/lib/link.js +91 -4
- package/lib/router.js +215 -2
- package/lib/routes.js +0 -32
- package/lib/session-digest.js +117 -0
- package/lib/status.js +28 -2
- package/package.json +1 -1
- package/lib/history/probe.js +0 -195
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionDigest`:`session/list` 的精简同形副本(spec 021)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它:`session/list` 占经中转下行字节的 **96%**,一次固定 714 KB,而中转出口实测只有
|
|
5
|
+
* 16–112 KB/s(2026-10-13 现场诊断)。其中 **58.8% 的字节是 App/DSHKit 从来不读的字段**
|
|
6
|
+
* (逐字段扫 79 个源文件核对,清单见 `contracts/session-digest.md` §4)。
|
|
7
|
+
*
|
|
8
|
+
* 三条硬约束:
|
|
9
|
+
* 1. **不动 `session/list`**:老 App 只认它,字段删掉就没有回退路径。
|
|
10
|
+
* 2. **不改入参**:宿主返回什么就基于它生成新对象,方便"同一份数据两条路径"逐字节对比。
|
|
11
|
+
* 3. **只删名单里的**:名单之外的字段(包括以后宿主新增的)一律原样透传,避免"裁剪"变成"猜"。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { deflateRawSync } from 'node:zlib'
|
|
15
|
+
|
|
16
|
+
/** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
|
|
17
|
+
export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
|
|
18
|
+
|
|
19
|
+
/** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
|
|
20
|
+
export const DROPPED_ITEM_KEYS = Object.freeze(['agentAvailable'])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 投影键里剔掉的字段(合计 56.7%)。
|
|
24
|
+
*
|
|
25
|
+
* 每一条都能在 App/DSHKit 里复查"零读取点":
|
|
26
|
+
* `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
|
|
27
|
+
* `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
|
|
28
|
+
* `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
|
|
29
|
+
* `subagentTiming` 1.5%、`agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
|
|
30
|
+
*/
|
|
31
|
+
export const DROPPED_PROJECTION_KEYS = Object.freeze([
|
|
32
|
+
'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
|
|
33
|
+
'contextBreakdown', 'sessionListMetadata', 'subagentTiming', 'agentPreset',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 只接受的压缩编码;其它一律按不压缩处理(保守,不因为多写一个字段就让调用方失败)。
|
|
38
|
+
*
|
|
39
|
+
* `deflate-raw` = RFC1951 原始 DEFLATE,**不带** gzip/zlib 包装。选它而不是 gzip 的原因是
|
|
40
|
+
* 手机侧:Apple 的 `Compression` 框架(`COMPRESSION_ZLIB`)一行 `compression_decode_buffer`
|
|
41
|
+
* 就能解开原始 DEFLATE,而 gzip 包装它不认,得引 libz 的 `inflateInit2(MAX_WBITS+16)` 做 C 互操作。
|
|
42
|
+
* 两者压缩比实测相同(239 KiB → 36.8 KiB)。
|
|
43
|
+
*/
|
|
44
|
+
export const DEFLATE_ENCODING = 'deflate-raw'
|
|
45
|
+
export const IDENTITY_ENCODING = 'identity'
|
|
46
|
+
|
|
47
|
+
/** 按原键序复制并跳过剔除名单:键序稳定才能做"逐字节对比"。 */
|
|
48
|
+
function omit(source, dropped) {
|
|
49
|
+
const out = {}
|
|
50
|
+
for (const key of Object.keys(source)) {
|
|
51
|
+
if (dropped.has(key)) continue
|
|
52
|
+
out[key] = source[key]
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 裁剪一份会话列表的**值**(`{ items: [...] }`)。
|
|
59
|
+
*
|
|
60
|
+
* 认两种输入(与 `trimHistoryPageForWire` 同款):宿主信封 `{ ok, value: { items } }`,
|
|
61
|
+
* 以及直接给的 `{ items }`。**返回的始终是列表值本身**,调用方不需要再猜信封在哪一层
|
|
62
|
+
* (曾经因为这里保留信封、外面又包一层,响应成了 `{encoding, value:{ok, value:{items}}}`)。
|
|
63
|
+
*
|
|
64
|
+
* 结构不是预期的样子就**原样返回入参**——裁剪不许把"看不懂"变成"删掉"。
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} result 宿主响应或其 `value`
|
|
67
|
+
* @returns {unknown} 裁剪后的列表值;不合法时原样返回入参
|
|
68
|
+
*/
|
|
69
|
+
export function digestSessionList(result) {
|
|
70
|
+
if (result === null || typeof result !== 'object') return result
|
|
71
|
+
const container = Array.isArray(result.items) ? result : result.value
|
|
72
|
+
if (container === null || typeof container !== 'object' || !Array.isArray(container.items)) {
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
return { ...container, items: container.items.map(digestItem) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 单条会话:剔顶层字段与投影键;`projections` 缺失或形状不对就原样留着。 */
|
|
79
|
+
function digestItem(item) {
|
|
80
|
+
if (item === null || typeof item !== 'object') return item
|
|
81
|
+
const out = omit(item, new Set(DROPPED_ITEM_KEYS))
|
|
82
|
+
const projections = item.projections
|
|
83
|
+
if (projections === null || typeof projections !== 'object') return out
|
|
84
|
+
// `kind: "sequenced"` 是信封常量,App 侧模型没解码它(1.3%)。
|
|
85
|
+
const envelope = omit(projections, new Set(['kind']))
|
|
86
|
+
const values = projections.values
|
|
87
|
+
if (values !== null && typeof values === 'object') {
|
|
88
|
+
envelope.values = omit(values, new Set(DROPPED_PROJECTION_KEYS))
|
|
89
|
+
}
|
|
90
|
+
out.projections = envelope
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 按调用方要求编码裁剪后的值。
|
|
96
|
+
*
|
|
97
|
+
* **只在应用层压**:中转的 `WebSocketResponse(compress=False)`,手机侧
|
|
98
|
+
* `URLSessionWebSocketTask` 也不支持 permessage-deflate,所以 WS 层压缩救不了手机那一跳。
|
|
99
|
+
* 压缩后的字节 base64 放进 JSON 文本帧(与 `_link/diagnostics` 同款),中转不需要任何改动。
|
|
100
|
+
*
|
|
101
|
+
* `rawBytes` 是**解压后的字节长度**:Apple 的 `compression_decode_buffer` 必须预先给足输出缓冲区,
|
|
102
|
+
* 带上它调用方就能一次分配到位,不必"猜容量、失败再翻倍重试"。
|
|
103
|
+
*
|
|
104
|
+
* @param {unknown} value 裁剪后的值
|
|
105
|
+
* @param {unknown} encoding 调用方要求;只认 `"deflate-raw"`
|
|
106
|
+
* @returns {{encoding: string, value: unknown, rawBytes?: number}} 带回执,调用方据此决定解不解压
|
|
107
|
+
*/
|
|
108
|
+
export function encodeDigest(value, encoding) {
|
|
109
|
+
if (encoding !== DEFLATE_ENCODING) return { encoding: IDENTITY_ENCODING, value }
|
|
110
|
+
const json = JSON.stringify(value ?? null)
|
|
111
|
+
const raw = Buffer.from(json, 'utf8')
|
|
112
|
+
return {
|
|
113
|
+
encoding: DEFLATE_ENCODING,
|
|
114
|
+
value: deflateRawSync(raw).toString('base64'),
|
|
115
|
+
rawBytes: raw.length,
|
|
116
|
+
}
|
|
117
|
+
}
|
package/lib/status.js
CHANGED
|
@@ -33,8 +33,7 @@ export const HOST_HINTS = {
|
|
|
33
33
|
unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', docAnchor: '排查:连不上时看这里' },
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */
|
|
37
|
-
function dshBlock(agent) {
|
|
36
|
+
/** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */function dshBlock(agent) {
|
|
38
37
|
const errorKind = agent.dshError ? (agent.dsh?.errorKind ?? 'unreachable') : null
|
|
39
38
|
const entry = errorKind ? DSH_HINTS[errorKind] : undefined
|
|
40
39
|
return {
|
|
@@ -51,6 +50,31 @@ function dshBlock(agent) {
|
|
|
51
50
|
}
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 生命周期流水的读法(只增字段)。
|
|
55
|
+
*
|
|
56
|
+
* 为什么状态接口要给这一块:`lastError` 只有最后一条,而"手机为什么连不上"往往要
|
|
57
|
+
* 看**之前**发生过什么(拆了几次链、每次什么原因、设备是不是同时开了两条)。
|
|
58
|
+
*
|
|
59
|
+
* 读法要说清楚:`recent` / `recorded` / `dropped` 都是**当前这个实例**的(热更会换实例,
|
|
60
|
+
* 换完这几个数从零开始);跨实例的完整历史只在 `path` 指的 JSONL 文件里——两者互补,
|
|
61
|
+
* 别拿 `recorded` 当"这台机器一共连过几次"。没有流水对象(老调用方、单测的假 agent)
|
|
62
|
+
* 时给空块,不是 `undefined`——读者不用做判空。
|
|
63
|
+
*/
|
|
64
|
+
function journalBlock(agent) {
|
|
65
|
+
const journal = agent.journal
|
|
66
|
+
return {
|
|
67
|
+
path: journal?.path ?? null,
|
|
68
|
+
recorded: journal?.recorded ?? 0,
|
|
69
|
+
dropped: journal?.dropped ?? 0,
|
|
70
|
+
fileErrors: journal?.fileErrors ?? 0,
|
|
71
|
+
recent: journal?.recent?.(STATUS_JOURNAL_ENTRIES) ?? [],
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `/status` 里随每次轮询带出的流水条数。完整历史读 `journal.path` 那个文件。 */
|
|
76
|
+
const STATUS_JOURNAL_ENTRIES = 20
|
|
77
|
+
|
|
54
78
|
/**
|
|
55
79
|
* @param {import('./link.js').MobileLinkAgent} agent
|
|
56
80
|
* @returns {Record<string, unknown>} 可直接序列化给 `/status` 的对象
|
|
@@ -97,5 +121,7 @@ export function statusSnapshot(agent) {
|
|
|
97
121
|
startedAt: agent.startedAt ?? null,
|
|
98
122
|
connectedAt: agent.connectedAt ?? null,
|
|
99
123
|
reconnectAttempts: agent.reconnectAttempts,
|
|
124
|
+
// 新增(只增不改):生命周期流水的摘要与最近若干条,见上面的 journalBlock。
|
|
125
|
+
journal: journalBlock(agent),
|
|
100
126
|
}
|
|
101
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-link",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
package/lib/history/probe.js
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './shared.js'
|
|
2
|
-
import { bytesOf, summarizeEvents } from './records.js'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* 只读测量(spec 007 T008/T047):`GET /mobile-link/history-probe` 用,锁定映射之后删。
|
|
6
|
-
*
|
|
7
|
-
* 它只读、不改任何状态;`getQuery` 是个取值函数而不是服务对象本身,因为宿主注入
|
|
8
|
-
* (`ctx.inject(['sessionQuery'])`)可能在服务建好之后才发生。
|
|
9
|
-
*/
|
|
10
|
-
export function createProbeRunner({ getQuery, logger }) {
|
|
11
|
-
return async function probe(args = {}) {
|
|
12
|
-
const sessionId = args.sessionId
|
|
13
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
14
|
-
return { ok: false, error: 'sessionId is required' }
|
|
15
|
-
}
|
|
16
|
-
if (getQuery() === undefined) {
|
|
17
|
-
return { ok: false, error: 'ctx.getQuery() is not mounted in this Host' }
|
|
18
|
-
}
|
|
19
|
-
const limit = Number.isFinite(Number(args.n)) && Number(args.n) > 0 ? Number(args.n) : 60
|
|
20
|
-
const out = { ok: true, sessionId, limit }
|
|
21
|
-
|
|
22
|
-
// 1. Whole log: how many events, and is log position == seq?
|
|
23
|
-
const t0 = Date.now()
|
|
24
|
-
try {
|
|
25
|
-
const records = await withTimeout(getQuery().listEvents(sessionId), HOST_CALL_TIMEOUT_MS, 'listEvents')
|
|
26
|
-
const mismatches = []
|
|
27
|
-
records.forEach((record, index) => {
|
|
28
|
-
if (record.seq !== index && mismatches.length < 5) mismatches.push({ index, seq: record.seq })
|
|
29
|
-
})
|
|
30
|
-
out.listEvents = {
|
|
31
|
-
...summarizeEvents(records),
|
|
32
|
-
ms: Date.now() - t0,
|
|
33
|
-
offsetEqualsSeq: mismatches.length === 0,
|
|
34
|
-
firstMismatches: mismatches,
|
|
35
|
-
}
|
|
36
|
-
} catch (error) {
|
|
37
|
-
out.listEvents = { ms: Date.now() - t0, error: messageOf(error) }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 2. Exact window read around a seq (`readEvent`): raw events, contiguous.
|
|
41
|
-
const targetSeq = Number.isFinite(Number(args.seq)) ? Number(args.seq) : out.listEvents?.lastSeq
|
|
42
|
-
if (Number.isInteger(targetSeq) && targetSeq >= 0) {
|
|
43
|
-
const t1 = Date.now()
|
|
44
|
-
try {
|
|
45
|
-
const window = await withTimeout(
|
|
46
|
-
getQuery().readEvent({ sessionId, seq: targetSeq, before: limit, after: 0 }),
|
|
47
|
-
HOST_CALL_TIMEOUT_MS,
|
|
48
|
-
'readEvent',
|
|
49
|
-
)
|
|
50
|
-
out.readEvent = {
|
|
51
|
-
requested: { seq: targetSeq, before: limit, after: 0 },
|
|
52
|
-
...summarizeEvents(window.events),
|
|
53
|
-
startSeq: window.startSeq ?? null,
|
|
54
|
-
endSeq: window.endSeq ?? null,
|
|
55
|
-
rawEventCount: window.events?.length ?? 0,
|
|
56
|
-
bytes: bytesOf(window.events),
|
|
57
|
-
ms: Date.now() - t1,
|
|
58
|
-
}
|
|
59
|
-
} catch (error) {
|
|
60
|
-
out.readEvent = { requested: { seq: targetSeq, before: limit }, ms: Date.now() - t1, error: messageOf(error) }
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 3. Seq-range filter (`filterEvents`): message-aligned semantic documents.
|
|
65
|
-
if (Number.isInteger(targetSeq) && targetSeq >= 0) {
|
|
66
|
-
const from = Math.max(0, targetSeq - limit + 1)
|
|
67
|
-
const t2 = Date.now()
|
|
68
|
-
try {
|
|
69
|
-
const docs = await withTimeout(
|
|
70
|
-
getQuery().filterEvents(sessionId, [{ kind: 'seq', from, to: targetSeq }]),
|
|
71
|
-
HOST_CALL_TIMEOUT_MS,
|
|
72
|
-
'filterEvents(seq)',
|
|
73
|
-
)
|
|
74
|
-
out.filterEventsSeq = {
|
|
75
|
-
requested: { from, to: targetSeq },
|
|
76
|
-
...summarizeEvents(docs),
|
|
77
|
-
bytes: bytesOf(docs),
|
|
78
|
-
sample: docs.slice(0, 2).map((doc) => ({ seq: doc.seq, type: doc.type, textLen: doc.text?.length ?? 0 })),
|
|
79
|
-
ms: Date.now() - t2,
|
|
80
|
-
}
|
|
81
|
-
} catch (error) {
|
|
82
|
-
out.filterEventsSeq = { requested: { from, to: targetSeq }, ms: Date.now() - t2, error: messageOf(error) }
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 4. Literal text scan (`filterEvents(text)`): the fallback for search when
|
|
87
|
-
// the full-text index is closed (base bundle ships `openAt: never`).
|
|
88
|
-
if (typeof args.text === 'string' && args.text.length > 0) {
|
|
89
|
-
const t3 = Date.now()
|
|
90
|
-
try {
|
|
91
|
-
const docs = await withTimeout(
|
|
92
|
-
getQuery().filterEvents(sessionId, [{ kind: 'text', text: args.text }]),
|
|
93
|
-
HOST_CALL_TIMEOUT_MS,
|
|
94
|
-
'filterEvents(text)',
|
|
95
|
-
)
|
|
96
|
-
out.filterEventsText = {
|
|
97
|
-
query: args.text,
|
|
98
|
-
...summarizeEvents(docs),
|
|
99
|
-
sample: docs.slice(0, 3).map((doc) => ({
|
|
100
|
-
seq: doc.seq,
|
|
101
|
-
type: doc.type,
|
|
102
|
-
textLen: doc.text?.length ?? 0,
|
|
103
|
-
head: (doc.text ?? '').slice(0, 60),
|
|
104
|
-
})),
|
|
105
|
-
ms: Date.now() - t3,
|
|
106
|
-
}
|
|
107
|
-
} catch (error) {
|
|
108
|
-
out.filterEventsText = { query: args.text, ms: Date.now() - t3, error: messageOf(error) }
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 5. Indexed search (`searchEvents`): expected to fail while the SQLite
|
|
113
|
-
// index is closed, which is why step 4 must be able to carry search.
|
|
114
|
-
if (typeof args.text === 'string' && args.text.length > 0) {
|
|
115
|
-
const t4 = Date.now()
|
|
116
|
-
try {
|
|
117
|
-
const page = await withTimeout(
|
|
118
|
-
getQuery().searchEvents({ sessionId, query: args.text, limit: 20 }),
|
|
119
|
-
HOST_CALL_TIMEOUT_MS,
|
|
120
|
-
'searchEvents',
|
|
121
|
-
)
|
|
122
|
-
out.searchEvents = {
|
|
123
|
-
query: args.text,
|
|
124
|
-
count: page.items?.length ?? 0,
|
|
125
|
-
hasMore: page.hasMore ?? null,
|
|
126
|
-
first: page.items?.[0] ? { seq: page.items[0].seq, type: page.items[0].type } : null,
|
|
127
|
-
ms: Date.now() - t4,
|
|
128
|
-
}
|
|
129
|
-
} catch (error) {
|
|
130
|
-
out.searchEvents = { query: args.text, ms: Date.now() - t4, error: messageOf(error) }
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// 7. Chunked literal scan: `filterEvents(text)` returns every match with its
|
|
135
|
-
// full text, so the connector must bound each call by a seq range and
|
|
136
|
-
// stop at the requested hit count. This measures that shape.
|
|
137
|
-
if (typeof args.text === 'string' && args.text.length > 0 && Number.isInteger(out.listEvents?.lastSeq ?? null)) {
|
|
138
|
-
const chunk = Number.isFinite(Number(args.chunk)) && Number(args.chunk) > 0 ? Number(args.chunk) : 2000
|
|
139
|
-
const wanted = Number.isFinite(Number(args.hits)) && Number(args.hits) > 0 ? Number(args.hits) : 50
|
|
140
|
-
const lastSeq = out.listEvents.lastSeq
|
|
141
|
-
const t6 = Date.now()
|
|
142
|
-
const hits = []
|
|
143
|
-
let calls = 0
|
|
144
|
-
let to = lastSeq
|
|
145
|
-
try {
|
|
146
|
-
while (to >= 0 && hits.length < wanted && calls < 50) {
|
|
147
|
-
const from = Math.max(0, to - chunk + 1)
|
|
148
|
-
const docs = await withTimeout(
|
|
149
|
-
getQuery().filterEvents(sessionId, [
|
|
150
|
-
{ kind: 'seq', from, to },
|
|
151
|
-
{ kind: 'text', text: args.text },
|
|
152
|
-
]),
|
|
153
|
-
HOST_CALL_TIMEOUT_MS,
|
|
154
|
-
`filterEvents(chunk ${from}-${to})`,
|
|
155
|
-
)
|
|
156
|
-
calls++
|
|
157
|
-
for (const doc of docs) {
|
|
158
|
-
if (hits.length >= wanted) break
|
|
159
|
-
hits.push({ seq: doc.seq, type: doc.type, textLen: doc.text?.length ?? 0 })
|
|
160
|
-
}
|
|
161
|
-
to = from - 1
|
|
162
|
-
}
|
|
163
|
-
out.chunkedText = {
|
|
164
|
-
query: args.text,
|
|
165
|
-
chunk,
|
|
166
|
-
calls,
|
|
167
|
-
hits: hits.length,
|
|
168
|
-
firstSeq: hits[0]?.seq ?? null,
|
|
169
|
-
lastSeq: hits[hits.length - 1]?.seq ?? null,
|
|
170
|
-
scannedFrom: to + 1,
|
|
171
|
-
ms: Date.now() - t6,
|
|
172
|
-
}
|
|
173
|
-
} catch (error) {
|
|
174
|
-
out.chunkedText = { query: args.text, calls, hits: hits.length, ms: Date.now() - t6, error: messageOf(error) }
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// 6. Cold read: no Agent activation. `readSession` returns the log snapshot.
|
|
179
|
-
const t5 = Date.now()
|
|
180
|
-
try {
|
|
181
|
-
const snapshot = await withTimeout(getQuery().readSession(sessionId), HOST_CALL_TIMEOUT_MS, 'readSession')
|
|
182
|
-
out.readSession = {
|
|
183
|
-
events: snapshot.events?.length ?? null,
|
|
184
|
-
startSeq: snapshot.startSeq ?? null,
|
|
185
|
-
endSeq: snapshot.endSeq ?? null,
|
|
186
|
-
headerKeys: snapshot.session ? Object.keys(snapshot.session).slice(0, 8) : [],
|
|
187
|
-
ms: Date.now() - t5,
|
|
188
|
-
}
|
|
189
|
-
} catch (error) {
|
|
190
|
-
out.readSession = { ms: Date.now() - t5, error: messageOf(error) }
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return out
|
|
194
|
-
}
|
|
195
|
-
}
|