@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/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
@@ -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
+ }