返回源码地图

packages/goal/goal/src/fold.ts

main snapshot · da00f7f5358f · 正文引用章节 13;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** Pure replay fold and strict decoder for durable goal changes. */
2
3import type { MessageSource } from '@deepseek-ai/dsh-llm'
4import type { SessionEvent } from '@deepseek-ai/dsh-session'
5import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
6import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
7import type {
8 FoldedGoal,
9 GoalChangeMeta,
10 GoalClearChangeMeta,
11 GoalMessageSource,
12 GoalOperation,
13 GoalSnapshotChangeMeta,
14} from './domain.ts'
15
16const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
17 'create',
18 'edit',
19 'pause',
20 'resume',
21 'complete',
22 'block',
23])
24const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
25
26/** Mutable accumulator kept private to the pure fold. */
27export interface GoalFoldState {
28 goal: GoalSnapshot | undefined
29 roundsStarted: number
30 createdAt: number | undefined
31 updatedAt: number | undefined
32 lastRef: GoalRef | undefined
33 seenGoalIds: Set<GoalSnapshot['id']>
34}
35
36/**
37 * Build an empty replay accumulator.
38 * @returns mutable state with no current goal or prior ref.
39 */
40export 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}
50
51/** Whether a value is a JSON record rather than an array. */
52function isRecord(value: unknown): value is Record<string, unknown> {
53 return typeof value === 'object' && value !== null && !Array.isArray(value)
54}
55
56/** Require one positive safe integer. */
57function 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 value
62}
63
64/** Require one non-negative safe integer. */
65function 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 value
70}
71
72/** Decode one canonical blocker explanation. */
73function 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 === 0
81 || 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}
86
87/** Decode and validate one snapshot. */
88function 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 === 0
94 || 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 GoalPhase
101 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}
116
117/** Decode and validate one ref. */
118function 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}
127
128/**
129 * Decode a value that declares itself as a goal change. Unrelated values
130 * 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 */
134export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
135 if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
136 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 GoalClearChangeMeta
151 }
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 GoalSnapshotChangeMeta
172}
173
174/** Narrow model attribution to a valid goal source. */
175function goalSource(source: MessageSource): GoalMessageSource | undefined {
176 if (source.kind !== 'goal') return undefined
177 if (typeof source.goalId !== 'string' || source.goalId.length === 0
178 || !Number.isSafeInteger(source.revision) || source.revision < 1
179 || !Number.isSafeInteger(source.round) || source.round < 1) {
180 throw new Error('goal message source is invalid')
181 }
182 return source
183}
184
185/** Require two snapshots to retain fields that only `edit` may replace. */
186function 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}
191
192/** Require one exact next revision of the current goal. */
193function 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}
198
199/** Validate one non-create snapshot operation against the preceding projection. */
200function validateSnapshotTransition(
201 state: GoalFoldState,
202 change: GoalSnapshotChangeMeta,
203 current: GoalSnapshot,
204): void {
205 const next = change.goal
206 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.createdAt
210 || change.updatedAt < state.updatedAt
211 || 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.phase
217 || JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
218 throw new Error('goal edit cannot change phase or blocked reason')
219 }
220 break
221 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 break
225 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 break
236 }
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 break
241 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 break
245 /* 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 never
250 throw new Error('unknown goal snapshot operation')
251 /* v8 ignore stop */
252 }
253}
254
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 */
260export function goalChangeRef(change: GoalChangeMeta): GoalRef {
261 return change.operation === 'clear'
262 ? change.cleared
263 : { id: change.goal.id, revision: change.goal.revision }
264}
265
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 */
271export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {
272 const ref = goalChangeRef(change)
273 if (change.operation === 'clear') {
274 const current = state.goal
275 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 = undefined
283 state.roundsStarted = 0
284 state.createdAt = undefined
285 state.updatedAt = undefined
286 state.lastRef = ref
287 return
288 }
289 if (change.operation === 'create') {
290 if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0
291 || (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.goal
298 if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)
299 validateSnapshotTransition(state, change, current)
300 }
301 state.goal = change.goal
302 state.roundsStarted = change.roundsStarted
303 state.createdAt = change.createdAt
304 state.updatedAt = change.updatedAt
305 state.lastRef = ref
306}
307
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 */
313export 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 return
320 }
321 if (event.type === 'user/message') {
322 const source = goalSource(event.data.source)
323 if (source === undefined) return
324 const current = state.goal
325 if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
326 || source.revision !== current.revision || source.round !== state.roundsStarted + 1
327 || 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.round
331 }
332}
333
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 */
339export 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}