@shendeguize/dsh-agent-sidecar 0.1.0
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 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized user-facing strings for the session-detail view (design §5.1
|
|
3
|
+
* view 2, §5.3 honesty wording). Deliberately a module-local table — NOT
|
|
4
|
+
* part of the main locale registry (locales/zh.ts / locales/en.ts): the
|
|
5
|
+
* locale tables are owned by parallel tasks and the S7 integration wave
|
|
6
|
+
* folds this table in centrally. Same posture as board/strings.ts.
|
|
7
|
+
*
|
|
8
|
+
* Chinese is the primary UI language. Templates use `{name}` placeholders
|
|
9
|
+
* resolved by `formatTemplate` in `logic.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Pure data module: no imports, no logic.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const DETAIL_STRINGS = {
|
|
17
|
+
/** Header block (session meta + controls). */
|
|
18
|
+
header: {
|
|
19
|
+
close: '返回看板',
|
|
20
|
+
listenOn: '监听中',
|
|
21
|
+
listenOff: '监听',
|
|
22
|
+
listenHint: '开启后新事件将实时追加并高亮',
|
|
23
|
+
untitled: '(无标题)',
|
|
24
|
+
unknownProject: '未知项目',
|
|
25
|
+
/** Design §5.3 / SKILL.md wording: statuses are inferred observations. */
|
|
26
|
+
observedDisclaimer: '状态为从持久化数据推断的观察值,可能滞后',
|
|
27
|
+
},
|
|
28
|
+
/** Session status badge labels (observed values, see disclaimer). */
|
|
29
|
+
status: {
|
|
30
|
+
working: '工作中',
|
|
31
|
+
waiting: '等待中',
|
|
32
|
+
idle: '空闲',
|
|
33
|
+
dead: '已结束',
|
|
34
|
+
unknown: '未知',
|
|
35
|
+
},
|
|
36
|
+
/** Timeline source badges (provenance of the merged page, honest labels). */
|
|
37
|
+
sources: {
|
|
38
|
+
title: '数据来源',
|
|
39
|
+
dshLive: 'dsh 实时',
|
|
40
|
+
dshCold: 'dsh 冷读',
|
|
41
|
+
sidecarReplay: 'sidecar 重放',
|
|
42
|
+
sidecarBuffer: 'sidecar 缓冲',
|
|
43
|
+
none: '来源未知',
|
|
44
|
+
},
|
|
45
|
+
/** Normalized event-kind labels; unknown kinds keep their raw text. */
|
|
46
|
+
kind: {
|
|
47
|
+
user: '用户消息',
|
|
48
|
+
assistant: '助手回复',
|
|
49
|
+
thinking: '思考',
|
|
50
|
+
toolCall: '工具调用',
|
|
51
|
+
toolResult: '工具结果',
|
|
52
|
+
turn: '回合',
|
|
53
|
+
step: '步骤',
|
|
54
|
+
error: '错误',
|
|
55
|
+
other: '事件',
|
|
56
|
+
},
|
|
57
|
+
/** Seq-discontinuity marker row (design §4.b.3 honest presentation). */
|
|
58
|
+
gap: {
|
|
59
|
+
label: '缺口:可能有 {n} 条事件未捕获(256 队列上限或未持久化)',
|
|
60
|
+
},
|
|
61
|
+
/** Timeline list chrome. */
|
|
62
|
+
timeline: {
|
|
63
|
+
loadMore: '加载更多历史',
|
|
64
|
+
loadingMore: '加载中…',
|
|
65
|
+
noMore: '已到时间线起点',
|
|
66
|
+
expand: '展开',
|
|
67
|
+
collapse: '收起',
|
|
68
|
+
newBadge: '新',
|
|
69
|
+
seq: 'seq {n}',
|
|
70
|
+
hiddenNotice: '为保持流畅,较早的 {n} 条已折叠',
|
|
71
|
+
showAll: '全部显示',
|
|
72
|
+
},
|
|
73
|
+
/** Loading / empty / error body states. */
|
|
74
|
+
states: {
|
|
75
|
+
loadingTitle: '正在加载时间线…',
|
|
76
|
+
emptyTitle: '暂无事件',
|
|
77
|
+
emptyHint: '该会话还没有可展示的规范化事件。',
|
|
78
|
+
errorTitle: '时间线加载失败',
|
|
79
|
+
/** Fallback template when the reason code has no friendly mapping. */
|
|
80
|
+
errorFallback: '错误码:{reason}',
|
|
81
|
+
errors: {
|
|
82
|
+
session_not_found: '会话不存在或已不可见',
|
|
83
|
+
invalid_cursor: '分页游标无效,请重新打开详情',
|
|
84
|
+
fusion_not_wired: '当前 host 未启用时间线能力',
|
|
85
|
+
network_error: '网络错误,无法联系 dsh host',
|
|
86
|
+
request_timeout: '请求超时',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
/** Relative time templates (coarse buckets, matches the board wording). */
|
|
90
|
+
time: {
|
|
91
|
+
justNow: '刚刚',
|
|
92
|
+
minutesAgo: '{n} 分钟前',
|
|
93
|
+
hoursAgo: '{n} 小时前',
|
|
94
|
+
daysAgo: '{n} 天前',
|
|
95
|
+
},
|
|
96
|
+
} as const
|
|
97
|
+
|
|
98
|
+
export type DetailStrings = typeof DETAIL_STRINGS
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport helpers for the detail view's M3 read endpoints. api.ts (T2.1)
|
|
3
|
+
* predates M3: its `fetchSession` is typed for the M1 placeholder response
|
|
4
|
+
* and it has no timeline-pagination call — and per the task boundary its
|
|
5
|
+
* existing exports must not change (the S7 integration wave unifies the
|
|
6
|
+
* data layer). So this module carries the missing calls with the same
|
|
7
|
+
* posture as api.ts (same-origin relative paths, bounded timeout,
|
|
8
|
+
* normalized ApiError, injectable primitives), reusing api.ts's exported
|
|
9
|
+
* building blocks instead of redefining them.
|
|
10
|
+
*
|
|
11
|
+
* Read-only surface, no retry policy here (transport, not policy).
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
API_PREFIX,
|
|
18
|
+
ApiError,
|
|
19
|
+
DEFAULT_TIMEOUT_MS,
|
|
20
|
+
type AbortControllerLike,
|
|
21
|
+
type FetchLike,
|
|
22
|
+
type RequestOptions,
|
|
23
|
+
type ResponseLike,
|
|
24
|
+
type SessionView,
|
|
25
|
+
type TimerHandle,
|
|
26
|
+
} from '../api.ts'
|
|
27
|
+
import type { TimelinePageWire } from './logic.ts'
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Wire mirrors of the M3 `GET session/<id>` body (host: routes.ts
|
|
31
|
+
// handleSession with fusion wired; unified row: fusion.ts UnifiedSession).
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** One deduplicated cross-agent session row (host: fusion.ts). */
|
|
35
|
+
export interface UnifiedSessionWire {
|
|
36
|
+
agent: string
|
|
37
|
+
sessionId: string
|
|
38
|
+
origin: 'dsh-live' | 'sidecar' | 'merged'
|
|
39
|
+
live: boolean
|
|
40
|
+
status: string
|
|
41
|
+
title: string
|
|
42
|
+
project: string
|
|
43
|
+
/** Unix epoch ms of the freshest signal across both sources. */
|
|
44
|
+
lastActivityAt: number
|
|
45
|
+
lastEvent: { ts: string; kind: string; text: string } | null
|
|
46
|
+
lastSeq: number | null
|
|
47
|
+
gap: boolean
|
|
48
|
+
parentId: string | null
|
|
49
|
+
extra: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Body of `GET session/<id>` on an M3 host (fusion wired). */
|
|
53
|
+
export interface SessionDetailWire {
|
|
54
|
+
/** Sidecar board row; null for dsh-live sessions the sidecar has not seen. */
|
|
55
|
+
session: SessionView | null
|
|
56
|
+
/** Fused row; null when only the board knows the session. */
|
|
57
|
+
unified: UnifiedSessionWire | null
|
|
58
|
+
/** Newest timeline page; null only on a pre-M3 host (placeholder contract). */
|
|
59
|
+
timeline: TimelinePageWire | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Bounded same-origin GET (api.ts `request` is module-private; this is the
|
|
64
|
+
// same discipline in miniature: timeout, external abort, ApiError taxonomy).
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
|
|
68
|
+
globalThis.setTimeout(fn, ms)
|
|
69
|
+
|
|
70
|
+
const defaultClearTimeout = (handle: TimerHandle): void => {
|
|
71
|
+
globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const defaultCreateAbortController = (): AbortControllerLike => new AbortController()
|
|
75
|
+
|
|
76
|
+
function resolveFetch(opts: RequestOptions): FetchLike {
|
|
77
|
+
if (opts.fetch !== undefined) return opts.fetch
|
|
78
|
+
return globalThis.fetch as unknown as FetchLike
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getJson(path: string, opts: RequestOptions): Promise<unknown> {
|
|
82
|
+
const doFetch = resolveFetch(opts)
|
|
83
|
+
const controller = (opts.createAbortController ?? defaultCreateAbortController)()
|
|
84
|
+
const setT = opts.setTimeout ?? defaultSetTimeout
|
|
85
|
+
const clearT = opts.clearTimeout ?? defaultClearTimeout
|
|
86
|
+
|
|
87
|
+
let timedOut = false
|
|
88
|
+
let externallyAborted = false
|
|
89
|
+
const timer = setT(() => {
|
|
90
|
+
timedOut = true
|
|
91
|
+
controller.abort()
|
|
92
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
93
|
+
|
|
94
|
+
const external = opts.signal
|
|
95
|
+
const onExternalAbort = (): void => {
|
|
96
|
+
externallyAborted = true
|
|
97
|
+
controller.abort()
|
|
98
|
+
}
|
|
99
|
+
if (external !== undefined) {
|
|
100
|
+
if (external.aborted) onExternalAbort()
|
|
101
|
+
else external.addEventListener('abort', onExternalAbort)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
let res: ResponseLike
|
|
106
|
+
try {
|
|
107
|
+
res = await doFetch(path, { method: 'GET', signal: controller.signal })
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
|
|
110
|
+
if (externallyAborted) throw new ApiError('aborted', 'request_aborted', null, err)
|
|
111
|
+
throw new ApiError('network', 'network_error', null, err)
|
|
112
|
+
}
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
let reason = `http_${res.status}`
|
|
115
|
+
try {
|
|
116
|
+
const body = await res.json()
|
|
117
|
+
if (typeof body === 'object' && body !== null) {
|
|
118
|
+
const value = (body as Record<string, unknown>)['reason']
|
|
119
|
+
if (typeof value === 'string' && value !== '') reason = value
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Non-JSON error body: the status-derived reason stands.
|
|
123
|
+
}
|
|
124
|
+
throw new ApiError('http', reason, res.status)
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return await res.json()
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
|
|
130
|
+
throw new ApiError('parse', 'invalid_json', res.status, err)
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
clearT(timer)
|
|
134
|
+
if (external !== undefined) external.removeEventListener('abort', onExternalAbort)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Public surface.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `GET <prefix>/session/<id>` typed for the M3 body. Unknown ids reject
|
|
144
|
+
* with an ApiError carrying the server's `session_not_found` reason; a
|
|
145
|
+
* pre-M3 host answers `timeline: null` (the caller degrades honestly).
|
|
146
|
+
*/
|
|
147
|
+
export async function fetchSessionDetail(
|
|
148
|
+
sessionId: string,
|
|
149
|
+
opts: RequestOptions = {},
|
|
150
|
+
): Promise<SessionDetailWire> {
|
|
151
|
+
const path = `${API_PREFIX}/session/${encodeURIComponent(sessionId)}`
|
|
152
|
+
return (await getJson(path, opts)) as SessionDetailWire
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `GET <prefix>/session/<id>/timeline?cursor=&limit=` — one older history
|
|
157
|
+
* page. `cursor` is the opaque `nextCursor` token from a previous page,
|
|
158
|
+
* passed through verbatim (the server rejects tampered tokens with 400
|
|
159
|
+
* `invalid_cursor`); omit it for the newest window (listen-mode refetch).
|
|
160
|
+
*/
|
|
161
|
+
export async function fetchTimelinePage(
|
|
162
|
+
sessionId: string,
|
|
163
|
+
opts: RequestOptions & { cursor?: string | null; limit?: number } = {},
|
|
164
|
+
): Promise<TimelinePageWire> {
|
|
165
|
+
const params = new URLSearchParams()
|
|
166
|
+
if (opts.cursor !== undefined && opts.cursor !== null && opts.cursor !== '') {
|
|
167
|
+
params.set('cursor', opts.cursor)
|
|
168
|
+
}
|
|
169
|
+
if (opts.limit !== undefined) params.set('limit', String(opts.limit))
|
|
170
|
+
const query = params.toString()
|
|
171
|
+
const path = `${API_PREFIX}/session/${encodeURIComponent(sessionId)}/timeline${
|
|
172
|
+
query === '' ? '' : `?${query}`
|
|
173
|
+
}`
|
|
174
|
+
return (await getJson(path, opts)) as TimelinePageWire
|
|
175
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-detail data glue (T5.10b): one framework-free store per opened
|
|
3
|
+
* detail view, feeding the controlled SessionDetail / LineageTree
|
|
4
|
+
* components. Owns transport orchestration only — accumulation and
|
|
5
|
+
* presentation stay in detail/logic.ts and dsh-tools/logic.ts:
|
|
6
|
+
*
|
|
7
|
+
* - initial load via detail/transport.ts `fetchSessionDetail` (header +
|
|
8
|
+
* newest timeline page in one round-trip);
|
|
9
|
+
* - older-history pagination via `fetchTimelinePage(cursor)`;
|
|
10
|
+
* - listen mode: the controller's SSE `state` frames carry no per-session
|
|
11
|
+
* events (ADR-2/ADR-3), so the integration calls {@link
|
|
12
|
+
* DetailStore.notifySnapshot} on every frame and the store refetches the
|
|
13
|
+
* newest window — coalesced so at most one refetch is in flight and at
|
|
14
|
+
* most one more is queued;
|
|
15
|
+
* - dsh-exclusive lineage: fetched only for dsh sessions; non-dsh agents
|
|
16
|
+
* get the client-minted `not_dsh_session` degradation without dialing
|
|
17
|
+
* (dsh-tools/logic.ts `externalLineageFallback`).
|
|
18
|
+
*
|
|
19
|
+
* Same store discipline as controller.ts: subscribe/getState for
|
|
20
|
+
* `useSyncExternalStore`, immutable state snapshots, `dispose()` makes
|
|
21
|
+
* late settlements no-ops. All transport is injectable for node tests.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { isApiError, type RequestOptions } from './api.ts'
|
|
27
|
+
import {
|
|
28
|
+
fetchSessionDetail,
|
|
29
|
+
fetchTimelinePage,
|
|
30
|
+
type SessionDetailWire,
|
|
31
|
+
} from './detail/transport.ts'
|
|
32
|
+
import { fetchLineage } from './m3-transport.ts'
|
|
33
|
+
import {
|
|
34
|
+
applyListenPage,
|
|
35
|
+
applyTimelinePage,
|
|
36
|
+
createTimelineVM,
|
|
37
|
+
type TimelinePageWire,
|
|
38
|
+
type TimelineVM,
|
|
39
|
+
} from './detail/logic.ts'
|
|
40
|
+
import {
|
|
41
|
+
externalLineageFallback,
|
|
42
|
+
type LineageResponseVM,
|
|
43
|
+
type LineageTraceVM,
|
|
44
|
+
} from './dsh-tools/logic.ts'
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// State shapes.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Header seed carried from the surface that opened the detail view (board
|
|
52
|
+
* card / project row); structurally SessionDetailHeaderVM. The
|
|
53
|
+
* authoritative header comes from the fetched detail body — the hint just
|
|
54
|
+
* paints the first frame and covers degraded loads.
|
|
55
|
+
*/
|
|
56
|
+
export interface DetailHeaderHint {
|
|
57
|
+
agent: string
|
|
58
|
+
title: string
|
|
59
|
+
project: string
|
|
60
|
+
status: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Lineage panel slice (mirrors LineageTree props). */
|
|
64
|
+
export interface LineageSliceState {
|
|
65
|
+
/** True until the first resolution (fetch settle or client degrade). */
|
|
66
|
+
loading: boolean
|
|
67
|
+
/** Transport/HTTP failure reason code, or null. */
|
|
68
|
+
error: string | null
|
|
69
|
+
available: boolean
|
|
70
|
+
reason: string | null
|
|
71
|
+
detail: string | null
|
|
72
|
+
trace: LineageTraceVM | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Everything the detail view renders (mirrors SessionDetail props). */
|
|
76
|
+
export interface DetailGlueState {
|
|
77
|
+
sessionId: string
|
|
78
|
+
header: DetailHeaderHint
|
|
79
|
+
timeline: TimelineVM
|
|
80
|
+
/** True while the initial load or an older-page fetch is in flight. */
|
|
81
|
+
loading: boolean
|
|
82
|
+
/** Machine reason code of the last failure, or null. */
|
|
83
|
+
error: string | null
|
|
84
|
+
hasMore: boolean
|
|
85
|
+
listening: boolean
|
|
86
|
+
/** True once the initial load succeeded (timeline usable). */
|
|
87
|
+
ready: boolean
|
|
88
|
+
lineage: LineageSliceState
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const EMPTY_HEADER: DetailHeaderHint = { agent: '', title: '', project: '', status: '' }
|
|
92
|
+
|
|
93
|
+
const INITIAL_LINEAGE: LineageSliceState = {
|
|
94
|
+
loading: true,
|
|
95
|
+
error: null,
|
|
96
|
+
available: false,
|
|
97
|
+
reason: null,
|
|
98
|
+
detail: null,
|
|
99
|
+
trace: null,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Map any settlement failure to a stable machine reason code. */
|
|
103
|
+
function reasonOf(err: unknown): string {
|
|
104
|
+
return isApiError(err) ? err.reason : 'network_error'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Injectable transport (defaults are the real calls).
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export interface DetailStoreOptions {
|
|
112
|
+
/** Header seed from the opening surface; null paints an empty header. */
|
|
113
|
+
hint?: DetailHeaderHint | null
|
|
114
|
+
/** Newest-window size for listen-mode refetches (server default if unset). */
|
|
115
|
+
listenLimit?: number
|
|
116
|
+
fetchDetailFn?: (sessionId: string, opts?: RequestOptions) => Promise<SessionDetailWire>
|
|
117
|
+
fetchPageFn?: (
|
|
118
|
+
sessionId: string,
|
|
119
|
+
opts?: RequestOptions & { cursor?: string | null; limit?: number },
|
|
120
|
+
) => Promise<TimelinePageWire>
|
|
121
|
+
fetchLineageFn?: (sessionId: string, opts?: RequestOptions) => Promise<LineageResponseVM>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// The store.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export class DetailStore {
|
|
129
|
+
private state: DetailGlueState
|
|
130
|
+
private readonly listeners = new Set<() => void>()
|
|
131
|
+
private readonly fetchDetailFn: NonNullable<DetailStoreOptions['fetchDetailFn']>
|
|
132
|
+
private readonly fetchPageFn: NonNullable<DetailStoreOptions['fetchPageFn']>
|
|
133
|
+
private readonly fetchLineageFn: NonNullable<DetailStoreOptions['fetchLineageFn']>
|
|
134
|
+
private readonly listenLimit: number | undefined
|
|
135
|
+
private disposed = false
|
|
136
|
+
private opened = false
|
|
137
|
+
private paging = false
|
|
138
|
+
private listenInFlight = false
|
|
139
|
+
private listenQueued = false
|
|
140
|
+
private lineageStarted = false
|
|
141
|
+
|
|
142
|
+
constructor(sessionId: string, options: DetailStoreOptions = {}) {
|
|
143
|
+
this.fetchDetailFn = options.fetchDetailFn ?? fetchSessionDetail
|
|
144
|
+
this.fetchPageFn = options.fetchPageFn ?? fetchTimelinePage
|
|
145
|
+
this.fetchLineageFn = options.fetchLineageFn ?? fetchLineage
|
|
146
|
+
this.listenLimit = options.listenLimit
|
|
147
|
+
this.state = {
|
|
148
|
+
sessionId,
|
|
149
|
+
header: options.hint ?? EMPTY_HEADER,
|
|
150
|
+
timeline: createTimelineVM(sessionId),
|
|
151
|
+
loading: false,
|
|
152
|
+
error: null,
|
|
153
|
+
hasMore: false,
|
|
154
|
+
listening: false,
|
|
155
|
+
ready: false,
|
|
156
|
+
lineage: INITIAL_LINEAGE,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
subscribe = (fn: () => void): (() => void) => {
|
|
161
|
+
this.listeners.add(fn)
|
|
162
|
+
return () => { this.listeners.delete(fn) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getState = (): DetailGlueState => this.state
|
|
166
|
+
|
|
167
|
+
private setState(patch: Partial<DetailGlueState>): void {
|
|
168
|
+
if (this.disposed) return
|
|
169
|
+
this.state = { ...this.state, ...patch }
|
|
170
|
+
for (const fn of [...this.listeners]) fn()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Initial load: header + newest timeline page, then lineage. Idempotent. */
|
|
174
|
+
async open(): Promise<void> {
|
|
175
|
+
if (this.opened || this.disposed) return
|
|
176
|
+
this.opened = true
|
|
177
|
+
this.setState({ loading: true, error: null })
|
|
178
|
+
try {
|
|
179
|
+
const wire = await this.fetchDetailFn(this.state.sessionId)
|
|
180
|
+
if (this.disposed) return
|
|
181
|
+
const header = headerFromDetailWire(wire) ?? this.state.header
|
|
182
|
+
if (wire.timeline === null) {
|
|
183
|
+
// Pre-M3 host placeholder contract: card data only, no timeline.
|
|
184
|
+
this.setState({ header, loading: false, error: 'fusion_not_wired' })
|
|
185
|
+
} else {
|
|
186
|
+
const timeline = applyTimelinePage(
|
|
187
|
+
createTimelineVM(this.state.sessionId),
|
|
188
|
+
wire.timeline,
|
|
189
|
+
)
|
|
190
|
+
this.setState({
|
|
191
|
+
header,
|
|
192
|
+
timeline,
|
|
193
|
+
loading: false,
|
|
194
|
+
error: null,
|
|
195
|
+
hasMore: timeline.nextCursor !== null,
|
|
196
|
+
ready: true,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
void this.loadLineage(header.agent)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (this.disposed) return
|
|
202
|
+
this.setState({ loading: false, error: reasonOf(err) })
|
|
203
|
+
// The board hint still tells the agent kind; resolve the lineage
|
|
204
|
+
// slice anyway so the panel degrades instead of spinning forever.
|
|
205
|
+
void this.loadLineage(this.state.header.agent)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Fetch one older history page via the accumulated cursor. */
|
|
210
|
+
async loadMore(): Promise<void> {
|
|
211
|
+
const { timeline } = this.state
|
|
212
|
+
if (this.disposed || this.paging || !this.state.ready) return
|
|
213
|
+
if (timeline.nextCursor === null) return
|
|
214
|
+
this.paging = true
|
|
215
|
+
this.setState({ loading: true, error: null })
|
|
216
|
+
try {
|
|
217
|
+
const page = await this.fetchPageFn(this.state.sessionId, {
|
|
218
|
+
cursor: timeline.nextCursor,
|
|
219
|
+
})
|
|
220
|
+
if (this.disposed) return
|
|
221
|
+
const next = applyTimelinePage(this.state.timeline, page)
|
|
222
|
+
this.setState({
|
|
223
|
+
timeline: next,
|
|
224
|
+
loading: false,
|
|
225
|
+
hasMore: next.nextCursor !== null,
|
|
226
|
+
})
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (this.disposed) return
|
|
229
|
+
// Entries already shown stay visible; the view renders the reason
|
|
230
|
+
// as an inline banner (deriveDetailBodyState 'list' arm).
|
|
231
|
+
this.setState({ loading: false, error: reasonOf(err) })
|
|
232
|
+
} finally {
|
|
233
|
+
this.paging = false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Flip listen mode; turning it on refetches the newest window at once. */
|
|
238
|
+
toggleListen(): void {
|
|
239
|
+
const listening = !this.state.listening
|
|
240
|
+
this.setState({ listening })
|
|
241
|
+
if (listening) this.scheduleListenRefetch()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* SSE `state` frame hook (one call per controller notification). Refreshes
|
|
246
|
+
* the header from the live board card when given, and in listen mode
|
|
247
|
+
* triggers a coalesced newest-window refetch.
|
|
248
|
+
*/
|
|
249
|
+
notifySnapshot(card: DetailHeaderHint | null): void {
|
|
250
|
+
if (this.disposed) return
|
|
251
|
+
if (card !== null) {
|
|
252
|
+
const h = this.state.header
|
|
253
|
+
if (
|
|
254
|
+
card.agent !== h.agent ||
|
|
255
|
+
card.title !== h.title ||
|
|
256
|
+
card.project !== h.project ||
|
|
257
|
+
card.status !== h.status
|
|
258
|
+
) {
|
|
259
|
+
this.setState({ header: card })
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (this.state.listening && this.state.ready) this.scheduleListenRefetch()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** At most one refetch in flight; at most one more queued (idempotence). */
|
|
266
|
+
private scheduleListenRefetch(): void {
|
|
267
|
+
if (this.disposed || !this.state.ready) return
|
|
268
|
+
if (this.listenInFlight) {
|
|
269
|
+
this.listenQueued = true
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
this.listenInFlight = true
|
|
273
|
+
void this.runListenRefetch()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async runListenRefetch(): Promise<void> {
|
|
277
|
+
try {
|
|
278
|
+
const page = await this.fetchPageFn(this.state.sessionId, {
|
|
279
|
+
...(this.listenLimit !== undefined ? { limit: this.listenLimit } : {}),
|
|
280
|
+
})
|
|
281
|
+
if (this.disposed) return
|
|
282
|
+
this.setState({ timeline: applyListenPage(this.state.timeline, page) })
|
|
283
|
+
} catch {
|
|
284
|
+
// Listen refetch is best-effort: the next SSE frame retries; the
|
|
285
|
+
// already-rendered timeline must not degrade into an error state.
|
|
286
|
+
} finally {
|
|
287
|
+
this.listenInFlight = false
|
|
288
|
+
if (this.listenQueued && !this.disposed) {
|
|
289
|
+
this.listenQueued = false
|
|
290
|
+
this.scheduleListenRefetch()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Resolve the lineage slice once (dsh-only capability; see module doc). */
|
|
296
|
+
private async loadLineage(agent: string): Promise<void> {
|
|
297
|
+
if (this.lineageStarted || this.disposed) return
|
|
298
|
+
if (agent.trim() === '') {
|
|
299
|
+
// Agent unknown (load failed before the header resolved): degrade
|
|
300
|
+
// as non-dsh rather than dialing a lineage endpoint blind.
|
|
301
|
+
this.lineageStarted = true
|
|
302
|
+
this.setState({
|
|
303
|
+
lineage: {
|
|
304
|
+
loading: false, error: null, available: false,
|
|
305
|
+
reason: 'not_dsh_session', detail: null, trace: null,
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
this.lineageStarted = true
|
|
311
|
+
const fallback = externalLineageFallback(agent)
|
|
312
|
+
if (fallback !== null) {
|
|
313
|
+
this.setState({
|
|
314
|
+
lineage: {
|
|
315
|
+
loading: false,
|
|
316
|
+
error: null,
|
|
317
|
+
available: fallback.available,
|
|
318
|
+
reason: fallback.reason,
|
|
319
|
+
detail: null,
|
|
320
|
+
trace: fallback.trace,
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const body = await this.fetchLineageFn(this.state.sessionId)
|
|
327
|
+
if (this.disposed) return
|
|
328
|
+
this.setState({
|
|
329
|
+
lineage: {
|
|
330
|
+
loading: false,
|
|
331
|
+
error: null,
|
|
332
|
+
available: body.available,
|
|
333
|
+
reason: body.reason,
|
|
334
|
+
detail: body.detail ?? null,
|
|
335
|
+
trace: body.trace,
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (this.disposed) return
|
|
340
|
+
this.setState({ lineage: { ...INITIAL_LINEAGE, loading: false, error: reasonOf(err) } })
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Late settlements become no-ops; subscribers are dropped. Idempotent. */
|
|
345
|
+
dispose(): void {
|
|
346
|
+
this.disposed = true
|
|
347
|
+
this.listeners.clear()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Pure helpers (exported for tests and for the mount integration).
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Authoritative header from the M3 detail body: the fused row wins (it
|
|
357
|
+
* merges both sources), the sidecar board row covers fusion-less hosts;
|
|
358
|
+
* null when the body carries neither (caller keeps its hint).
|
|
359
|
+
*/
|
|
360
|
+
export function headerFromDetailWire(wire: SessionDetailWire): DetailHeaderHint | null {
|
|
361
|
+
if (wire.unified !== null) {
|
|
362
|
+
return {
|
|
363
|
+
agent: wire.unified.agent,
|
|
364
|
+
title: wire.unified.title,
|
|
365
|
+
project: wire.unified.project,
|
|
366
|
+
status: wire.unified.status,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (wire.session !== null) {
|
|
370
|
+
return {
|
|
371
|
+
agent: wire.session.agent,
|
|
372
|
+
title: wire.session.title,
|
|
373
|
+
project: wire.session.project,
|
|
374
|
+
status: wire.session.status,
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Find the live board card of a session (controller SessionCardVM rows) →
|
|
382
|
+
* header hint, or null when off-board.
|
|
383
|
+
*/
|
|
384
|
+
export function findCardHint(
|
|
385
|
+
sessions: ReadonlyArray<{
|
|
386
|
+
agent: string
|
|
387
|
+
sessionId: string
|
|
388
|
+
title: string
|
|
389
|
+
project: string
|
|
390
|
+
status: string
|
|
391
|
+
}>,
|
|
392
|
+
sessionId: string,
|
|
393
|
+
): DetailHeaderHint | null {
|
|
394
|
+
const card = sessions.find((s) => s.sessionId === sessionId)
|
|
395
|
+
if (card === undefined) return null
|
|
396
|
+
return { agent: card.agent, title: card.title, project: card.project, status: card.status }
|
|
397
|
+
}
|