1
/** Pure replay fold and strict decoder for durable goal changes. */3
import type { MessageSource } from '@deepseek-ai/dsh-llm'4
import type { SessionEvent } from '@deepseek-ai/dsh-session'5
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'6
import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'7
import type {8
FoldedGoal,9
GoalChangeMeta,10
GoalClearChangeMeta,11
GoalMessageSource,12
GoalOperation,13
GoalSnapshotChangeMeta,14
} from './domain.ts'16
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([17
'create',18
'edit',19
'pause',20
'resume',21
'complete',22
'block',23
])24
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])26
/** Mutable accumulator kept private to the pure fold. */27
export interface GoalFoldState {28
goal: GoalSnapshot | undefined29
roundsStarted: number30
createdAt: number | undefined31
updatedAt: number | undefined32
lastRef: GoalRef | undefined33
seenGoalIds: Set<GoalSnapshot['id']>34
}36
/**37
* Build an empty replay accumulator.38
* @returns mutable state with no current goal or prior ref.39
*/40
export function emptyGoalFoldState(): GoalFoldState {41
return {42
goal: undefined,43
roundsStarted: 0,44
createdAt: undefined,45
updatedAt: undefined,46
lastRef: undefined,47
seenGoalIds: new Set(),48
}49
}51
/** Whether a value is a JSON record rather than an array. */52
function isRecord(value: unknown): value is Record<string, unknown> {53
return typeof value === 'object' && value !== null && !Array.isArray(value)54
}56
/** Require one positive safe integer. */57
function positiveInteger(value: unknown, field: string): number {58
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {59
throw new Error(`goal change ${field} must be a positive safe integer`)60
}61
return value62
}64
/** Require one non-negative safe integer. */65
function nonNegativeInteger(value: unknown, field: string): number {66
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {67
throw new Error(`goal change ${field} must be a non-negative safe integer`)68
}69
return value70
}72
/** Decode one canonical blocker explanation. */73
function decodeBlockReason(value: unknown): GoalBlockReason {74
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {75
throw new Error('goal change goal.blockedReason must have exactly code and message fields')76
}77
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {78
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')79
}80
if (typeof value['message'] !== 'string' || value['message'].trim().length === 081
|| value['message'] !== value['message'].trim()) {82
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')83
}84
return { code: value['code'], message: value['message'] }85
}87
/** Decode and validate one snapshot. */88
function decodeSnapshot(value: unknown): GoalSnapshot {89
if (!isRecord(value)) throw new Error('goal change goal must be a record')90
if (typeof value['id'] !== 'string' || value['id'].length === 0) {91
throw new Error('goal change goal.id must be a non-empty string')92
}93
if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 094
|| value['objective'] !== value['objective'].trim()) {95
throw new Error('goal change goal.objective must be non-empty and normalized')96
}97
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {98
throw new Error('goal change goal.phase is invalid')99
}100
const phase = value['phase'] as GoalPhase101
const expectedKeys = phase === 'blocked'102
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'103
: 'id,maxGoalRounds,objective,phase,revision'104
if (Object.keys(value).sort().join(',') !== expectedKeys) {105
throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`)106
}107
return {108
id: GoalId(value['id']),109
revision: positiveInteger(value['revision'], 'goal.revision'),110
objective: value['objective'],111
phase,112
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),113
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},114
}115
}117
/** Decode and validate one ref. */118
function decodeRef(value: unknown): GoalRef {119
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') {120
throw new Error('goal clear tombstone must have exactly id and revision fields')121
}122
if (typeof value['id'] !== 'string' || value['id'].length === 0) {123
throw new Error('goal clear tombstone id must be a non-empty string')124
}125
return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') }126
}128
/**129
* Decode a value that declares itself as a goal change. Unrelated values130
* return `undefined`; malformed goal changes fail replay loudly.131
* @param value - candidate source change.132
* @returns validated goal change or `undefined` for another value kind.133
*/134
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {135
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined136
if (value['version'] !== GOAL_CHANGE_VERSION) {137
throw new Error(`unsupported goal change version ${String(value['version'])}`)138
}139
if (value['operation'] === 'clear') {140
const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version']141
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {142
throw new Error(`goal clear change must have exactly ${allowed.sort().join(',')} fields`)143
}144
return {145
kind: 'goal/change',146
version: GOAL_CHANGE_VERSION,147
operation: 'clear',148
cleared: decodeRef(value['cleared']),149
clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'),150
} satisfies GoalClearChangeMeta151
}152
if (typeof value['operation'] !== 'string'153
|| !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude<GoalOperation, 'clear'>)) {154
throw new Error('goal change operation is invalid')155
}156
const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']157
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {158
throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(',')} fields`)159
}160
const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')161
const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')162
if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')163
return {164
kind: 'goal/change',165
version: GOAL_CHANGE_VERSION,166
operation: value['operation'] as Exclude<GoalOperation, 'clear'>,167
goal: decodeSnapshot(value['goal']),168
roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),169
createdAt,170
updatedAt,171
} satisfies GoalSnapshotChangeMeta172
}174
/** Narrow model attribution to a valid goal source. */175
function goalSource(source: MessageSource): GoalMessageSource | undefined {176
if (source.kind !== 'goal') return undefined177
if (typeof source.goalId !== 'string' || source.goalId.length === 0178
|| !Number.isSafeInteger(source.revision) || source.revision < 1179
|| !Number.isSafeInteger(source.round) || source.round < 1) {180
throw new Error('goal message source is invalid')181
}182
return source183
}185
/** Require two snapshots to retain fields that only `edit` may replace. */186
function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {187
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {188
throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)189
}190
}192
/** Require one exact next revision of the current goal. */193
function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {194
if (next.id !== current.id || next.revision !== current.revision + 1) {195
throw new Error(`goal ${operation} must advance the current goal by one revision`)196
}197
}199
/** Validate one non-create snapshot operation against the preceding projection. */200
function validateSnapshotTransition(201
state: GoalFoldState,202
change: GoalSnapshotChangeMeta,203
current: GoalSnapshot,204
): void {205
const next = change.goal206
requireNextRevision(current, next, change.operation)207
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */208
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')209
if (change.createdAt !== state.createdAt210
|| change.updatedAt < state.updatedAt211
|| change.roundsStarted !== state.roundsStarted) {212
throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)213
}214
switch (change.operation) {215
case 'edit':216
if (next.phase !== current.phase217
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {218
throw new Error('goal edit cannot change phase or blocked reason')219
}220
break221
case 'pause':222
requireSameDefinition(current, next, change.operation)223
if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition')224
break225
case 'resume': {226
requireSameDefinition(current, next, change.operation)227
const resumable: ReadonlySet<GoalPhase> = new Set([228
'active',229
'paused',230
'blocked',231
])232
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {233
throw new Error('goal resume has an invalid phase transition or exhausted round budget')234
}235
break236
}237
case 'complete':238
requireSameDefinition(current, next, change.operation)239
if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition')240
break241
case 'block':242
requireSameDefinition(current, next, change.operation)243
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')244
break245
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */246
case 'create':247
throw new Error('goal create cannot be validated as a current-goal transition')248
default:249
change.operation satisfies never250
throw new Error('unknown goal snapshot operation')251
/* v8 ignore stop */252
}253
}255
/**256
* Return the revision identity carried by a snapshot or tombstone.257
* @param change - decoded goal mutation.258
* @returns stable identity used to reconcile a deferred change with its log event.259
*/260
export function goalChangeRef(change: GoalChangeMeta): GoalRef {261
return change.operation === 'clear'262
? change.cleared263
: { id: change.goal.id, revision: change.goal.revision }264
}266
/**267
* Validate and apply one decoded change to a mutable accumulator.268
* @param state - preceding durable goal projection.269
* @param change - decoded full snapshot or clear tombstone.270
*/271
export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {272
const ref = goalChangeRef(change)273
if (change.operation === 'clear') {274
const current = state.goal275
if (current === undefined) throw new Error('goal clear requires a current goal')276
requireNextRevision(current, change.cleared, change.operation)277
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */278
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')279
if (change.clearedAt < state.updatedAt) {280
throw new Error('goal clear timestamp cannot precede the current goal update')281
}282
state.goal = undefined283
state.roundsStarted = 0284
state.createdAt = undefined285
state.updatedAt = undefined286
state.lastRef = ref287
return288
}289
if (change.operation === 'create') {290
if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0291
|| (state.goal !== undefined && state.goal.phase !== 'complete')292
|| state.seenGoalIds.has(change.goal.id)) {293
throw new Error('goal create requires a fresh active revision-one goal with zero rounds')294
}295
state.seenGoalIds.add(change.goal.id)296
} else {297
const current = state.goal298
if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)299
validateSnapshotTransition(state, change, current)300
}301
state.goal = change.goal302
state.roundsStarted = change.roundsStarted303
state.createdAt = change.createdAt304
state.updatedAt = change.updatedAt305
state.lastRef = ref306
}308
/**309
* Apply one session event to the strict durable goal fold.310
* @param state - mutable fold accumulator.311
* @param event - next event in sequence order.312
*/313
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {314
if (event.type === 'goal/change') {315
const change = decodeGoalChange(event.data)316
/* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */317
if (change === undefined) throw new Error(`goal change at session event ${event.seq} has an invalid kind`)318
applyGoalChange(state, change)319
return320
}321
if (event.type === 'user/message') {322
const source = goalSource(event.data.source)323
if (source === undefined) return324
const current = state.goal325
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id326
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1327
|| source.round > current.maxGoalRounds) {328
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)329
}330
state.roundsStarted = source.round331
}332
}334
/**335
* Fold current goal state from a contiguous session event log.336
* @param events - session events in sequence order.337
* @returns a fresh durable projection; activation is deliberately absent.338
*/339
export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {340
const state = emptyGoalFoldState()341
for (const event of events) applyGoalEvent(state, event)342
return {343
...state.goal === undefined ? {} : { goal: { ...state.goal } },344
roundsStarted: state.roundsStarted,345
...state.createdAt === undefined ? {} : { createdAt: state.createdAt },346
...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },347
...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },348
}349
}