@luziyang2026/dsh-question-nav 0.3.0 → 0.4.1
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 +17 -25
- package/README.md +16 -11
- package/README.zh.md +15 -9
- package/lib/client.js +176 -340
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -3
- package/lib/types/client/QuestionNavStrip.d.ts +9 -3
- package/lib/types/client/index.d.ts +9 -8
- package/lib/types/client/locales.d.ts +0 -6
- package/lib/types/core/nodes.d.ts +0 -7
- package/lib/types/core/question-entry.d.ts +17 -0
- package/lib/types/core/turn-dots.d.ts +48 -0
- package/lib/types/index.d.ts +17 -6
- package/lib/types/projection.d.ts +38 -0
- package/package.json +10 -8
- package/src/client/QuestionNavStrip.tsx +76 -118
- package/src/client/index.ts +20 -46
- package/src/client/locales.ts +0 -6
- package/src/client/question-nav.module.css +15 -24
- package/src/core/nodes.ts +0 -14
- package/src/core/question-entry.ts +17 -0
- package/src/core/turn-dots.ts +98 -0
- package/src/index.ts +23 -6
- package/src/projection.ts +98 -0
- package/lib/types/core/history-index.d.ts +0 -99
- package/lib/types/core/load-all.d.ts +0 -53
- package/src/core/history-index.ts +0 -176
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `questionIndex` session projection unit: a pure fold of the session
|
|
3
|
+
* event log into the ordered list of user questions, each tagged with the
|
|
4
|
+
* turn that claimed it. Registered on `ctx.sessionProjections` by the host
|
|
5
|
+
* half (src/index.ts); persistence, replay, and client delivery are the
|
|
6
|
+
* projection seam's (session-projection-cache checkpoints the state, the
|
|
7
|
+
* api-proxy carriers seed + push the wire view).
|
|
8
|
+
*
|
|
9
|
+
* Fold rules mirror the chat messageDefinition classification: an
|
|
10
|
+
* append-origin `user/message` with a human (`user`) source is a question;
|
|
11
|
+
* replacement copies (compaction checkpoints) and injected context are not.
|
|
12
|
+
* Turns come from `turn/start` boundaries, so retry/goal-continuation turns
|
|
13
|
+
* without a question simply produce no entry — dots may skip turn numbers,
|
|
14
|
+
* staying exactly aligned with the Trajectory view's turn labels.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-question-nav/projection
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
|
21
|
+
import type { QuestionEntry } from './core/question-entry.ts'
|
|
22
|
+
|
|
23
|
+
/** Fold state: the last opened turn plus every question recorded so far. */
|
|
24
|
+
export interface QuestionIndexState {
|
|
25
|
+
/** Turn of the last `turn/start` (0 before any; turns are 1-based). */
|
|
26
|
+
turn: number
|
|
27
|
+
/** Every recorded question, in event order. */
|
|
28
|
+
questions: QuestionEntry[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
32
|
+
interface SessionProjectionMap {
|
|
33
|
+
questionIndex: QuestionEntry[]
|
|
34
|
+
}
|
|
35
|
+
interface SessionProjectionStateMap {
|
|
36
|
+
questionIndex: QuestionIndexState
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const questionEntrySchema = z.object({
|
|
41
|
+
turn: z.number().int().nonnegative(),
|
|
42
|
+
id: z.string(),
|
|
43
|
+
seq: z.number().int().nonnegative(),
|
|
44
|
+
time: z.number().nonnegative(),
|
|
45
|
+
text: z.string(),
|
|
46
|
+
}).strict()
|
|
47
|
+
|
|
48
|
+
/** Validates persisted rows after their `ver` gate (the unit's input boundary). */
|
|
49
|
+
const questionIndexStateSchema = z.object({
|
|
50
|
+
turn: z.number().int().nonnegative(),
|
|
51
|
+
questions: z.array(questionEntrySchema),
|
|
52
|
+
}).strict()
|
|
53
|
+
|
|
54
|
+
/** Validates the wire payload before it leaves the host. */
|
|
55
|
+
const questionIndexViewSchema = z.array(questionEntrySchema)
|
|
56
|
+
|
|
57
|
+
/** First text block of a user message; empty string when absent. */
|
|
58
|
+
function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {
|
|
59
|
+
if (content === undefined || content.length === 0) return ''
|
|
60
|
+
const first = content[0]
|
|
61
|
+
return typeof first?.text === 'string' ? first.text : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The `questionIndex` unit registered on `ctx.sessionProjections`. */
|
|
65
|
+
export const questionIndexProjectionDefinition: Omit<ProjectionDefinition<'questionIndex', QuestionIndexState>, 'wire'> & {
|
|
66
|
+
wire: NonNullable<ProjectionDefinition<'questionIndex', QuestionIndexState>['wire']>
|
|
67
|
+
} = {
|
|
68
|
+
key: 'questionIndex',
|
|
69
|
+
stateVersion: 1,
|
|
70
|
+
stateSchema: questionIndexStateSchema,
|
|
71
|
+
init: () => ({ turn: 0, questions: [] }),
|
|
72
|
+
apply: (state, event) => {
|
|
73
|
+
// Every uninteresting event returns the same reference (Object.is gates
|
|
74
|
+
// the change feed and the persisted-cache dirty check).
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case 'turn/start':
|
|
77
|
+
return event.data.turn === state.turn ? state : { ...state, turn: event.data.turn }
|
|
78
|
+
case 'user/message': {
|
|
79
|
+
if (event.surfaceOp !== 'append') return state
|
|
80
|
+
if (event.data.source?.kind !== 'user') return state
|
|
81
|
+
const entry: QuestionEntry = {
|
|
82
|
+
turn: state.turn,
|
|
83
|
+
id: String(event.data.id),
|
|
84
|
+
seq: event.seq,
|
|
85
|
+
time: event.time,
|
|
86
|
+
text: messageText(event.data.content),
|
|
87
|
+
}
|
|
88
|
+
return { ...state, questions: [...state.questions, entry] }
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return state
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
wire: {
|
|
95
|
+
viewSchema: questionIndexViewSchema,
|
|
96
|
+
view: state => state.questions,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Question-index builder over the raw session history RPC.
|
|
3
|
-
*
|
|
4
|
-
* DSH pages the rendered conversation window on purpose (memory economy):
|
|
5
|
-
* `chat.nodes` only ever holds the loaded window, and force-expanding it
|
|
6
|
-
* (repeated `loadOlder()`) materializes + renders the whole log — the exact
|
|
7
|
-
* cost DSH's paging exists to avoid. This module instead builds a lightweight
|
|
8
|
-
* index of every user question by paging the RAW history RPC (`session.history`
|
|
9
|
-
* with `beforeSeq`), which reads the host log without touching the render
|
|
10
|
-
* window at all. Only `{key, seq, time, text}` per question is retained.
|
|
11
|
-
*
|
|
12
|
-
* The chat anchor key is derived deterministically from the event — it equals
|
|
13
|
-
* `conversationContextKey('input-message', String(event.data.id))` — so the
|
|
14
|
-
* dots can target rows that are not loaded yet, and a click then pages the
|
|
15
|
-
* window on demand (see `jump.ts`).
|
|
16
|
-
*
|
|
17
|
-
* Pure-ish: takes injected ports (one raw history page read, clocks) so it is
|
|
18
|
-
* unit-testable without a browser or a live session.
|
|
19
|
-
*/
|
|
20
|
-
import type { QuestionNode } from './nodes.ts';
|
|
21
|
-
/** Minimal shape of a raw history event (structural, not SDK-bound). */
|
|
22
|
-
export interface RawEventLike {
|
|
23
|
-
type: string;
|
|
24
|
-
seq: number;
|
|
25
|
-
time: number;
|
|
26
|
-
surfaceOp?: unknown;
|
|
27
|
-
data?: {
|
|
28
|
-
id?: unknown;
|
|
29
|
-
source?: {
|
|
30
|
-
kind?: string;
|
|
31
|
-
plugin?: string;
|
|
32
|
-
};
|
|
33
|
-
content?: readonly {
|
|
34
|
-
type?: string;
|
|
35
|
-
text?: string;
|
|
36
|
-
}[];
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
/** The conversation Definition kind whose key a user question node uses. */
|
|
40
|
-
export declare const MESSAGE_DEFINITION_KIND = "input-message";
|
|
41
|
-
/**
|
|
42
|
-
* The engine-owned stable chat key for a user question event — mirrors
|
|
43
|
-
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
44
|
-
* (verified against it in the unit test).
|
|
45
|
-
*/
|
|
46
|
-
export declare function questionKey(id: unknown): string;
|
|
47
|
-
/**
|
|
48
|
-
* Whether a raw event is one user question the strip should index.
|
|
49
|
-
* Mirrors the DSH `messageDefinition` match + `start` classification:
|
|
50
|
-
* an append-origin `user/message` with a human (`user`) source. Replacement
|
|
51
|
-
* copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
|
|
52
|
-
* context (`source.kind !== 'user'`) are excluded.
|
|
53
|
-
*/
|
|
54
|
-
export declare function isQuestionEvent(event: RawEventLike): boolean;
|
|
55
|
-
/** Map one raw question event to a strip question node, or null when not one. */
|
|
56
|
-
export declare function questionFromEvent(event: RawEventLike): QuestionNode | null;
|
|
57
|
-
export interface HistoryIndexPorts {
|
|
58
|
-
/**
|
|
59
|
-
* Read one raw history page. `beforeSeq` is exclusive (events with seq <
|
|
60
|
-
* beforeSeq); `undefined` reads the newest page. Resolves undefined when
|
|
61
|
-
* the page is unavailable (session gone / transport error).
|
|
62
|
-
*/
|
|
63
|
-
history: (beforeSeq: number | undefined, maxMessages: number) => Promise<{
|
|
64
|
-
events: readonly {
|
|
65
|
-
event: RawEventLike;
|
|
66
|
-
}[];
|
|
67
|
-
hasMore: boolean;
|
|
68
|
-
} | undefined>;
|
|
69
|
-
/** Monotonic ms clock. */
|
|
70
|
-
now: () => number;
|
|
71
|
-
}
|
|
72
|
-
export interface HistoryIndexOptions {
|
|
73
|
-
/** Raw messages per page (default 100). */
|
|
74
|
-
maxMessages?: number;
|
|
75
|
-
/** Max pages before giving up (default 200 => 20k messages). */
|
|
76
|
-
maxPages?: number;
|
|
77
|
-
/** Total wall-clock budget (default 30s). */
|
|
78
|
-
totalTimeoutMs?: number;
|
|
79
|
-
/** Abort the build; checked every iteration. */
|
|
80
|
-
signal?: AbortSignal;
|
|
81
|
-
/** Resume from a previous `nextBeforeSeq` instead of the newest page. */
|
|
82
|
-
startBeforeSeq?: number;
|
|
83
|
-
}
|
|
84
|
-
export type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED';
|
|
85
|
-
export interface HistoryIndexResult {
|
|
86
|
-
ok: boolean;
|
|
87
|
-
code: HistoryIndexCode;
|
|
88
|
-
/** Questions collected so far, ascending by anchorSeq. */
|
|
89
|
-
questions: QuestionNode[];
|
|
90
|
-
/** Page count actually read. */
|
|
91
|
-
pages: number;
|
|
92
|
-
/** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */
|
|
93
|
-
nextBeforeSeq: number | undefined;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Page the raw session history backward, collecting every user question into a
|
|
97
|
-
* lightweight index. Never touches the render window.
|
|
98
|
-
*/
|
|
99
|
-
export declare function buildQuestionIndex(ports: HistoryIndexPorts, options?: HistoryIndexOptions): Promise<HistoryIndexResult>;
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Load-all orchestration for the question-nav strip.
|
|
3
|
-
*
|
|
4
|
-
* DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
|
|
5
|
-
* the currently loaded window, and questions that still sit behind the "load
|
|
6
|
-
* older" button are invisible to the strip until the window is expanded
|
|
7
|
-
* backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
|
|
8
|
-
* history is materialized), so every user question becomes a dot.
|
|
9
|
-
*
|
|
10
|
-
* Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
|
|
11
|
-
* liveness, clocks) so it is unit-testable without a browser or session.
|
|
12
|
-
*/
|
|
13
|
-
export interface LoadAllSnapshot {
|
|
14
|
-
openState: string;
|
|
15
|
-
hasMore: boolean;
|
|
16
|
-
loadingOlder: boolean;
|
|
17
|
-
}
|
|
18
|
-
export interface LoadAllPorts {
|
|
19
|
-
/** Read the current session snapshot; undefined when unavailable. */
|
|
20
|
-
snapshot: () => LoadAllSnapshot | undefined;
|
|
21
|
-
/** Expand the window backwards by one page (may preserve scroll). */
|
|
22
|
-
loadOlder: () => Promise<void>;
|
|
23
|
-
/** True while the chat view is active (a `[data-chat-flow]` is mounted). */
|
|
24
|
-
isViewActive: () => boolean;
|
|
25
|
-
/** Monotonic ms clock. */
|
|
26
|
-
now: () => number;
|
|
27
|
-
/** Async sleep. */
|
|
28
|
-
sleep: (ms: number) => Promise<void>;
|
|
29
|
-
}
|
|
30
|
-
export interface LoadAllOptions {
|
|
31
|
-
/** Max older pages to fetch before giving up (default 400). */
|
|
32
|
-
maxPages?: number;
|
|
33
|
-
/** Total wall-clock budget for the whole expansion (default 60s). */
|
|
34
|
-
totalTimeoutMs?: number;
|
|
35
|
-
/** Poll interval for open/loading transitions (default 60ms). */
|
|
36
|
-
pollMs?: number;
|
|
37
|
-
/** Abort the expansion; checked every iteration. */
|
|
38
|
-
signal?: AbortSignal;
|
|
39
|
-
}
|
|
40
|
-
export type LoadAllCode = 'COMPLETE' | 'VIEW_INACTIVE' | 'NOT_OPEN' | 'BUDGET' | 'TIMEOUT' | 'CANCELLED';
|
|
41
|
-
export interface LoadAllResult {
|
|
42
|
-
ok: boolean;
|
|
43
|
-
code: LoadAllCode;
|
|
44
|
-
/** Number of `loadOlder` pages actually fetched. */
|
|
45
|
-
pages: number;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Expand the session window backwards until the earliest history is loaded.
|
|
49
|
-
* Waits while the session is still opening; aborts on cancellation, budget or
|
|
50
|
-
* timeout. Safe to re-enter: once `hasMore` is false the loop returns
|
|
51
|
-
* immediately with `COMPLETE`.
|
|
52
|
-
*/
|
|
53
|
-
export declare function loadAllOlder(ports: LoadAllPorts, options?: LoadAllOptions): Promise<LoadAllResult>;
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Question-index builder over the raw session history RPC.
|
|
3
|
-
*
|
|
4
|
-
* DSH pages the rendered conversation window on purpose (memory economy):
|
|
5
|
-
* `chat.nodes` only ever holds the loaded window, and force-expanding it
|
|
6
|
-
* (repeated `loadOlder()`) materializes + renders the whole log — the exact
|
|
7
|
-
* cost DSH's paging exists to avoid. This module instead builds a lightweight
|
|
8
|
-
* index of every user question by paging the RAW history RPC (`session.history`
|
|
9
|
-
* with `beforeSeq`), which reads the host log without touching the render
|
|
10
|
-
* window at all. Only `{key, seq, time, text}` per question is retained.
|
|
11
|
-
*
|
|
12
|
-
* The chat anchor key is derived deterministically from the event — it equals
|
|
13
|
-
* `conversationContextKey('input-message', String(event.data.id))` — so the
|
|
14
|
-
* dots can target rows that are not loaded yet, and a click then pages the
|
|
15
|
-
* window on demand (see `jump.ts`).
|
|
16
|
-
*
|
|
17
|
-
* Pure-ish: takes injected ports (one raw history page read, clocks) so it is
|
|
18
|
-
* unit-testable without a browser or a live session.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import type { QuestionNode } from './nodes.ts'
|
|
22
|
-
import { messageText } from './nodes.ts'
|
|
23
|
-
|
|
24
|
-
/** Minimal shape of a raw history event (structural, not SDK-bound). */
|
|
25
|
-
export interface RawEventLike {
|
|
26
|
-
type: string
|
|
27
|
-
seq: number
|
|
28
|
-
time: number
|
|
29
|
-
surfaceOp?: unknown
|
|
30
|
-
data?: {
|
|
31
|
-
id?: unknown
|
|
32
|
-
source?: { kind?: string; plugin?: string }
|
|
33
|
-
content?: readonly { type?: string; text?: string }[]
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** The conversation Definition kind whose key a user question node uses. */
|
|
38
|
-
export const MESSAGE_DEFINITION_KIND = 'input-message'
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The engine-owned stable chat key for a user question event — mirrors
|
|
42
|
-
* `conversationContextKey('input-message', String(id))` from the DSH runtime
|
|
43
|
-
* (verified against it in the unit test).
|
|
44
|
-
*/
|
|
45
|
-
export function questionKey(id: unknown): string {
|
|
46
|
-
const kind = MESSAGE_DEFINITION_KIND
|
|
47
|
-
return `${kind.length}:${kind}${String(id)}`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Whether a raw event is one user question the strip should index.
|
|
52
|
-
* Mirrors the DSH `messageDefinition` match + `start` classification:
|
|
53
|
-
* an append-origin `user/message` with a human (`user`) source. Replacement
|
|
54
|
-
* copies (compaction checkpoints, `source.kind === 'plugin'`) and injected
|
|
55
|
-
* context (`source.kind !== 'user'`) are excluded.
|
|
56
|
-
*/
|
|
57
|
-
export function isQuestionEvent(event: RawEventLike): boolean {
|
|
58
|
-
if (event.type !== 'user/message') return false
|
|
59
|
-
if (event.surfaceOp !== 'append') return false
|
|
60
|
-
return event.data?.source?.kind === 'user'
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Map one raw question event to a strip question node, or null when not one. */
|
|
64
|
-
export function questionFromEvent(event: RawEventLike): QuestionNode | null {
|
|
65
|
-
if (!isQuestionEvent(event)) return null
|
|
66
|
-
return {
|
|
67
|
-
key: questionKey(event.data?.id),
|
|
68
|
-
anchorSeq: event.seq,
|
|
69
|
-
seq: event.seq,
|
|
70
|
-
time: event.time,
|
|
71
|
-
text: messageText(event.data?.content),
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface HistoryIndexPorts {
|
|
76
|
-
/**
|
|
77
|
-
* Read one raw history page. `beforeSeq` is exclusive (events with seq <
|
|
78
|
-
* beforeSeq); `undefined` reads the newest page. Resolves undefined when
|
|
79
|
-
* the page is unavailable (session gone / transport error).
|
|
80
|
-
*/
|
|
81
|
-
history: (
|
|
82
|
-
beforeSeq: number | undefined,
|
|
83
|
-
maxMessages: number,
|
|
84
|
-
) => Promise<{ events: readonly { event: RawEventLike }[]; hasMore: boolean } | undefined>
|
|
85
|
-
/** Monotonic ms clock. */
|
|
86
|
-
now: () => number
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface HistoryIndexOptions {
|
|
90
|
-
/** Raw messages per page (default 100). */
|
|
91
|
-
maxMessages?: number
|
|
92
|
-
/** Max pages before giving up (default 200 => 20k messages). */
|
|
93
|
-
maxPages?: number
|
|
94
|
-
/** Total wall-clock budget (default 30s). */
|
|
95
|
-
totalTimeoutMs?: number
|
|
96
|
-
/** Abort the build; checked every iteration. */
|
|
97
|
-
signal?: AbortSignal
|
|
98
|
-
/** Resume from a previous `nextBeforeSeq` instead of the newest page. */
|
|
99
|
-
startBeforeSeq?: number
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export type HistoryIndexCode = 'COMPLETE' | 'BUDGET' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELLED'
|
|
103
|
-
|
|
104
|
-
export interface HistoryIndexResult {
|
|
105
|
-
ok: boolean
|
|
106
|
-
code: HistoryIndexCode
|
|
107
|
-
/** Questions collected so far, ascending by anchorSeq. */
|
|
108
|
-
questions: QuestionNode[]
|
|
109
|
-
/** Page count actually read. */
|
|
110
|
-
pages: number
|
|
111
|
-
/** Where to continue (exclusive) when stopped early; undefined when COMPLETE. */
|
|
112
|
-
nextBeforeSeq: number | undefined
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const DEFAULTS = {
|
|
116
|
-
maxMessages: 100,
|
|
117
|
-
maxPages: 200,
|
|
118
|
-
totalTimeoutMs: 30_000,
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function minSeq(events: readonly { event: RawEventLike }[]): number | undefined {
|
|
122
|
-
let min: number | undefined
|
|
123
|
-
for (const { event } of events) {
|
|
124
|
-
if (min === undefined || event.seq < min) min = event.seq
|
|
125
|
-
}
|
|
126
|
-
return min
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Page the raw session history backward, collecting every user question into a
|
|
131
|
-
* lightweight index. Never touches the render window.
|
|
132
|
-
*/
|
|
133
|
-
export async function buildQuestionIndex(
|
|
134
|
-
ports: HistoryIndexPorts,
|
|
135
|
-
options: HistoryIndexOptions = {},
|
|
136
|
-
): Promise<HistoryIndexResult> {
|
|
137
|
-
const cfg = { ...DEFAULTS, ...options }
|
|
138
|
-
const deadline = ports.now() + cfg.totalTimeoutMs
|
|
139
|
-
const questions: QuestionNode[] = []
|
|
140
|
-
let beforeSeq: number | undefined = cfg.startBeforeSeq
|
|
141
|
-
let pages = 0
|
|
142
|
-
|
|
143
|
-
const cancelled = (): boolean => cfg.signal?.aborted === true
|
|
144
|
-
|
|
145
|
-
while (true) {
|
|
146
|
-
if (cancelled()) return { ok: false, code: 'CANCELLED', questions, pages, nextBeforeSeq: beforeSeq }
|
|
147
|
-
if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', questions, pages, nextBeforeSeq: beforeSeq }
|
|
148
|
-
if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', questions, pages, nextBeforeSeq: beforeSeq }
|
|
149
|
-
|
|
150
|
-
const page = await ports.history(beforeSeq, cfg.maxMessages)
|
|
151
|
-
if (page === undefined) {
|
|
152
|
-
// Transient: retry a little, then give up with what we have.
|
|
153
|
-
if (pages === 0) return { ok: false, code: 'UNAVAILABLE', questions, pages, nextBeforeSeq: beforeSeq }
|
|
154
|
-
return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
for (const { event } of page.events) {
|
|
158
|
-
const question = questionFromEvent(event)
|
|
159
|
-
if (question !== null) questions.push(question)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (!page.hasMore) {
|
|
163
|
-
questions.sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
164
|
-
return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const next = minSeq(page.events)
|
|
168
|
-
if (next === undefined) {
|
|
169
|
-
// Empty page with hasMore true is anomalous; stop cleanly.
|
|
170
|
-
questions.sort((a, b) => a.anchorSeq - b.anchorSeq)
|
|
171
|
-
return { ok: true, code: 'COMPLETE', questions, pages, nextBeforeSeq: undefined }
|
|
172
|
-
}
|
|
173
|
-
beforeSeq = next
|
|
174
|
-
pages += 1
|
|
175
|
-
}
|
|
176
|
-
}
|