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
|
): 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 {
|
export interface ProjectionsBaseline {
|
||||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||||
asOfSeq: number
|
asOfSeq: number
|
||||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
/** 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). */
|
/** 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.
|
* @param baseline - the response's projections block.
|
||||||
*/
|
*/
|
||||||
resetBaseline(baseline: ProjectionsBaseline): void {
|
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) {
|
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
|
this.openError = result.error
|
||||||
return
|
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.
|
// 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()
|
const tailSeq = this.windowTailSeq()
|
||||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||||
if (generation !== this.openGeneration) return
|
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'
|
this.openState = 'open'
|
||||||
} catch (error) {
|
} 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 })
|
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.
|
// 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') {
|
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) {
|
} catch (error) {
|
||||||
console.error('[web-runtime] gap repair failed:', error)
|
console.error('[web-runtime] gap repair failed:', error)
|
||||||
@@ -850,19 +850,3 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
|
|||||||
if (hasContent) return 'active'
|
if (hasContent) return 'active'
|
||||||
return promptAttempted ? 'engaging' : 'blank'
|
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', () => {
|
it('degrades a baseline payload failing schema validation to absent instead of poisoning the cell', () => {
|
||||||
const { set, cell } = bench()
|
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()
|
expect(cell.getSnapshot()).toBeUndefined()
|
||||||
// The watermark still advanced to the cut: pre-cut events stay dropped.
|
// The watermark still advanced to the cut: pre-cut events stay dropped.
|
||||||
set.offerEvent(markEvent(8, ['pre-cut']))
|
set.offerEvent(markEvent(8, ['pre-cut']))
|
||||||
|
|||||||
Reference in New Issue
Block a user