@deepseek-ai/dsh-goal 0.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +58 -0
- package/README.zh.md +58 -0
- package/lib/index.js +827 -0
- package/lib/invariant.js +332 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +783 -0
- package/lib/typert.remote-client.d.ts +40 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +366 -0
- package/lib/types/client.d.ts +10 -0
- package/lib/types/client.js +10 -0
- package/lib/types/domain.d.ts +92 -0
- package/lib/types/domain.js +10 -0
- package/lib/types/fold.d.ts +50 -0
- package/lib/types/fold.js +322 -0
- package/lib/types/index.d.ts +155 -0
- package/lib/types/index.js +495 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/invariant.js +70 -0
- package/lib/types/runtime.d.ts +21 -0
- package/lib/types/runtime.js +25 -0
- package/lib/types/types.d.ts +96 -0
- package/lib/types/types.js +13 -0
- package/package.json +84 -0
- package/src/client.ts +10 -0
- package/src/domain.ts +116 -0
- package/src/fold.ts +349 -0
- package/src/index.ts +592 -0
- package/src/invariant.ts +79 -0
- package/src/runtime.ts +30 -0
- package/src/types.ts +112 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-session goal domain: event-sourced state, compare-and-set mutations,
|
|
3
|
+
* and process-local continuation activation.
|
|
4
|
+
* @module @deepseek-ai/dsh-goal
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
import { z as zod } from 'zod'
|
|
11
|
+
import type { ZodType } from 'zod'
|
|
12
|
+
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
|
13
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
14
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
15
|
+
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
|
|
16
|
+
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-session-projection'
|
|
18
|
+
import {
|
|
19
|
+
applyGoalEvent,
|
|
20
|
+
decodeGoalChange,
|
|
21
|
+
emptyGoalFoldState,
|
|
22
|
+
goalChangeRef,
|
|
23
|
+
} from './fold.ts'
|
|
24
|
+
import type { GoalFoldState } from './fold.ts'
|
|
25
|
+
import {
|
|
26
|
+
GOAL_CHANGE_VERSION,
|
|
27
|
+
GoalError,
|
|
28
|
+
GoalId,
|
|
29
|
+
} from './runtime.ts'
|
|
30
|
+
import type {
|
|
31
|
+
CreateGoalRequest,
|
|
32
|
+
CreateGoalResult,
|
|
33
|
+
EditGoalRequest,
|
|
34
|
+
GoalActivation,
|
|
35
|
+
GoalBlockReason,
|
|
36
|
+
GoalPhase,
|
|
37
|
+
GoalProjection,
|
|
38
|
+
GoalRef,
|
|
39
|
+
GoalSnapshot,
|
|
40
|
+
GoalView,
|
|
41
|
+
} from './types.ts'
|
|
42
|
+
import type {
|
|
43
|
+
GoalChangeMeta,
|
|
44
|
+
GoalChanged,
|
|
45
|
+
GoalClearChangeMeta,
|
|
46
|
+
GoalOperation,
|
|
47
|
+
GoalSnapshotChangeMeta,
|
|
48
|
+
} from './domain.ts'
|
|
49
|
+
|
|
50
|
+
// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key
|
|
51
|
+
// declaration) re-exported onto the package root keeps the module edge in
|
|
52
|
+
// the emitted index.d.ts, so aggregate programs consuming the declarations
|
|
53
|
+
// still receive the SessionProjectionMap merge.
|
|
54
|
+
export type * from './types.ts'
|
|
55
|
+
export type * from './domain.ts'
|
|
56
|
+
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
|
|
57
|
+
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
|
|
58
|
+
|
|
59
|
+
declare module '@deepseek-ai/cordis' {
|
|
60
|
+
interface Context {
|
|
61
|
+
goals: GoalService
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Wire payload schema of the `goal` projection (whole current goal or pre-create/cleared null). */
|
|
66
|
+
const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
|
|
67
|
+
zod.object({
|
|
68
|
+
goal: zod.object({
|
|
69
|
+
id: zod.string().min(1),
|
|
70
|
+
revision: zod.number().int().positive(),
|
|
71
|
+
objective: zod.string().min(1),
|
|
72
|
+
phase: zod.union([zod.literal('active'), zod.literal('paused'), zod.literal('blocked'), zod.literal('complete')]),
|
|
73
|
+
blockedReason: zod.object({ code: zod.string(), message: zod.string() }).optional(),
|
|
74
|
+
maxGoalRounds: zod.number().int().positive(),
|
|
75
|
+
}),
|
|
76
|
+
roundsStarted: zod.number().int().nonnegative(),
|
|
77
|
+
createdAt: zod.number(),
|
|
78
|
+
updatedAt: zod.number(),
|
|
79
|
+
}),
|
|
80
|
+
zod.null(),
|
|
81
|
+
]) as ZodType<GoalProjection | null>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Light last-wins fold of the `goal` projection unit. Unlike the strict
|
|
85
|
+
* replay fold (fold.ts: transition validation, fail-loud on malformed
|
|
86
|
+
* changes, Set-typed state), this transition is projection-grade: the state
|
|
87
|
+
* is plain JSON (persisted-cache precondition), any non-goal or malformed
|
|
88
|
+
* event returns the same reference (the registry's Object.is gate — the
|
|
89
|
+
* title/todos posture), and correctness of the written change is the write
|
|
90
|
+
* side's job (GoalService validated it before appending; the package
|
|
91
|
+
* invariant rejects a violating stream fail-loud where it is installed).
|
|
92
|
+
* @param state - the projection covering all prior events.
|
|
93
|
+
* @param event - the next committed session event.
|
|
94
|
+
* @returns the next projection (same reference when the event is not a goal change).
|
|
95
|
+
*/
|
|
96
|
+
export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
|
|
97
|
+
if (event.type !== 'goal/change') return state
|
|
98
|
+
let change: GoalChangeMeta | undefined
|
|
99
|
+
try {
|
|
100
|
+
change = decodeGoalChange(event.data)
|
|
101
|
+
} catch (_invalidPersistedGoalChange) {
|
|
102
|
+
return state
|
|
103
|
+
}
|
|
104
|
+
if (change === undefined) return state
|
|
105
|
+
return change.operation === 'clear'
|
|
106
|
+
? null
|
|
107
|
+
: {
|
|
108
|
+
goal: change.goal,
|
|
109
|
+
roundsStarted: change.roundsStarted,
|
|
110
|
+
createdAt: change.createdAt,
|
|
111
|
+
updatedAt: change.updatedAt,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Deployment defaults for goal creation. */
|
|
116
|
+
export interface Config {
|
|
117
|
+
/** Total rounds used when a create request omits its own cap. */
|
|
118
|
+
defaultMaxGoalRounds?: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolved defaults. */
|
|
122
|
+
export interface ResolvedConfig {
|
|
123
|
+
/** Validated positive safe-integer default round cap. */
|
|
124
|
+
defaultMaxGoalRounds: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Process-local cache plus activation intent crossing the synchronous append boundary. */
|
|
128
|
+
interface GoalCache {
|
|
129
|
+
readonly state: GoalFoldState
|
|
130
|
+
activation: GoalActivation
|
|
131
|
+
observedSeq: number
|
|
132
|
+
pendingActivation: { readonly seq: number; readonly activation: GoalActivation } | undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Validated create input with every deployment default materialized. */
|
|
136
|
+
interface ResolvedCreateGoal {
|
|
137
|
+
readonly objective: string
|
|
138
|
+
readonly maxGoalRounds: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Validate a caller-visible positive safe-integer round cap. */
|
|
142
|
+
function resolveMaxGoalRounds(value: number): number {
|
|
143
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
144
|
+
throw new GoalError('maxGoalRounds must be a positive safe integer', 'GOAL_INVALID_MAX_ROUNDS')
|
|
145
|
+
}
|
|
146
|
+
return value
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Validate and normalize an objective at the domain boundary. */
|
|
150
|
+
function resolveObjective(value: string): string {
|
|
151
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
152
|
+
throw new GoalError('goal objective must be a non-empty string', 'GOAL_INVALID_OBJECTIVE')
|
|
153
|
+
}
|
|
154
|
+
return value.trim()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Materialize deployment defaults and validate one create request. */
|
|
158
|
+
function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal {
|
|
159
|
+
return {
|
|
160
|
+
objective: resolveObjective(request.objective),
|
|
161
|
+
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Validate and detach one policy-owned blocker explanation. */
|
|
166
|
+
function resolveBlockReason(reason: unknown): GoalBlockReason {
|
|
167
|
+
const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason)
|
|
168
|
+
? reason as Record<string, unknown>
|
|
169
|
+
: undefined
|
|
170
|
+
const code = record?.['code']
|
|
171
|
+
const message = record?.['message']
|
|
172
|
+
if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)
|
|
173
|
+
|| typeof message !== 'string' || message.trim().length === 0) {
|
|
174
|
+
throw new GoalError(
|
|
175
|
+
'goal block reason requires a lower-kebab-case code and a non-empty message',
|
|
176
|
+
'GOAL_INVALID_BLOCK_REASON',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
return { code, message: message.trim() }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
|
|
183
|
+
export class GoalService extends GatewayService {
|
|
184
|
+
static inject = ['agents']
|
|
185
|
+
|
|
186
|
+
static Config: z<Config> = z.object({
|
|
187
|
+
defaultMaxGoalRounds: z.number().default(256),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
private readonly resolved: ResolvedConfig
|
|
191
|
+
private readonly caches = new WeakMap<Session, GoalCache>()
|
|
192
|
+
|
|
193
|
+
constructor(ctx: Context, config: Config = {}) {
|
|
194
|
+
super(ctx, 'goals')
|
|
195
|
+
this.resolved = {
|
|
196
|
+
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
|
|
197
|
+
}
|
|
198
|
+
ctx.on('agent/session-start', ({ agent }) => {
|
|
199
|
+
this.cache(agent.session).activation = 'disarmed'
|
|
200
|
+
})
|
|
201
|
+
// The `goal` projection unit: last-wins fold of goal/change whole values
|
|
202
|
+
// (see applyGoalProjection). The unit child activates only when a
|
|
203
|
+
// projection registry is composed (headless assemblies stay unaffected).
|
|
204
|
+
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
|
205
|
+
projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
|
|
206
|
+
key: 'goal',
|
|
207
|
+
schema: goalProjectionSchema,
|
|
208
|
+
init: () => null,
|
|
209
|
+
apply: applyGoalProjection,
|
|
210
|
+
view: state => state,
|
|
211
|
+
stateVersion: 4,
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read the current goal for one exact live agent.
|
|
218
|
+
* @param agent - owning live agent.
|
|
219
|
+
* @returns a fresh view or `undefined` when no goal is current.
|
|
220
|
+
* @throws {@link GoalError} when the agent is not the registry's live instance.
|
|
221
|
+
*/
|
|
222
|
+
get(agent: Agent): GoalView | undefined {
|
|
223
|
+
this.assertLive(agent)
|
|
224
|
+
const cache = this.cache(agent.session)
|
|
225
|
+
this.sync(agent.session, cache)
|
|
226
|
+
return this.view(cache)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Remove process-local continuation authority without changing durable goal
|
|
231
|
+
* phase or revision. Lifecycle owners use this before unloading a driver;
|
|
232
|
+
* a later human-authorized {@link resume} records the new activation edge.
|
|
233
|
+
* @param agent - owning live agent.
|
|
234
|
+
* @returns a fresh disarmed view, or `undefined` when no goal is current.
|
|
235
|
+
*/
|
|
236
|
+
disarm(agent: Agent): GoalView | undefined {
|
|
237
|
+
this.assertLive(agent)
|
|
238
|
+
const cache = this.cache(agent.session)
|
|
239
|
+
this.sync(agent.session, cache)
|
|
240
|
+
cache.activation = 'disarmed'
|
|
241
|
+
return this.view(cache)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create and arm a goal. A completed goal may be replaced; every other
|
|
246
|
+
* current phase must be cleared or resumed instead.
|
|
247
|
+
* @param agent - owning live agent.
|
|
248
|
+
* @param request - objective and optional round cap.
|
|
249
|
+
* @returns the created live view.
|
|
250
|
+
*/
|
|
251
|
+
create(agent: Agent, request: CreateGoalRequest): GoalView {
|
|
252
|
+
const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds)
|
|
253
|
+
const cache = this.prepareMutation(agent)
|
|
254
|
+
const current = cache.state.goal
|
|
255
|
+
if (current !== undefined && current.phase !== 'complete') {
|
|
256
|
+
throw new GoalError(`goal "${current.id}" already exists with phase "${current.phase}"`, 'GOAL_ALREADY_EXISTS')
|
|
257
|
+
}
|
|
258
|
+
const now = Date.now()
|
|
259
|
+
const goal: GoalSnapshot = {
|
|
260
|
+
id: GoalId(`goal-${randomUUID()}`),
|
|
261
|
+
revision: 1,
|
|
262
|
+
objective: spec.objective,
|
|
263
|
+
phase: 'active',
|
|
264
|
+
maxGoalRounds: spec.maxGoalRounds,
|
|
265
|
+
}
|
|
266
|
+
return this.commitSnapshot(agent, cache, 'create', goal, 0, now, now, 'armed')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Edit objective and/or round cap without changing phase.
|
|
271
|
+
* @param agent - owning live agent.
|
|
272
|
+
* @param ref - expected current revision.
|
|
273
|
+
* @param request - at least one replacement field.
|
|
274
|
+
* @returns the edited view.
|
|
275
|
+
*/
|
|
276
|
+
@Remote('edit')
|
|
277
|
+
edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView {
|
|
278
|
+
const cache = this.prepareMutation(agent)
|
|
279
|
+
const current = this.expectCurrent(cache, ref)
|
|
280
|
+
if (request.objective === undefined && request.maxGoalRounds === undefined) {
|
|
281
|
+
throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT')
|
|
282
|
+
}
|
|
283
|
+
const goal: GoalSnapshot = {
|
|
284
|
+
...current,
|
|
285
|
+
revision: current.revision + 1,
|
|
286
|
+
...request.objective === undefined ? {} : { objective: resolveObjective(request.objective) },
|
|
287
|
+
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds) },
|
|
288
|
+
}
|
|
289
|
+
return this.commitCurrent(agent, cache, 'edit', goal, cache.activation)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Pause an active goal and disarm automatic continuation.
|
|
294
|
+
* @param agent - owning live agent.
|
|
295
|
+
* @param ref - expected current revision.
|
|
296
|
+
* @returns the paused view.
|
|
297
|
+
*/
|
|
298
|
+
@Remote('pause')
|
|
299
|
+
pause(agent: Agent, ref: GoalRef): GoalView {
|
|
300
|
+
return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resume and arm a stopped goal, or rearm an active goal after a
|
|
305
|
+
* session-start edge, while its round budget still has capacity.
|
|
306
|
+
* @param agent - owning live agent.
|
|
307
|
+
* @param ref - expected current revision.
|
|
308
|
+
* @returns the active view.
|
|
309
|
+
*/
|
|
310
|
+
@Remote('resume')
|
|
311
|
+
resume(agent: Agent, ref: GoalRef): GoalView {
|
|
312
|
+
const cache = this.prepareMutation(agent)
|
|
313
|
+
const current = this.expectCurrent(cache, ref)
|
|
314
|
+
const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked']
|
|
315
|
+
if (!resumable.includes(current.phase)) {
|
|
316
|
+
throw this.transitionError(current, 'resume', resumable)
|
|
317
|
+
}
|
|
318
|
+
if (current.phase === 'active' && cache.activation === 'armed') {
|
|
319
|
+
throw new GoalError(`goal "${current.id}" is already active and armed`, 'GOAL_INVALID_TRANSITION')
|
|
320
|
+
}
|
|
321
|
+
if (cache.state.roundsStarted >= current.maxGoalRounds) {
|
|
322
|
+
throw new GoalError(
|
|
323
|
+
`goal "${current.id}" exhausted ${current.maxGoalRounds} goal rounds; increase maxGoalRounds before resuming`,
|
|
324
|
+
'GOAL_INVALID_TRANSITION',
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Mark a current non-complete goal complete and disarm it.
|
|
332
|
+
* @param agent - owning live agent.
|
|
333
|
+
* @param ref - expected current revision.
|
|
334
|
+
* @returns the completed view.
|
|
335
|
+
*/
|
|
336
|
+
@Remote('complete')
|
|
337
|
+
complete(agent: Agent, ref: GoalRef): GoalView {
|
|
338
|
+
return this.transition(
|
|
339
|
+
agent,
|
|
340
|
+
ref,
|
|
341
|
+
'complete',
|
|
342
|
+
['active', 'paused', 'blocked'],
|
|
343
|
+
'complete',
|
|
344
|
+
'disarmed',
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Mark an active goal blocked and disarm it.
|
|
350
|
+
* @param agent - owning live agent.
|
|
351
|
+
* @param ref - expected current revision.
|
|
352
|
+
* @param reason - policy-owned stable code and human-readable explanation.
|
|
353
|
+
* @returns the blocked view with its durable reason.
|
|
354
|
+
*/
|
|
355
|
+
block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView {
|
|
356
|
+
const cache = this.prepareMutation(agent)
|
|
357
|
+
const current = this.expectCurrent(cache, ref)
|
|
358
|
+
if (current.phase !== 'active') {
|
|
359
|
+
throw this.transitionError(current, 'block', ['active'])
|
|
360
|
+
}
|
|
361
|
+
return this.commitCurrent(
|
|
362
|
+
agent,
|
|
363
|
+
cache,
|
|
364
|
+
'block',
|
|
365
|
+
{ ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) },
|
|
366
|
+
'disarmed',
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Clear the current goal while retaining a durable tombstone and history.
|
|
372
|
+
* @param agent - owning live agent.
|
|
373
|
+
* @param ref - expected current revision.
|
|
374
|
+
* @returns the tombstone ref whose revision is one past the cleared snapshot.
|
|
375
|
+
*/
|
|
376
|
+
@Remote('clear')
|
|
377
|
+
clear(agent: Agent, ref: GoalRef): GoalRef {
|
|
378
|
+
const cache = this.prepareMutation(agent)
|
|
379
|
+
const current = this.expectCurrent(cache, ref)
|
|
380
|
+
const tombstone: GoalRef = { id: current.id, revision: current.revision + 1 }
|
|
381
|
+
const change: GoalClearChangeMeta = {
|
|
382
|
+
kind: 'goal/change',
|
|
383
|
+
version: GOAL_CHANGE_VERSION,
|
|
384
|
+
operation: 'clear',
|
|
385
|
+
cleared: tombstone,
|
|
386
|
+
clearedAt: this.nextMutationTime(cache),
|
|
387
|
+
}
|
|
388
|
+
this.commit(agent, cache, change, 'disarmed')
|
|
389
|
+
return { ...tombstone }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Resolve and validate the cache used by a mutation. */
|
|
393
|
+
private prepareMutation(agent: Agent): GoalCache {
|
|
394
|
+
this.assertLive(agent)
|
|
395
|
+
const cache = this.cache(agent.session)
|
|
396
|
+
this.sync(agent.session, cache)
|
|
397
|
+
return cache
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Reject stale or missing current-state refs. */
|
|
401
|
+
private expectCurrent(cache: GoalCache, ref: GoalRef): GoalSnapshot {
|
|
402
|
+
const current = cache.state.goal
|
|
403
|
+
if (current === undefined) throw new GoalError('no current goal', 'GOAL_NOT_FOUND')
|
|
404
|
+
if (ref.id !== current.id || ref.revision !== current.revision) {
|
|
405
|
+
throw new GoalError(
|
|
406
|
+
`stale goal ref "${ref.id}" revision ${ref.revision}; current is "${current.id}" revision ${current.revision}`,
|
|
407
|
+
'GOAL_STALE_REVISION',
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
return current
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Enforce exact live-agent identity rather than trusting a matching id. */
|
|
414
|
+
private assertLive(agent: Agent): void {
|
|
415
|
+
if (this.ctx.agents.get(agent.id) !== agent) {
|
|
416
|
+
throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE')
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Return the per-session cache, folding a seed once with activation disarmed. */
|
|
421
|
+
private cache(session: Session): GoalCache {
|
|
422
|
+
let cache = this.caches.get(session)
|
|
423
|
+
if (cache !== undefined) return cache
|
|
424
|
+
const state = emptyGoalFoldState()
|
|
425
|
+
for (const event of session.events) applyGoalEvent(state, event)
|
|
426
|
+
cache = {
|
|
427
|
+
state,
|
|
428
|
+
activation: 'disarmed',
|
|
429
|
+
observedSeq: session.seq,
|
|
430
|
+
pendingActivation: undefined,
|
|
431
|
+
}
|
|
432
|
+
this.caches.set(session, cache)
|
|
433
|
+
return cache
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Incrementally observe durable events and reconcile local activation intent. */
|
|
437
|
+
private sync(session: Session, cache: GoalCache): void {
|
|
438
|
+
for (const event of session.events.slice(cache.observedSeq)) {
|
|
439
|
+
applyGoalEvent(cache.state, event)
|
|
440
|
+
if (event.type === 'goal/change') {
|
|
441
|
+
cache.activation = cache.pendingActivation?.seq === event.seq
|
|
442
|
+
? cache.pendingActivation.activation
|
|
443
|
+
: 'disarmed'
|
|
444
|
+
}
|
|
445
|
+
cache.observedSeq += 1
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Build a new revision with one replacement phase. */
|
|
450
|
+
private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot {
|
|
451
|
+
return {
|
|
452
|
+
id: current.id,
|
|
453
|
+
revision: current.revision + 1,
|
|
454
|
+
objective: current.objective,
|
|
455
|
+
phase,
|
|
456
|
+
maxGoalRounds: current.maxGoalRounds,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Shared validated phase transition. */
|
|
461
|
+
private transition(
|
|
462
|
+
agent: Agent,
|
|
463
|
+
ref: GoalRef,
|
|
464
|
+
operation: Exclude<GoalOperation, 'create' | 'edit' | 'clear'>,
|
|
465
|
+
allowed: readonly GoalPhase[],
|
|
466
|
+
phase: GoalPhase,
|
|
467
|
+
activation: GoalActivation,
|
|
468
|
+
): GoalView {
|
|
469
|
+
const cache = this.prepareMutation(agent)
|
|
470
|
+
const current = this.expectCurrent(cache, ref)
|
|
471
|
+
if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed)
|
|
472
|
+
return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Render a stable invalid-transition error. */
|
|
476
|
+
private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError {
|
|
477
|
+
return new GoalError(
|
|
478
|
+
`cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`,
|
|
479
|
+
'GOAL_INVALID_TRANSITION',
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Commit a mutation that retains the current goal's derived counters/times. */
|
|
484
|
+
private commitCurrent(
|
|
485
|
+
agent: Agent,
|
|
486
|
+
cache: GoalCache,
|
|
487
|
+
operation: Exclude<GoalOperation, 'create' | 'clear'>,
|
|
488
|
+
goal: GoalSnapshot,
|
|
489
|
+
activation: GoalActivation,
|
|
490
|
+
): GoalView {
|
|
491
|
+
const createdAt = cache.state.createdAt
|
|
492
|
+
/* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */
|
|
493
|
+
if (createdAt === undefined) throw new Error('current goal cache lacks createdAt')
|
|
494
|
+
return this.commitSnapshot(
|
|
495
|
+
agent,
|
|
496
|
+
cache,
|
|
497
|
+
operation,
|
|
498
|
+
goal,
|
|
499
|
+
cache.state.roundsStarted,
|
|
500
|
+
createdAt,
|
|
501
|
+
this.nextMutationTime(cache),
|
|
502
|
+
activation,
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Clamp a current goal's next timestamp across backward wall-clock movement. */
|
|
507
|
+
private nextMutationTime(cache: GoalCache): number {
|
|
508
|
+
const updatedAt = cache.state.updatedAt
|
|
509
|
+
/* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */
|
|
510
|
+
if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt')
|
|
511
|
+
return Math.max(Date.now(), updatedAt)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Build and commit one full-snapshot mutation. */
|
|
515
|
+
private commitSnapshot(
|
|
516
|
+
agent: Agent,
|
|
517
|
+
cache: GoalCache,
|
|
518
|
+
operation: Exclude<GoalOperation, 'clear'>,
|
|
519
|
+
goal: GoalSnapshot,
|
|
520
|
+
roundsStarted: number,
|
|
521
|
+
createdAt: number,
|
|
522
|
+
updatedAt: number,
|
|
523
|
+
activation: GoalActivation,
|
|
524
|
+
): GoalView {
|
|
525
|
+
const change: GoalSnapshotChangeMeta = {
|
|
526
|
+
kind: 'goal/change',
|
|
527
|
+
version: GOAL_CHANGE_VERSION,
|
|
528
|
+
operation,
|
|
529
|
+
goal,
|
|
530
|
+
roundsStarted,
|
|
531
|
+
createdAt,
|
|
532
|
+
updatedAt,
|
|
533
|
+
}
|
|
534
|
+
this.commit(agent, cache, change, activation)
|
|
535
|
+
const view = this.view(cache)
|
|
536
|
+
/* v8 ignore next -- the durable goal event installs the snapshot before this read */
|
|
537
|
+
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
|
|
538
|
+
return view
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Commit one mutation into the goal log, cache, and live event stream. */
|
|
542
|
+
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
|
|
543
|
+
const ref = goalChangeRef(change)
|
|
544
|
+
cache.pendingActivation = { seq: agent.session.seq, activation }
|
|
545
|
+
try {
|
|
546
|
+
agent.session.append('goal/change', change)
|
|
547
|
+
this.sync(agent.session, cache)
|
|
548
|
+
} finally {
|
|
549
|
+
cache.pendingActivation = undefined
|
|
550
|
+
}
|
|
551
|
+
const goal = this.view(cache)
|
|
552
|
+
const notification: GoalChanged = {
|
|
553
|
+
operation: change.operation,
|
|
554
|
+
ref: { ...ref },
|
|
555
|
+
...goal === undefined ? {} : { goal },
|
|
556
|
+
}
|
|
557
|
+
agentEvents(this.ctx, agent).emit('goal/changed', { change: notification })
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Build a detached current view. */
|
|
561
|
+
private view(cache: GoalCache): GoalView | undefined {
|
|
562
|
+
const goal = cache.state.goal
|
|
563
|
+
const createdAt = cache.state.createdAt
|
|
564
|
+
const updatedAt = cache.state.updatedAt
|
|
565
|
+
if (goal === undefined) return undefined
|
|
566
|
+
/* v8 ignore next 3 -- strict replay and snapshot commits establish both timestamps with every current goal */
|
|
567
|
+
if (createdAt === undefined || updatedAt === undefined) {
|
|
568
|
+
throw new Error(`goal "${goal.id}" cache lacks timestamps`)
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
...goal,
|
|
572
|
+
roundsStarted: cache.state.roundsStarted,
|
|
573
|
+
createdAt,
|
|
574
|
+
updatedAt,
|
|
575
|
+
activation: cache.activation,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Create one Goal through the remote boundary.
|
|
581
|
+
* @param agent - exact live Agent resolved from the wire identity.
|
|
582
|
+
* @param request - objective and optional round cap.
|
|
583
|
+
* @returns the created Goal identity.
|
|
584
|
+
*/
|
|
585
|
+
@Remote('create')
|
|
586
|
+
remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult {
|
|
587
|
+
const view = this.create(agent, request)
|
|
588
|
+
return { ref: { id: view.id, revision: view.revision } }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export default GoalService
|
package/src/invariant.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Package-owned durable goal-stream invariants. @module @deepseek-ai/dsh-goal/invariant */
|
|
2
|
+
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
|
5
|
+
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
6
|
+
import { applyGoalEvent, emptyGoalFoldState } from './fold.ts'
|
|
7
|
+
import type { GoalFoldState } from './fold.ts'
|
|
8
|
+
|
|
9
|
+
const PACKAGE_NAME = '@deepseek-ai/dsh-goal'
|
|
10
|
+
|
|
11
|
+
/** Cordis companion plugin name. */
|
|
12
|
+
export const name = 'goal-invariant'
|
|
13
|
+
/** Service required before the companion can reserve package ownership. */
|
|
14
|
+
export const inject = ['invariants']
|
|
15
|
+
|
|
16
|
+
/** Copy the independent fold before validating one candidate event. */
|
|
17
|
+
function cloneState(state: GoalFoldState): GoalFoldState {
|
|
18
|
+
return {
|
|
19
|
+
goal: state.goal,
|
|
20
|
+
roundsStarted: state.roundsStarted,
|
|
21
|
+
createdAt: state.createdAt,
|
|
22
|
+
updatedAt: state.updatedAt,
|
|
23
|
+
lastRef: state.lastRef,
|
|
24
|
+
seenGoalIds: new Set(state.seenGoalIds),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Apply one event through the strict goal decoder and attribute failures. */
|
|
29
|
+
function applyChecked(state: GoalFoldState, event: SessionEvent, fail: InvariantFailure): void {
|
|
30
|
+
try {
|
|
31
|
+
applyGoalEvent(state, event)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
/* v8 ignore next -- the strict goal decoder throws Error instances */
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
35
|
+
fail(`session event ${event.seq} violates the durable goal stream: ${message}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Install an independent incremental fold over every attached session. */
|
|
40
|
+
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
|
41
|
+
const states = new WeakMap<Session, GoalFoldState>()
|
|
42
|
+
const staged = new WeakMap<SessionEvent, { session: Session; state: GoalFoldState }>()
|
|
43
|
+
|
|
44
|
+
const seed = (session: Session): GoalFoldState => {
|
|
45
|
+
const state = emptyGoalFoldState()
|
|
46
|
+
for (const event of session.events) applyChecked(state, event, fail)
|
|
47
|
+
states.set(session, state)
|
|
48
|
+
return state
|
|
49
|
+
}
|
|
50
|
+
/* v8 ignore next -- session/event always follows list() or session/created seeding */
|
|
51
|
+
const stateFor = (session: Session): GoalFoldState => states.get(session) ?? seed(session)
|
|
52
|
+
|
|
53
|
+
for (const session of ctx.sessions.list()) seed(session)
|
|
54
|
+
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
|
55
|
+
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
|
56
|
+
if (eventName !== 'session/event') return
|
|
57
|
+
const [session, event] = args as [Session, SessionEvent]
|
|
58
|
+
const state = cloneState(stateFor(session))
|
|
59
|
+
applyChecked(state, event, fail)
|
|
60
|
+
staged.set(event, { session, state })
|
|
61
|
+
}, { global: true })
|
|
62
|
+
ctx.on('session/event', (session, event) => {
|
|
63
|
+
const candidate = staged.get(event)
|
|
64
|
+
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
|
65
|
+
if (candidate === undefined || candidate.session !== session) {
|
|
66
|
+
return fail('session/event reached publication without matching goal-fold validation')
|
|
67
|
+
}
|
|
68
|
+
staged.delete(event)
|
|
69
|
+
states.set(session, candidate.state)
|
|
70
|
+
}, { global: true })
|
|
71
|
+
}, { inject: ['sessions'] })
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register the goal-stream invariant companion.
|
|
75
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
76
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
77
|
+
*/
|
|
78
|
+
export const apply = (ctx: Context): Promise<() => void> =>
|
|
79
|
+
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Runtime constructors and protocol constants for the goal domain. */
|
|
2
|
+
|
|
3
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type { GoalId as GoalIdType } from './types.ts'
|
|
5
|
+
import type { GoalErrorCode } from './domain.ts'
|
|
6
|
+
|
|
7
|
+
/** Version of the goal change embedded in a round-zero message source. */
|
|
8
|
+
export const GOAL_CHANGE_VERSION = 1
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Brand a string as a goal id.
|
|
12
|
+
* @param id - raw goal identifier.
|
|
13
|
+
* @returns the same string with the compile-time brand.
|
|
14
|
+
*/
|
|
15
|
+
export function GoalId(id: string): GoalIdType {
|
|
16
|
+
return id as GoalIdType
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Error returned by the goal domain boundary. */
|
|
20
|
+
export class GoalError extends HarnessError {
|
|
21
|
+
/**
|
|
22
|
+
* @param message - human-readable rejection reason.
|
|
23
|
+
* @param code - stable machine-routable classification.
|
|
24
|
+
*/
|
|
25
|
+
// Keep the constructor to narrow HarnessError's string code at this boundary.
|
|
26
|
+
// oxlint-disable-next-line typescript/no-useless-constructor -- type-only narrowing
|
|
27
|
+
constructor(message: string, code: GoalErrorCode) {
|
|
28
|
+
super(message, code)
|
|
29
|
+
}
|
|
30
|
+
}
|