fix(host): mount the detail endpoint through Connection's /api fetch registry
The split generation's detail transport rode connection.rpc.handle, whose registration reads webServer off the connection service's own context — cordis's strict-read guard rejects that for plugin callers, so the channel never armed and every detail read fell through to the static fallback (405). Mount the handler through connection.fetch.register instead (the same exact-route seam the harness's own file-upload and media-reference routes use), riding the authenticated /api fence; the client POSTs it same-origin. The detail payload now also carries the slim wire head at the same fold cut (the composition scalars the Agent network's cold-node ring fetch renders from); a missing or malformed head drops out alone, never rejecting the payload the detail cards consume.
This commit is contained in:
1 parent
6e306c31b9
commit
a82e6a1dc7
11 files changed
+336
-257
No files matched your search
@@ -7,10 +7,10 @@
|
||||
* - INLINE (older hosts, channel-less deployments, the baseline-gate
|
||||
* fallback): the wire value carries the collections in place. It passes
|
||||
* through untouched — no fetch ever happens.
|
||||
* - SPLIT (current host with the detail channel live): the wire value is the
|
||||
* - SPLIT (current host with the detail route live): the wire value is the
|
||||
* slim head (~1KB — every session.list row, control baseline, and push
|
||||
* frame carries it whole). The collections arrive from the host's
|
||||
* `/dsh-context` `detail` endpoint (host/detail.ts): one targeted read
|
||||
* frame carries it whole). The collections arrive from the plugin's
|
||||
* `/api/dsh-context/detail` fetch route (host/detail.ts): one targeted read
|
||||
* when the tab/modal first opens, then a debounced refetch whenever the
|
||||
* pushed `detailRev` outruns the served detail. Closed tabs fetch nothing.
|
||||
*
|
||||
@@ -29,12 +29,12 @@
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ContextTimeline, ContextTimelineDetail } from '../shared/types'
|
||||
import type { ClientCtx, SessionStandardProps } from './services'
|
||||
import { asRecord, numOf, objectsOf, projectionOf, rpcCallOf, timelineOf } from './services'
|
||||
import { asRecord, numOf, objectsOf, projectionOf, timelineOf } from './services'
|
||||
|
||||
// The channel/endpoint pair of host/detail.ts — re-declared here: the client
|
||||
// bundle inlines every import, and the host module must never reach it.
|
||||
const DETAIL_CHANNEL = '/dsh-context'
|
||||
const DETAIL_ENDPOINT = 'detail'
|
||||
// The detail route of host/detail.ts — re-declared here: the client bundle
|
||||
// inlines every import, and the host module must never reach it. Same-origin
|
||||
// POST under the harness's authenticated `/api` fence.
|
||||
const DETAIL_ROUTE = '/api/dsh-context/detail'
|
||||
|
||||
/**
|
||||
* Narrow the detail endpoint's payload to a render-safe value (the same
|
||||
@@ -46,8 +46,14 @@ export function detailOf(value: unknown): ContextTimelineDetail | null {
|
||||
const data = asRecord(value)
|
||||
if (data === null) return null
|
||||
if (typeof data.rev !== 'number' || !Number.isFinite(data.rev) || data.rev < 0) return null
|
||||
// The slim head rides the payload for the Agent network's cold-node ring
|
||||
// fetch; a missing or malformed head only degrades that composition (the
|
||||
// detail cards never read it), so it drops out instead of rejecting the
|
||||
// payload the cards consume.
|
||||
const head = timelineOf(data.head)
|
||||
return {
|
||||
rev: data.rev,
|
||||
...(head !== null ? { head } : {}),
|
||||
requests: objectsOf(data.requests),
|
||||
events: objectsOf(data.events),
|
||||
nodes: objectsOf(data.nodes),
|
||||
@@ -61,28 +67,30 @@ export function detailOf(value: unknown): ContextTimelineDetail | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail reader over the harness's generic Connection RPC
|
||||
* (`ctx.connection.rpc.call`) — resolved through the shared `rpcCallOf`
|
||||
* reflect read (the same seam openPathVia/canOpenPathsOf use), so a hostile
|
||||
* or absent connection service degrades to `undefined` instead of throwing.
|
||||
* The returned thunk resolves the session's current detail, `null` when the
|
||||
* session is not live anymore, and rejects on transport failure or a
|
||||
* malformed payload (the store turns the two into the retryable state).
|
||||
* The detail reader over the plugin's own `/api` fetch route (host/detail.ts).
|
||||
* Same-origin POST through the harness's authenticated fence; the returned
|
||||
* thunk resolves the session's current detail, `null` when the session is not
|
||||
* live anymore, and rejects on transport failure or a malformed payload (the
|
||||
* store turns the two into the retryable state).
|
||||
*/
|
||||
export function makeDetailFetcher(
|
||||
ctx: ClientCtx,
|
||||
sessionId: string,
|
||||
): (() => Promise<ContextTimelineDetail | null>) | undefined {
|
||||
void ctx
|
||||
if (sessionId === '') return undefined
|
||||
const call = rpcCallOf(ctx)
|
||||
if (call === undefined) return undefined
|
||||
return async () => {
|
||||
const result = await call(DETAIL_CHANNEL, DETAIL_ENDPOINT, { sessionId })
|
||||
const r = asRecord(result)
|
||||
if (r === null || r.ok !== true) throw new Error('dsh-context: detail rpc failed')
|
||||
const response = await fetch(DETAIL_ROUTE, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`dsh-context: detail route HTTP ${response.status}`)
|
||||
const r = asRecord(await response.json())
|
||||
if (r === null || r.ok !== true) throw new Error('dsh-context: detail read failed')
|
||||
if (r.value === null) return null
|
||||
const detail = detailOf(r.value)
|
||||
if (detail === null) throw new Error('dsh-context: detail rpc malformed')
|
||||
if (detail === null) throw new Error('dsh-context: detail read malformed')
|
||||
return detail
|
||||
}
|
||||
}
|
||||
@@ -179,9 +187,9 @@ export class DetailStore {
|
||||
// read's settle re-arms when the wanted rev still outruns the served one.
|
||||
if (this.inFlight) return
|
||||
if (this.fetcher === undefined) {
|
||||
// No connection face on this deployment: the typed failure arms the
|
||||
// cards' note. (The slim head is only served when the host channel
|
||||
// went live, so this is the exotic path.)
|
||||
// No session id to read for: the typed failure arms the cards' note.
|
||||
// (The slim head is only served when the host route went live, so this
|
||||
// is the exotic path.)
|
||||
this.failed = true
|
||||
this.emit()
|
||||
return
|
||||
|
||||
+72
-66
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The on-demand DETAIL channel of the split `contextTimeline` generation.
|
||||
* The on-demand DETAIL route of the split `contextTimeline` generation.
|
||||
*
|
||||
* The projection's wire value is the slim head (fold.ts `buildTimelineHead`);
|
||||
* the heavy collections (per-request records, context events, the served
|
||||
@@ -9,83 +9,86 @@
|
||||
* follow snapshot, and push frame whole (see shared/types.ts
|
||||
* `ContextTimelineDetail`).
|
||||
*
|
||||
* The channel is the harness's generic Connection RPC (`ctx.connection.rpc`),
|
||||
* the same transport the plugin used before the v0.9 projection migration —
|
||||
* verified present on every supported baseline (0.1.2-rc.1+). The handler
|
||||
* resolves the session through the harness's own ladder: a LIVE session's
|
||||
* unit state comes straight off the registry's `stateOf` (no second fold);
|
||||
* a session only ever VIEWED cold (prepared into the observation cache —
|
||||
* `SessionStore.prepare` never enters it into the live store) is observed
|
||||
* through `ctx.sessionQuery` and its immutable log folded from init (cheap:
|
||||
* a cold session's log is static, and the client's per-session store reads
|
||||
* it once per page view). A session that left the live set mid-request, a
|
||||
* unit that never registered, or a session nothing can observe resolves to
|
||||
* a typed `null` — the client keeps its last detail and offers a retry,
|
||||
* never an unhandled rejection.
|
||||
* The transport is Connection's exact Fetch-route registry
|
||||
* (`ctx.connection.fetch.register`) — the same seam the harness's own file
|
||||
* upload and media-reference routes mount through, riding the authenticated
|
||||
* `/api` fence. The handler resolves the session through the harness's own
|
||||
* ladder: a LIVE session's unit state comes straight off the registry's
|
||||
* `stateOf` (no second fold); a session only ever VIEWED cold (prepared into
|
||||
* the observation cache — `SessionStore.prepare` never enters it into the
|
||||
* live store) is observed through `ctx.sessionQuery` and its immutable log
|
||||
* folded from init (cheap: a cold session's log is static, and the client's
|
||||
* per-session store reads it once per page view). A session that left the
|
||||
* live set mid-request, a unit that never registered, or a session nothing
|
||||
* can observe resolves to a typed `null` — the client keeps its last detail
|
||||
* and offers a retry, never an unhandled rejection.
|
||||
*
|
||||
* Load order is never assumed: `watchDetailChannel` nests a `ctx.inject` on
|
||||
* the two faces, so a connection service that activates AFTER this plugin
|
||||
* still arms the channel (cordis replays the inject when the dependency set
|
||||
* completes). The returned gate is read by the timeline unit's view at every
|
||||
* serve, so the wire generation flips to slim the moment the channel goes
|
||||
* live and flips back if it unloads — the client reconciles both (it detects
|
||||
* the generation per value, timelineSource.ts). A deployment whose
|
||||
* connection/sessions services never compose keeps the gate closed forever
|
||||
* and serves the inline value unchanged.
|
||||
* the two faces the registration reads (connection, sessions), so a service
|
||||
* that activates AFTER this plugin still arms the route (cordis replays the
|
||||
* inject when the dependency set completes). The returned gate is read by
|
||||
* the timeline unit's view at every serve, so the wire generation flips to
|
||||
* slim the moment the route goes live and flips back if it unloads — the
|
||||
* client reconciles both (it detects the generation per value,
|
||||
* timelineSource.ts). A deployment whose connection/sessions services never
|
||||
* compose — or whose connection carries no fetch registry — keeps the gate
|
||||
* closed forever and serves the inline value unchanged.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { FoldBounds } from './config'
|
||||
import { applyTimeline, buildTimelineDetail, createTimelineState } from './fold'
|
||||
|
||||
/** The plugin's generic Connection RPC channel (the pre-v0.9 name, kept). */
|
||||
export const DETAIL_CHANNEL = '/dsh-context'
|
||||
/** The one endpoint the channel serves: the session's timeline detail. */
|
||||
export const DETAIL_ENDPOINT = 'detail'
|
||||
/** The plugin's detail route, under the authenticated `/api` fence. */
|
||||
export const DETAIL_ROUTE = '/api/dsh-context/detail'
|
||||
|
||||
/** The channel's liveness, read by the timeline unit's view at every serve. */
|
||||
/** The route's liveness, read by the timeline unit's view at every serve. */
|
||||
export interface DetailChannelGate {
|
||||
readonly live: boolean
|
||||
}
|
||||
|
||||
/** The host `connection` service, as far as the channel consumes it. */
|
||||
/** The host `connection` service, as far as the route consumes it. */
|
||||
interface ConnectionHostFace {
|
||||
rpc?: {
|
||||
fetch?: {
|
||||
// The handler's result is awaited by the transport; the cold rung below
|
||||
// awaits the observation read.
|
||||
handle?(
|
||||
channel: string,
|
||||
handler: (endpoint: string, payload: unknown) => unknown,
|
||||
): () => void
|
||||
register?(route: {
|
||||
path: string
|
||||
methods: readonly string[]
|
||||
requestBody: 'buffered'
|
||||
fetch: (request: Request) => Promise<Response>
|
||||
}): () => void
|
||||
}
|
||||
}
|
||||
|
||||
/** The host `sessions` service, as far as the channel consumes it (the strict-global-read idiom). */
|
||||
/** The host `sessions` service, as far as the route consumes it (the strict-global-read idiom). */
|
||||
interface SessionsHostFace {
|
||||
get?(id: string): unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The host `sessionQuery` service, as far as the channel consumes it: the
|
||||
* cold-session rung. A session that is only VIEWED is never entered into the
|
||||
* live store (`SessionStore.prepare` with a persistence seed does not
|
||||
* register it) — the observation reader carries it instead, with the full
|
||||
* immutable log on `events`.
|
||||
* The host `sessionQuery` service, as far as the route consumes it: the
|
||||
* prepared-observation read that folds a session only ever VIEWED cold.
|
||||
*/
|
||||
interface SessionQueryFace {
|
||||
observeSession?(id: string, options?: { projectionMode?: 'all' | 'none' }): Promise<unknown>
|
||||
observeSession?(id: string, options: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
/** The RPC failure envelope the transport expects (ConnectionRpcFailure). */
|
||||
function failure(code: string, message: string): { ok: false; error: { code: string; message: string; details: object } } {
|
||||
return { ok: false, error: { code, message, details: {} } }
|
||||
/** One JSON reply of the detail route. */
|
||||
function reply(value: unknown): Response {
|
||||
return Response.json(value, { headers: { 'cache-control': 'no-store' } })
|
||||
}
|
||||
|
||||
/** The typed failure envelope (the same shape the Connection RPC carried). */
|
||||
function failure(code: string, message: string): Response {
|
||||
return reply({ ok: false, error: { code, message } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the detail endpoint whenever the connection and sessions services are
|
||||
* Serve the detail route whenever the connection and sessions services are
|
||||
* both composed (see the module header for the load-order contract). The
|
||||
* registration rides the injected fiber: either service unloading withdraws
|
||||
* the channel and closes the gate.
|
||||
* the route and closes the gate.
|
||||
*/
|
||||
export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChannelGate {
|
||||
const gate = { live: false }
|
||||
@@ -93,20 +96,23 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan
|
||||
const connection = c.get('connection') as ConnectionHostFace | undefined
|
||||
const sessions = c.get('sessions') as SessionsHostFace | undefined
|
||||
// Bind at extraction (an unbound hand-off loses `this` on the real faces).
|
||||
const handle = typeof connection?.rpc?.handle === 'function'
|
||||
? connection.rpc.handle.bind(connection.rpc)
|
||||
const register = typeof connection?.fetch?.register === 'function'
|
||||
? connection.fetch.register.bind(connection.fetch)
|
||||
: undefined
|
||||
const getSession = typeof sessions?.get === 'function' ? sessions.get.bind(sessions) : undefined
|
||||
if (handle === undefined || getSession === undefined) return
|
||||
if (register === undefined || getSession === undefined) return
|
||||
const projections = ctx.sessionProjections
|
||||
|
||||
const handler = async (endpoint: string, payload: unknown): Promise<unknown> => {
|
||||
if (endpoint !== DETAIL_ENDPOINT) {
|
||||
return failure('dsh-context/unknown-endpoint', `unknown endpoint: ${endpoint}`)
|
||||
const handler = async (request: Request): Promise<Response> => {
|
||||
let sessionId: unknown
|
||||
try {
|
||||
const body: unknown = await request.json()
|
||||
sessionId = body !== null && typeof body === 'object'
|
||||
? (body as { sessionId?: unknown }).sessionId
|
||||
: undefined
|
||||
} catch {
|
||||
return failure('dsh-context/bad-request', 'body is not JSON')
|
||||
}
|
||||
const sessionId = payload !== null && typeof payload === 'object'
|
||||
? (payload as { sessionId?: unknown }).sessionId
|
||||
: undefined
|
||||
if (typeof sessionId !== 'string' || sessionId === '') {
|
||||
return failure('dsh-context/bad-request', 'missing sessionId')
|
||||
}
|
||||
@@ -118,9 +124,9 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan
|
||||
// cursor (no-op when the drive is current); never mutate the result.
|
||||
const state = projections.stateOf(session as never, 'contextTimeline')
|
||||
// The unit is absent only in the baseline-gated composition, which never
|
||||
// installs this channel — a miss is defensive.
|
||||
if (state === undefined) return { ok: true, value: null }
|
||||
return { ok: true, value: buildTimelineDetail(state, bounds) }
|
||||
// installs this route — a miss is defensive.
|
||||
if (state === undefined) return reply({ ok: true, value: null })
|
||||
return reply({ ok: true, value: buildTimelineDetail(state, bounds) })
|
||||
}
|
||||
// Cold session (viewed through a prepared observation, never entered
|
||||
// into the live store): observe it and fold the detail from its
|
||||
@@ -131,10 +137,10 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan
|
||||
const observe = typeof query?.observeSession === 'function'
|
||||
? query.observeSession.bind(query)
|
||||
: undefined
|
||||
if (observe === undefined) return { ok: true, value: null }
|
||||
if (observe === undefined) return reply({ ok: true, value: null })
|
||||
const observation = await observe(sessionId, { projectionMode: 'none' })
|
||||
const events = (observation as { events?: unknown } | null)?.events
|
||||
if (!Array.isArray(events)) return { ok: true, value: null }
|
||||
if (!Array.isArray(events)) return reply({ ok: true, value: null })
|
||||
let state = createTimelineState()
|
||||
try {
|
||||
for (const ev of events) state = applyTimeline(state, ev as never, bounds)
|
||||
@@ -142,19 +148,19 @@ export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChan
|
||||
const dispose = (observation as { [Symbol.dispose]?: unknown } | null)?.[Symbol.dispose]
|
||||
if (typeof dispose === 'function') dispose.call(observation)
|
||||
}
|
||||
return { ok: true, value: buildTimelineDetail(state, bounds) }
|
||||
return reply({ ok: true, value: buildTimelineDetail(state, bounds) })
|
||||
} catch (err) {
|
||||
return failure('gateway/internal', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
c.effect(() => {
|
||||
const unregister = handle(DETAIL_CHANNEL, handler)
|
||||
return () => {
|
||||
unregister()
|
||||
}
|
||||
}, 'dsh-context: detail channel')
|
||||
c.effect(() => register({
|
||||
path: DETAIL_ROUTE,
|
||||
methods: ['POST'],
|
||||
requestBody: 'buffered',
|
||||
fetch: request => handler(request),
|
||||
}), 'dsh-context: detail route')
|
||||
} catch {
|
||||
// A hostile or rejecting registry must not take the plugin down — the
|
||||
// gate stays closed and the wire value stays inline.
|
||||
|
||||
+4
-2
@@ -1462,10 +1462,12 @@ export function buildTimelineHead(state: TimelineState): Snapshot {
|
||||
|
||||
/**
|
||||
* The on-demand detail payload (host/detail.ts serves it off the live fold
|
||||
* state): the heavy collections plus the revision marker the head carries.
|
||||
* state): the heavy collections, the slim head at the SAME cut (the Agent
|
||||
* network's cold-node ring fetch renders composition off it), and the
|
||||
* revision marker the head carries.
|
||||
*/
|
||||
export function buildTimelineDetail(state: TimelineState, bounds: FoldBounds): ContextTimelineDetail {
|
||||
return { rev: state.detailRev ?? 0, ...detailCollectionsOf(state, bounds) }
|
||||
return { rev: state.detailRev ?? 0, head: buildTimelineHead(state), ...detailCollectionsOf(state, bounds) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -68,9 +68,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// load path refuses such events wholesale, permanently bricking the session.
|
||||
watchStepIdentity(ctx)
|
||||
// The split wire generation (detail.ts): the detail channel arms whenever
|
||||
// the connection/sessions services compose (load order never assumed), and
|
||||
// the unit's view reads the gate per serve — slim while the channel is
|
||||
// live, inline otherwise.
|
||||
// the connection/sessions/webServer services compose (load order never
|
||||
// assumed), and the unit's view reads the gate per serve — slim while the
|
||||
// channel is live, inline otherwise.
|
||||
const gate = watchDetailChannel(ctx, resolveBounds(config))
|
||||
ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live))
|
||||
ctx.sessionProjections.register(createContextHeadersDefinition(name => attribution.ownerOf(name)))
|
||||
|
||||
@@ -259,6 +259,17 @@ export interface Snapshot {
|
||||
*/
|
||||
export interface ContextTimelineDetail {
|
||||
rev: number
|
||||
/**
|
||||
* The slim wire head at the SAME fold cut as the collections: the
|
||||
* composition scalars (`current`), the window/model envelope, and the
|
||||
* precomputed counts. Sessions listed cold (never attached since the
|
||||
* requesting unit last changed) carry no `contextTimeline` projection row
|
||||
* for the browser's list reads, so the Agent network card fetches this
|
||||
* head per node to render their composition rings. The host always serves
|
||||
* it; optional so a payload missing it still serves the collections (the
|
||||
* detail cards) and only the ring composition degrades.
|
||||
*/
|
||||
head?: ContextTimeline
|
||||
requests: RequestRecord[]
|
||||
events: ContextEventRecord[]
|
||||
nodes: SurfaceNode[]
|
||||
|
||||
@@ -43,6 +43,7 @@ const OPEN = (): boolean => true
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ContextModal', () => {
|
||||
@@ -419,27 +420,23 @@ describe('ContextModal — the split generation', () => {
|
||||
assert.fail(message)
|
||||
}
|
||||
|
||||
test('the modal shares the detail channel: the browser lands the collections on open', async () => {
|
||||
const ctx = new TestClientCtx({
|
||||
services: {
|
||||
sessions: new TestSessions(),
|
||||
connection: {
|
||||
rpc: {
|
||||
call: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
rev: 1,
|
||||
requests: [{ seq: 1, turn: 1, step: 1, time: 1, system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 21 }],
|
||||
events: [],
|
||||
nodes: [{ seq: 1, cat: 'assistant', tokens: 5, text: 'modal reply' }],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
},
|
||||
}),
|
||||
},
|
||||
test('the modal shares the detail route: the browser lands the collections on open', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
rev: 1,
|
||||
requests: [{ seq: 1, turn: 1, step: 1, time: 1, system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 21 }],
|
||||
events: [],
|
||||
nodes: [{ seq: 1, cat: 'assistant', tokens: 5, text: 'modal reply' }],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
|
||||
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
|
||||
const head = timeline({
|
||||
counts: { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 },
|
||||
|
||||
@@ -29,6 +29,7 @@ const kit = makeKit()
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
|
||||
@@ -1238,13 +1239,10 @@ describe('ContextView — the split generation (slim head + detail channel)', ()
|
||||
}
|
||||
}
|
||||
|
||||
/** A ctx whose connection.rpc.call serves (or fails) the detail endpoint. */
|
||||
/** Stub the global fetch to serve (or fail) the plugin's detail route. */
|
||||
function slimCtx(serve: () => Promise<unknown>): TestClientCtx {
|
||||
return new TestClientCtx({
|
||||
services: {
|
||||
connection: { rpc: { call: (_channel: string, _endpoint: string, _payload: unknown) => serve() } },
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, status: 200, json: async () => await serve() }))
|
||||
return new TestClientCtx()
|
||||
}
|
||||
|
||||
async function until(fn: () => boolean, message: string): Promise<void> {
|
||||
@@ -1316,31 +1314,28 @@ describe('ContextView — the split generation (slim head + detail channel)', ()
|
||||
|
||||
describe('ContextView — the op-log generation (fileOps on the detail payload)', () => {
|
||||
test('the File Activity card renders the fold-derived ops, no conversation join needed', async () => {
|
||||
const ctx = new TestClientCtx({
|
||||
services: {
|
||||
connection: {
|
||||
rpc: {
|
||||
call: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
rev: 1,
|
||||
requests: [{ seq: 2, turn: 1, step: 1, time: T0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
|
||||
events: [],
|
||||
nodes: [],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
// The op log covers the full session — the conversation window
|
||||
// join plays no role in this card on this generation.
|
||||
fileOps: [
|
||||
{ seq: 1, path: '/ws/README.md', kind: 'read', tool: 'read', err: false, added: 0, removed: 0, read: { start: 1, count: 12 } },
|
||||
{ seq: 2, path: '/ws/src/a.ts', kind: 'write', tool: 'edit', err: false, added: 3, removed: 1 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
vi.stubGlobal('fetch', async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
rev: 1,
|
||||
requests: [{ seq: 2, turn: 1, step: 1, time: T0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
|
||||
events: [],
|
||||
nodes: [],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
// The op log covers the full session — the conversation window
|
||||
// join plays no role in this card on this generation.
|
||||
fileOps: [
|
||||
{ seq: 1, path: '/ws/README.md', kind: 'read', tool: 'read', err: false, added: 0, removed: 0, read: { start: 1, count: 12 } },
|
||||
{ seq: 2, path: '/ws/src/a.ts', kind: 'write', tool: 'edit', err: false, added: 3, removed: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
const ctx = new TestClientCtx()
|
||||
const View = makeView(ctx)
|
||||
const m = await mount(h(View, {
|
||||
sessionId: 'sv-opslog',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { mount, text } from './helpers/kit'
|
||||
afterEach(() => {
|
||||
resetTimelineDetailStores()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -52,11 +53,18 @@ function slimHead(rev: number, over: Record<string, unknown> = {}): Record<strin
|
||||
}
|
||||
}
|
||||
|
||||
/** A ctx whose connection face serves the given call behavior. */
|
||||
function ctxWithCall(call: unknown): ClientCtx {
|
||||
const ctx = new TestClientCtx()
|
||||
if (call !== undefined) ctx.setService('connection', { rpc: { call } })
|
||||
return asClientCtx(ctx)
|
||||
/**
|
||||
* Stub the global fetch with a programmable detail reply (the transport the
|
||||
* detail reader POSTs) and return a bare ctx — the reader never touches the
|
||||
* ctx's services for the read itself.
|
||||
*/
|
||||
function ctxWithCall(behavior: () => { status?: number; body?: unknown }): ClientCtx {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
const outcome = await behavior()
|
||||
const status = outcome.status ?? 200
|
||||
return { ok: status === 200, status, json: async () => outcome.body }
|
||||
})
|
||||
return asClientCtx(new TestClientCtx())
|
||||
}
|
||||
|
||||
/** Poll until the predicate holds (real timers ride the store's debounce). */
|
||||
@@ -106,42 +114,59 @@ describe('detailOf', () => {
|
||||
// Absent stays absent (the legacy inline generation's marker).
|
||||
assert.equal(detailOf(detail(1))?.fileOps, undefined)
|
||||
})
|
||||
|
||||
test('the slim head rides through sanitized; a malformed head drops alone', () => {
|
||||
// A well-formed head passes through (the Agent network's cold-ring fetch).
|
||||
const served = detailOf(detail(2, { head: slimHead(2) }))
|
||||
assert.ok(served?.head !== undefined)
|
||||
assert.equal(served.head.current.total, 15)
|
||||
assert.equal(served.head.model, 'm')
|
||||
// A non-record head drops without rejecting the payload the cards consume.
|
||||
const junkHead = detailOf(detail(2, { head: 'nope' }))
|
||||
assert.ok(junkHead !== null)
|
||||
assert.equal(junkHead.head, undefined)
|
||||
assert.ok(junkHead.requests.length > 0)
|
||||
// Absent stays absent.
|
||||
assert.equal(detailOf(detail(2))?.head, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeDetailFetcher', () => {
|
||||
test('no session id, no connection face, or a hostile read yields no fetcher', () => {
|
||||
assert.equal(makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: null })), ''), undefined)
|
||||
assert.equal(makeDetailFetcher(asClientCtx(new TestClientCtx()), 's1'), undefined, 'no connection service')
|
||||
assert.equal(makeDetailFetcher(ctxWithCall(undefined), 's1'), undefined)
|
||||
assert.equal(makeDetailFetcher(ctxWithCall('not-a-function'), 's1'), undefined)
|
||||
const hostile = { get: () => { throw new Error('hostile') } }
|
||||
assert.equal(makeDetailFetcher(hostile as unknown as ClientCtx, 's1'), undefined)
|
||||
test('no session id yields no fetcher', () => {
|
||||
assert.equal(makeDetailFetcher(ctxWithCall(() => ({ body: null })), ''), undefined)
|
||||
})
|
||||
|
||||
test('the envelope contract: not-ok rejects, null value means absent, malformed value rejects', async () => {
|
||||
const failed = makeDetailFetcher(ctxWithCall(async () => ({ ok: false, error: { code: 'x', message: 'm', details: {} } })), 's1')!
|
||||
await assert.rejects(() => failed(), /detail rpc failed/)
|
||||
const failed = makeDetailFetcher(ctxWithCall(() => ({ body: { ok: false, error: { code: 'x', message: 'm', details: {} } } })), 's1')!
|
||||
await assert.rejects(() => failed(), /detail read failed/)
|
||||
|
||||
const absent = makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: null })), 's1')!
|
||||
const absent = makeDetailFetcher(ctxWithCall(() => ({ body: { ok: true, value: null } })), 's1')!
|
||||
assert.equal(await absent(), null)
|
||||
|
||||
const malformed = makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: { no: 'rev' } })), 's1')!
|
||||
await assert.rejects(() => malformed(), /detail rpc malformed/)
|
||||
const malformed = makeDetailFetcher(ctxWithCall(() => ({ body: { ok: true, value: { no: 'rev' } } })), 's1')!
|
||||
await assert.rejects(() => malformed(), /detail read malformed/)
|
||||
|
||||
const transport = makeDetailFetcher(ctxWithCall(async () => { throw new Error('offline') }), 's1')!
|
||||
const transport = makeDetailFetcher(ctxWithCall(() => {
|
||||
throw new Error('offline')
|
||||
}), 's1')!
|
||||
await assert.rejects(() => transport(), /offline/)
|
||||
|
||||
// A non-200 answer (the route absent on an inline-generation host) rejects.
|
||||
const missing = makeDetailFetcher(ctxWithCall(() => ({ status: 404, body: 'not found' })), 's1')!
|
||||
await assert.rejects(() => missing(), /HTTP 404/)
|
||||
})
|
||||
|
||||
test('a good read resolves the narrowed detail and rides the declared channel pair', async () => {
|
||||
const seen: string[] = []
|
||||
const fetcher = makeDetailFetcher(ctxWithCall(async (channel: string, endpoint: string, payload: unknown) => {
|
||||
seen.push(channel, endpoint)
|
||||
assert.deepEqual(payload, { sessionId: 's1' })
|
||||
return { ok: true, value: detail(4) }
|
||||
}), 's1')!
|
||||
const d = await fetcher()
|
||||
test('a good read resolves the narrowed detail and POSTs the declared route', async () => {
|
||||
const seen: { url?: unknown; body?: unknown } = {}
|
||||
vi.stubGlobal('fetch', async (url: unknown, init: { body: string }) => {
|
||||
seen.url = url
|
||||
seen.body = JSON.parse(String(init.body))
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, value: detail(4) }) }
|
||||
})
|
||||
const d = await makeDetailFetcher(asClientCtx(new TestClientCtx()), 's1')!()
|
||||
assert.equal(d?.rev, 4)
|
||||
assert.deepEqual(seen, ['/dsh-context', 'detail'])
|
||||
assert.equal(seen.url, '/api/dsh-context/detail')
|
||||
assert.deepEqual(seen.body, { sessionId: 's1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -308,7 +333,7 @@ describe('DetailStore', () => {
|
||||
assert.equal(store.getSnapshot().failed, false)
|
||||
})
|
||||
|
||||
test('without a fetcher the store types the failure (the exotic no-connection path)', async () => {
|
||||
test('without a fetcher the store types the failure (the empty-session-id path)', async () => {
|
||||
const store = new DetailStore(undefined, 0)
|
||||
store.request(1)
|
||||
await settle()
|
||||
@@ -374,9 +399,9 @@ describe('DetailStore', () => {
|
||||
|
||||
describe('detailStoreOf', () => {
|
||||
test('one store per session, shared by every consumer; reset drops the cache', () => {
|
||||
const ctx = ctxWithCall(async () => ({ ok: true, value: detail(1) }))
|
||||
const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(1) } }))
|
||||
const a = detailStoreOf(ctx, 's1')
|
||||
assert.equal(a, detailStoreOf(ctxWithCall(async () => ({ ok: true, value: null })), 's1'), 'the session key wins, the first fetcher keeps serving')
|
||||
assert.equal(a, detailStoreOf(ctxWithCall(() => ({ body: { ok: true, value: null } })), 's1'), 'the session key wins, the first fetcher keeps serving')
|
||||
assert.notEqual(a, detailStoreOf(ctx, 's2'))
|
||||
resetTimelineDetailStores()
|
||||
assert.notEqual(detailStoreOf(ctx, 's1'), a, 'a reset store is a fresh instance')
|
||||
@@ -408,9 +433,9 @@ function probeRead(container: HTMLElement, key: string): string {
|
||||
describe('useTimelineSource', () => {
|
||||
test('the inline generation passes through untouched and never fetches', async () => {
|
||||
let calls = 0
|
||||
const ctx = ctxWithCall(async () => {
|
||||
const ctx = ctxWithCall(() => {
|
||||
calls++
|
||||
return { ok: true, value: detail(1) }
|
||||
return { body: { ok: true, value: detail(1) } }
|
||||
})
|
||||
const inline: ContextTimeline = {
|
||||
ok: true,
|
||||
@@ -436,7 +461,7 @@ describe('useTimelineSource', () => {
|
||||
})
|
||||
|
||||
test('no projection value keeps the loading surface', async () => {
|
||||
const m = await mount(h(SourceProbe, { ctx: ctxWithCall(undefined), sessionId: 's1', value: undefined }))
|
||||
const m = await mount(h(SourceProbe, { ctx: ctxWithCall(() => ({ body: null })), sessionId: 's1', value: undefined }))
|
||||
assert.equal(probeRead(m.container, 'state'), 'loading')
|
||||
assert.equal(probeRead(m.container, 'steps'), 'null')
|
||||
await m.unmount()
|
||||
@@ -444,16 +469,18 @@ describe('useTimelineSource', () => {
|
||||
|
||||
test('the split generation: counters render off the head immediately, the detail lands through the channel', async () => {
|
||||
let calls = 0
|
||||
const ctx = ctxWithCall(async () => {
|
||||
const ctx = ctxWithCall(() => {
|
||||
calls++
|
||||
return {
|
||||
ok: true,
|
||||
value: detail(3, {
|
||||
surfaceFloor: 2,
|
||||
archiveFloor: 5,
|
||||
fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
|
||||
fileOpsFloor: 4,
|
||||
}),
|
||||
body: {
|
||||
ok: true,
|
||||
value: detail(3, {
|
||||
surfaceFloor: 2,
|
||||
archiveFloor: 5,
|
||||
fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
|
||||
fileOpsFloor: 4,
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(3) }))
|
||||
@@ -473,7 +500,7 @@ describe('useTimelineSource', () => {
|
||||
|
||||
test('a rev bump on the pushed head refetches the detail', async () => {
|
||||
let served = 3
|
||||
const ctx = ctxWithCall(async () => ({ ok: true, value: detail(served) }))
|
||||
const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(served) } }))
|
||||
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(3) }))
|
||||
await until(() => probeRead(m.container, 'state') === 'ready', 'the first read never landed')
|
||||
assert.equal(detailStoreOf(ctx, 's1').getSnapshot().detail?.rev, 3)
|
||||
@@ -489,10 +516,10 @@ describe('useTimelineSource', () => {
|
||||
test('the failed read arms the retry affordance, which refires the read', async () => {
|
||||
let online = false
|
||||
let calls = 0
|
||||
const ctx = ctxWithCall(async () => {
|
||||
const ctx = ctxWithCall(() => {
|
||||
calls++
|
||||
if (!online) throw new Error('offline')
|
||||
return { ok: true, value: detail(1) }
|
||||
return { body: { ok: true, value: detail(1) } }
|
||||
})
|
||||
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(1) }))
|
||||
await until(() => probeRead(m.container, 'state') === 'failed', 'the failure never surfaced')
|
||||
@@ -506,7 +533,7 @@ describe('useTimelineSource', () => {
|
||||
})
|
||||
|
||||
test('an absent answer (the session left the live set) surfaces the failed note', async () => {
|
||||
const ctx = ctxWithCall(async () => ({ ok: true, value: null }))
|
||||
const ctx = ctxWithCall(() => ({ body: { ok: true, value: null } }))
|
||||
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(1) }))
|
||||
await until(() => probeRead(m.container, 'state') === 'failed', 'absence never surfaced')
|
||||
await m.unmount()
|
||||
|
||||
+80
-58
@@ -1,14 +1,14 @@
|
||||
// The on-demand detail channel (src/host/detail.ts): the nested-inject
|
||||
// gating on the connection/sessions faces (load-order independent), the
|
||||
// gate's live flip on unload, and the endpoint's typed outcomes — the detail
|
||||
// payload off the live fold state, the typed `null` for a gone session or an
|
||||
// absent unit, and the failure envelope for bad payloads and hostile reads
|
||||
// (the channel must never throw into the transport).
|
||||
// The on-demand detail route (src/host/detail.ts): the nested-inject gating
|
||||
// on the connection/sessions faces (load-order independent), the gate's live
|
||||
// flip on unload, and the route's typed outcomes — the detail payload off the
|
||||
// live fold state, the typed `null` for a gone session or an absent unit, and
|
||||
// the failure envelope for bad payloads and hostile reads (the route must
|
||||
// never throw into the transport).
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { watchDetailChannel } from '../../src/host/detail'
|
||||
import { DETAIL_ROUTE, watchDetailChannel } from '../../src/host/detail'
|
||||
import { resolveBounds } from '../../src/host/config'
|
||||
import type { TimelineState } from '../../src/host/fold'
|
||||
import { assistantMessage, header, userMessage } from './helpers/events'
|
||||
@@ -16,7 +16,7 @@ import { driveTimeline } from './helpers/projection'
|
||||
|
||||
const BOUNDS = resolveBounds({})
|
||||
|
||||
type Handler = (endpoint: string, payload: unknown) => unknown
|
||||
type RouteFetch = (request: Request) => Promise<Response>
|
||||
|
||||
interface CtxSpec {
|
||||
connection?: unknown
|
||||
@@ -30,8 +30,8 @@ interface CtxSpec {
|
||||
* once its dependency list completes (here: immediately when the services
|
||||
* map has them, never otherwise), and its returned disposer is collected.
|
||||
*/
|
||||
function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; handler?: Handler }; disposers: (() => void)[] } {
|
||||
const captured: { channel?: string; handler?: Handler } = {}
|
||||
function ctxOf(spec: CtxSpec): { ctx: Context; captured: { path?: string; fetch?: RouteFetch }; disposers: (() => void)[] } {
|
||||
const captured: { path?: string; fetch?: RouteFetch } = {}
|
||||
const disposers: (() => void)[] = []
|
||||
const services = new Map<string, unknown>()
|
||||
if ('connection' in spec) services.set('connection', spec.connection)
|
||||
@@ -51,13 +51,13 @@ function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; han
|
||||
if (typeof d === 'function') disposers.push(d as () => void)
|
||||
},
|
||||
}
|
||||
// Wire the connection face so handle() captures its registration.
|
||||
// Wire the connection face so register() captures its route.
|
||||
if (spec.connection !== undefined && spec.connection !== null) {
|
||||
const conn = spec.connection as { rpc?: { handle?: unknown } }
|
||||
if (typeof conn.rpc?.handle === 'function') {
|
||||
conn.rpc.handle = (channel: string, handler: Handler) => {
|
||||
captured.channel = channel
|
||||
captured.handler = handler
|
||||
const conn = spec.connection as { fetch?: { register?: unknown } }
|
||||
if (typeof conn.fetch?.register === 'function') {
|
||||
conn.fetch.register = (route: { path: string; fetch: RouteFetch }) => {
|
||||
captured.path = route.path
|
||||
captured.fetch = route.fetch
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,27 @@ function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; han
|
||||
return { ctx: ctx as unknown as Context, captured, disposers }
|
||||
}
|
||||
|
||||
/** POST one JSON body to the captured route and parse the JSON reply. */
|
||||
async function call(captured: { fetch?: RouteFetch }, body: unknown): Promise<Record<string, unknown>> {
|
||||
const request = new Request(`http://dsh.test${DETAIL_ROUTE}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
})
|
||||
const response = await captured.fetch!(request)
|
||||
return await response.json() as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** POST one raw (non-JSON) body to the captured route. */
|
||||
async function callRaw(captured: { fetch?: RouteFetch }, body: string): Promise<Record<string, unknown>> {
|
||||
const request = new Request(`http://dsh.test${DETAIL_ROUTE}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
return await captured.fetch!(request).then(r => r.json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** A live sessions face whose get() serves the `s1` id only. */
|
||||
function sessionsWith(session: unknown): { get(id: string): unknown } {
|
||||
return { get: (id: string) => (id === 's1' ? session : undefined) }
|
||||
@@ -81,25 +102,25 @@ describe('watchDetailChannel gating', () => {
|
||||
// Sessions only.
|
||||
assert.equal(watchDetailChannel(ctxOf({ sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false)
|
||||
// Connection only.
|
||||
const connOnly = ctxOf({ connection: { rpc: { handle: () => () => {} } } })
|
||||
const connOnly = ctxOf({ connection: { fetch: { register: () => () => {} } } })
|
||||
assert.equal(watchDetailChannel(connOnly.ctx, BOUNDS).live, false)
|
||||
// A connection without the rpc.handle face, or a sessions without get.
|
||||
assert.equal(watchDetailChannel(ctxOf({ connection: { rpc: {} }, sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false)
|
||||
// A connection without the fetch.register face, or a sessions without get.
|
||||
assert.equal(watchDetailChannel(ctxOf({ connection: { fetch: {} }, sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false)
|
||||
assert.equal(watchDetailChannel(ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: { get: 42 },
|
||||
}).ctx, BOUNDS).live, false)
|
||||
})
|
||||
|
||||
test('the gate opens on registration and closes on unload', () => {
|
||||
const { ctx, captured, disposers } = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
})
|
||||
const gate = watchDetailChannel(ctx, BOUNDS)
|
||||
assert.equal(gate.live, true)
|
||||
assert.equal(captured.channel, '/dsh-context')
|
||||
assert.equal(typeof captured.handler, 'function')
|
||||
assert.equal(captured.path, '/api/dsh-context/detail')
|
||||
assert.equal(typeof captured.fetch, 'function')
|
||||
// Unload: the inject fiber's disposer closes the gate (the wire flips back to inline).
|
||||
for (const d of disposers) d()
|
||||
assert.equal(gate.live, false)
|
||||
@@ -110,8 +131,8 @@ describe('watchDetailChannel gating', () => {
|
||||
get: (name: string) =>
|
||||
name === 'connection'
|
||||
? {
|
||||
rpc: {
|
||||
handle: () => {
|
||||
fetch: {
|
||||
register: () => {
|
||||
throw new Error('registration rejected')
|
||||
},
|
||||
},
|
||||
@@ -132,41 +153,40 @@ describe('watchDetailChannel gating', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the detail endpoint', () => {
|
||||
function liveCtx(state: TimelineState | undefined): { ctx: Context; captured: { handler?: Handler } } {
|
||||
describe('the detail route', () => {
|
||||
function liveCtx(state: TimelineState | undefined): { ctx: Context; captured: { fetch?: RouteFetch } } {
|
||||
return ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
stateOf: () => state,
|
||||
})
|
||||
}
|
||||
|
||||
test('unknown endpoints and bad payloads fail with typed envelopes', async () => {
|
||||
test('bad payloads fail with typed envelopes', async () => {
|
||||
const { ctx, captured } = liveCtx(undefined)
|
||||
watchDetailChannel(ctx, BOUNDS)
|
||||
const handler = captured.handler!
|
||||
assert.deepEqual(await handler('nope', { sessionId: 's1' }), {
|
||||
ok: false,
|
||||
error: { code: 'dsh-context/unknown-endpoint', message: 'unknown endpoint: nope', details: {} },
|
||||
})
|
||||
for (const payload of [null, 42, {}, { sessionId: 42 }, { sessionId: '' }]) {
|
||||
const result = await handler('detail', payload) as { ok: boolean; error?: { code: string } }
|
||||
assert.equal(result.ok, false, `payload ${JSON.stringify(payload)} rejected`)
|
||||
for (const body of [null, 42, {}, { sessionId: 42 }, { sessionId: '' }]) {
|
||||
const result = await call(captured, body) as { ok: boolean; error?: { code: string } }
|
||||
assert.equal(result.ok, false, `payload ${JSON.stringify(body)} rejected`)
|
||||
assert.equal(result.error?.code, 'dsh-context/bad-request')
|
||||
}
|
||||
// A non-JSON body degrades to the same typed rejection.
|
||||
const raw = await callRaw(captured, 'not json') as { ok: boolean; error?: { code: string } }
|
||||
assert.equal(raw.ok, false)
|
||||
assert.equal(raw.error?.code, 'dsh-context/bad-request')
|
||||
})
|
||||
|
||||
test('a session outside the live set resolves to the typed null', async () => {
|
||||
const { state } = driveTimeline([userMessage(1, [{ type: 'text', text: 'hi' }], { kind: 'user' })])
|
||||
const { ctx, captured } = liveCtx(state)
|
||||
watchDetailChannel(ctx, BOUNDS)
|
||||
assert.deepEqual(await captured.handler!('detail', { sessionId: 'gone' }), { ok: true, value: null })
|
||||
assert.deepEqual(await call(captured, { sessionId: 'gone' }), { ok: true, value: null })
|
||||
})
|
||||
|
||||
test('an absent unit state resolves to the typed null', async () => {
|
||||
const { ctx, captured } = liveCtx(undefined)
|
||||
watchDetailChannel(ctx, BOUNDS)
|
||||
assert.deepEqual(await captured.handler!('detail', { sessionId: 's1' }), { ok: true, value: null })
|
||||
assert.deepEqual(await call(captured, { sessionId: 's1' }), { ok: true, value: null })
|
||||
})
|
||||
|
||||
test('a cold session (never entered into the live store) folds its detail off the observed log', async () => {
|
||||
@@ -178,7 +198,7 @@ describe('the detail endpoint', () => {
|
||||
let disposed = 0
|
||||
let observedOptions: unknown
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
stateOf: () => undefined,
|
||||
sessionQuery: {
|
||||
@@ -191,24 +211,25 @@ describe('the detail endpoint', () => {
|
||||
},
|
||||
})
|
||||
watchDetailChannel(ctx, BOUNDS)
|
||||
const result = await captured.handler!('detail', { sessionId: 'cold1' }) as {
|
||||
const result = await call(captured, { sessionId: 'cold1' }) as {
|
||||
ok: boolean
|
||||
value: { rev: number; requests: unknown[]; nodes: unknown[] } | null
|
||||
value: { rev: number; requests: unknown[]; nodes: unknown[]; head: { current: { total: number } } } | null
|
||||
}
|
||||
assert.equal(result.ok, true)
|
||||
assert.ok(result.value !== null)
|
||||
assert.equal(result.value.requests.length, 1, 'the observed log folded')
|
||||
assert.equal(result.value.nodes.length, 2)
|
||||
assert.ok(result.value.head.current.total > 0, 'the slim head rides the cold fold (the graph ring fetch)')
|
||||
assert.deepEqual(observedOptions, { projectionMode: 'none' }, 'the observation skips projection work')
|
||||
assert.equal(disposed, 1, 'the observation lease disposed after the fold')
|
||||
// A session nothing can observe (the query resolves null) stays a typed null.
|
||||
assert.deepEqual(await captured.handler!('detail', { sessionId: 'nobody' }), { ok: true, value: null })
|
||||
assert.deepEqual(await call(captured, { sessionId: 'nobody' }), { ok: true, value: null })
|
||||
})
|
||||
|
||||
test('an observation without a dispose face still serves (nothing to release)', async () => {
|
||||
const events = [userMessage(1, [{ type: 'text', text: 'hi' }], { kind: 'user' })]
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
stateOf: () => undefined,
|
||||
sessionQuery: {
|
||||
@@ -216,7 +237,7 @@ describe('the detail endpoint', () => {
|
||||
},
|
||||
})
|
||||
watchDetailChannel(ctx, BOUNDS)
|
||||
const result = await captured.handler!('detail', { sessionId: 'cold1' }) as { ok: boolean; value: { nodes: unknown[] } | null }
|
||||
const result = await call(captured, { sessionId: 'cold1' }) as { ok: boolean; value: { nodes: unknown[] } | null }
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.value?.nodes.length, 1)
|
||||
})
|
||||
@@ -224,23 +245,23 @@ describe('the detail endpoint', () => {
|
||||
test('a sessionQuery without the observe face (or a rejecting read) degrades cleanly', async () => {
|
||||
// Face present but the method missing.
|
||||
const noFace = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
sessionQuery: {},
|
||||
})
|
||||
watchDetailChannel(noFace.ctx, BOUNDS)
|
||||
assert.deepEqual(await noFace.captured.handler!('detail', { sessionId: 'cold' }), { ok: true, value: null })
|
||||
assert.deepEqual(await call(noFace.captured, { sessionId: 'cold' }), { ok: true, value: null })
|
||||
|
||||
// The observation read rejects (persistence down).
|
||||
const rejecting = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
sessionQuery: {
|
||||
observeSession: () => Promise.reject(new Error('persistence down')),
|
||||
},
|
||||
})
|
||||
watchDetailChannel(rejecting.ctx, BOUNDS)
|
||||
const result = await rejecting.captured.handler!('detail', { sessionId: 'cold' }) as { ok: boolean; error: { code: string; message: string } }
|
||||
const result = await call(rejecting.captured, { sessionId: 'cold' }) as { ok: boolean; error: { code: string; message: string } }
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error.code, 'gateway/internal')
|
||||
assert.equal(result.error.message, 'persistence down')
|
||||
@@ -254,17 +275,18 @@ describe('the detail endpoint', () => {
|
||||
])
|
||||
const { ctx, captured } = liveCtx(state)
|
||||
watchDetailChannel(ctx, BOUNDS)
|
||||
const result = await captured.handler!('detail', { sessionId: 's1' }) as { ok: true; value: { rev: number; requests: unknown[]; nodes: unknown[] } }
|
||||
const result = await call(captured, { sessionId: 's1' }) as { ok: true; value: { rev: number; requests: unknown[]; nodes: unknown[]; head: { current: { total: number } } } }
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.value.rev, 2, 'two detail-mutating folds')
|
||||
assert.equal(result.value.requests.length, 1)
|
||||
assert.equal(result.value.nodes.length, 2)
|
||||
assert.ok(result.value.head.current.total > 0, 'the slim head rides the live read too')
|
||||
})
|
||||
|
||||
test('hostile reads degrade to the failure envelope, never a throw', async () => {
|
||||
// sessions.get throwing.
|
||||
const throwingGet = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: {
|
||||
get: () => {
|
||||
throw new Error('hostile sessions')
|
||||
@@ -272,37 +294,37 @@ describe('the detail endpoint', () => {
|
||||
},
|
||||
})
|
||||
watchDetailChannel(throwingGet.ctx, BOUNDS)
|
||||
const r1 = await throwingGet.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { code: string; message: string } }
|
||||
const r1 = await call(throwingGet.captured, { sessionId: 's1' }) as { ok: boolean; error: { code: string; message: string } }
|
||||
assert.equal(r1.ok, false)
|
||||
assert.equal(r1.error.code, 'gateway/internal')
|
||||
assert.equal(r1.error.message, 'hostile sessions')
|
||||
|
||||
// stateOf throwing.
|
||||
const throwingState = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
stateOf: () => {
|
||||
throw new Error('hostile registry')
|
||||
},
|
||||
})
|
||||
watchDetailChannel(throwingState.ctx, BOUNDS)
|
||||
const r2 = await throwingState.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { message: string } }
|
||||
const r2 = await call(throwingState.captured, { sessionId: 's1' }) as { ok: boolean; error: { message: string } }
|
||||
assert.equal(r2.ok, false)
|
||||
assert.equal(r2.error.message, 'hostile registry')
|
||||
|
||||
// A state whose collections are junk (the detail builder throws).
|
||||
const junkState = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: sessionsWith(okSession()),
|
||||
stateOf: () => ({ requests: null }),
|
||||
})
|
||||
watchDetailChannel(junkState.ctx, BOUNDS)
|
||||
const r3 = await junkState.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean }
|
||||
const r3 = await call(junkState.captured, { sessionId: 's1' }) as { ok: boolean }
|
||||
assert.equal(r3.ok, false)
|
||||
|
||||
// A non-Error rejection stringifies into the envelope.
|
||||
const stringThrow = ctxOf({
|
||||
connection: { rpc: { handle: () => () => {} } },
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
sessions: {
|
||||
get: () => {
|
||||
throw 'string boom'
|
||||
@@ -310,7 +332,7 @@ describe('the detail endpoint', () => {
|
||||
},
|
||||
})
|
||||
watchDetailChannel(stringThrow.ctx, BOUNDS)
|
||||
const r4 = await stringThrow.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { message: string } }
|
||||
const r4 = await call(stringThrow.captured, { sessionId: 's1' }) as { ok: boolean; error: { message: string } }
|
||||
assert.equal(r4.ok, false)
|
||||
assert.equal(r4.error.message, 'string boom')
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('buildTimelineHead', () => {
|
||||
})
|
||||
|
||||
describe('buildTimelineDetail', () => {
|
||||
test('serves the same collections the inline view serves, plus the revision', () => {
|
||||
test('serves the same collections the inline view serves, plus the revision and the head', () => {
|
||||
const { state, view } = driveTimeline(canonicalLog())
|
||||
const detail = buildTimelineDetail(state, resolveBounds({}))
|
||||
assert.equal(detail.rev, 6)
|
||||
@@ -155,6 +155,11 @@ describe('buildTimelineDetail', () => {
|
||||
assert.deepEqual(detail.archive, view.archive)
|
||||
assert.equal(detail.surfaceFloor, view.surfaceFloor)
|
||||
assert.equal(detail.archiveFloor, view.archiveFloor)
|
||||
// The slim head rides at the SAME cut: the composition the Agent
|
||||
// network's cold-node ring fetch renders from.
|
||||
assert.ok(detail.head !== undefined)
|
||||
assert.deepEqual(detail.head.current, view.current)
|
||||
assert.deepEqual(detail.head.counts, buildTimelineHead(state).counts)
|
||||
assertPlainJson(detail)
|
||||
// The detail never aliases the persisted state.
|
||||
assert.notEqual(detail.requests[0], state.requests[0])
|
||||
@@ -206,7 +211,7 @@ describe('the split wire generation (definition slim flag)', () => {
|
||||
const detail = buildTimelineDetail(state, resolveBounds({}))
|
||||
const inline = timelineDef({}, false).wire.view(state)
|
||||
const { counts: _counts, last: _last, detailRev: _rev, ...headScalars } = head
|
||||
const { rev: _detailRev, ...collections } = detail
|
||||
const { rev: _detailRev, head: _detailHead, ...collections } = detail
|
||||
assert.deepEqual(inline, { ...headScalars, ...collections })
|
||||
})
|
||||
})
|
||||
@@ -184,13 +184,13 @@ describe('dsh-context host plugin', () => {
|
||||
assert.equal(snapshot.values.contextHeaders, undefined)
|
||||
})
|
||||
|
||||
test('the split generation with the detail channel live: slim wire head + the endpoint serves the collections', async () => {
|
||||
test('the split generation with the detail route live: slim wire head + the endpoint serves the collections', async () => {
|
||||
const ctx = new Context()
|
||||
let handler: ((endpoint: string, payload: unknown) => Promise<unknown>) | undefined
|
||||
let route: { path?: string; fetch?: (request: Request) => Promise<Response> } | undefined
|
||||
ctx.provide('connection', {
|
||||
rpc: {
|
||||
handle: (_channel: string, h: never) => {
|
||||
handler = h
|
||||
fetch: {
|
||||
register: (r: never) => {
|
||||
route = r
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
@@ -214,8 +214,14 @@ describe('dsh-context host plugin', () => {
|
||||
assert.equal(timeline.nodes.length, 0, 'the collections stay off the wire value')
|
||||
assert.equal(timeline.requests.length, 0)
|
||||
|
||||
assert.ok(handler !== undefined, 'the detail channel registered')
|
||||
const result = await handler('detail', { sessionId: session.header.id }) as {
|
||||
assert.ok(route !== undefined && route.fetch !== undefined, 'the detail route registered')
|
||||
assert.equal(route.path, '/api/dsh-context/detail')
|
||||
const response = await route.fetch(new Request('http://dsh.test/api/dsh-context/detail', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: session.header.id }),
|
||||
}))
|
||||
const result = await response.json() as {
|
||||
ok: boolean
|
||||
value: { rev: number; nodes: unknown[]; requests: unknown[] } | null
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user