refactor(gui): read the typed projections block off the history response
The wire type now carries projections?: SessionProjectionsBlock (host-base landed), so the structural projectionsOf narrowing and its TODO(gui) go away — Session reads result.value.projections directly at all three installWindow sites. ProjectionsBaseline stays as the cell framework's structural twin (React-free layer keeps depending on the type table only) with values typed Partial<SessionProjectionMap>; the erased walk moves inside resetBaseline where per-key typing is re-established by schema.parse.
This commit is contained in:
@@ -68,12 +68,17 @@ export type UseProjection = {
|
||||
): S
|
||||
}
|
||||
|
||||
/** Tail-page projections baseline (structural wire mirror; the zod schema lands with the host-base PR). */
|
||||
/**
|
||||
* Tail-page projections baseline — structurally identical to the wire's
|
||||
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
|
||||
* React-free cell framework depends only on the type table, not the wire
|
||||
* package's response vocabulary.
|
||||
*/
|
||||
export interface ProjectionsBaseline {
|
||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||
asOfSeq: number
|
||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
||||
values: Record<string, unknown>
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** Type-erased spec view the framework machinery works with (the register seam already proved the typed contract). */
|
||||
@@ -215,8 +220,11 @@ export class ProjectionCellSet {
|
||||
* @param baseline - the response's projections block.
|
||||
*/
|
||||
resetBaseline(baseline: ProjectionsBaseline): void {
|
||||
// Erased view: the framework walks the open key space; per-key typing
|
||||
// lives at the cell spec seam (schema.parse re-establishes it).
|
||||
const values = baseline.values as Record<string, unknown>
|
||||
for (const [key, cell] of this.cells) {
|
||||
cell.resetBaseline(Object.hasOwn(baseline.values, key), baseline.values[key], baseline.asOfSeq)
|
||||
cell.resetBaseline(Object.hasOwn(values, key), values[key], baseline.asOfSeq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,13 +495,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value))
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value))
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -590,7 +590,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value))
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -850,19 +850,3 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
|
||||
if (hasContent) return 'active'
|
||||
return promptAttempted ? 'engaging' : 'blank'
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural read of the optional projections block on a history response.
|
||||
* TODO(gui): drop this narrowing once the host-base PR (dsh-session-projection
|
||||
* + apiproxy block) lands and the wire type carries `projections` — parallel
|
||||
* construction posture, same as the code-dispatch event narrowing above.
|
||||
* @param value - the history response value.
|
||||
* @returns the block, or undefined (loadOlder pages and blockless deployments).
|
||||
*/
|
||||
function projectionsOf(value: object): ProjectionsBaseline | undefined {
|
||||
const block = (value as { projections?: ProjectionsBaseline }).projections
|
||||
if (block === undefined) return undefined
|
||||
return typeof block.asOfSeq === 'number' && typeof block.values === 'object' && block.values !== null
|
||||
? block
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -95,7 +95,9 @@ describe('ProjectionCellSet semantics', () => {
|
||||
|
||||
it('degrades a baseline payload failing schema validation to absent instead of poisoning the cell', () => {
|
||||
const { set, cell } = bench()
|
||||
set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' } })
|
||||
// Deliberately malformed wire payload: the typed block cannot express it,
|
||||
// which is exactly why the boundary schema exists.
|
||||
set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' as never } })
|
||||
expect(cell.getSnapshot()).toBeUndefined()
|
||||
// The watermark still advanced to the cut: pre-cut events stay dropped.
|
||||
set.offerEvent(markEvent(8, ['pre-cut']))
|
||||
|
||||
Reference in New Issue
Block a user