feat(goal): split the pure type outlet and register the 'goal' projection unit
dsh-goal/types becomes the client-safe pure outlet (tool-todo dual-outlet shape): GoalId/GoalRef/GoalPhase/GoalBlockReason/GoalSnapshot, the new GoalProjection payload, and the SessionProjectionMap 'goal' key merge — zero host imports. Host-coupled vocabulary (GoalView/activation, change metas, message source, folds, GoalError codes, the scoped goal/changed event) moves to src/domain.ts, re-exported from the package root. ./client re-exports the outlet for client aggregates. GoalService registers the projection unit under ctx.inject(['sessionProjections']): applyGoalProjection is a projection-grade last-wins fold — plain-JSON state, same-reference return on non-goal or malformed events (a throwing apply would tear down the registry drive; strict validation stays with the write side and foldGoal). Activation is process-local and deliberately absent from the projection value.
This commit is contained in:
@@ -15,12 +15,21 @@
|
|||||||
"types": "./lib/types/invariant.d.ts",
|
"types": "./lib/types/invariant.d.ts",
|
||||||
"default": "./lib/invariant.js"
|
"default": "./lib/invariant.js"
|
||||||
},
|
},
|
||||||
|
"./types": {
|
||||||
|
"types": "./lib/types/types.d.ts",
|
||||||
|
"default": "./lib/types/types.js"
|
||||||
|
},
|
||||||
|
"./client": {
|
||||||
|
"types": "./lib/types/client.d.ts",
|
||||||
|
"default": "./lib/types/client.js"
|
||||||
|
},
|
||||||
"./src/*": "./src/*",
|
"./src/*": "./src/*",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib/index.js",
|
"lib/index.js",
|
||||||
"lib/invariant.js",
|
"lib/invariant.js",
|
||||||
|
"lib/types/**/*.js",
|
||||||
"lib/types/**/*.d.ts",
|
"lib/types/**/*.d.ts",
|
||||||
"lib/types/**/*.d.ts.map",
|
"lib/types/**/*.d.ts.map",
|
||||||
"src"
|
"src"
|
||||||
@@ -28,6 +37,7 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||||
|
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||||
@@ -36,10 +46,12 @@
|
|||||||
"cordis": "^4.0.0-rc.7"
|
"cordis": "^4.0.0-rc.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"schemastery": "^3.17.2"
|
"schemastery": "^3.17.2",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||||
|
|||||||
10
packages/goal/goal/src/client.ts
Normal file
10
packages/goal/goal/src/client.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Client-namespace projection of the goal domain: a pure re-export of the
|
||||||
|
* package's types outlet. Client code imports ONLY the client namespace
|
||||||
|
* (repo discipline), so `./client` projects the same single-source content
|
||||||
|
* `./types` serves to host consumers — zero duplication.
|
||||||
|
*
|
||||||
|
* @module @deepseek-ai/dsh-goal/client
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type * from './types.ts'
|
||||||
137
packages/goal/goal/src/domain.ts
Normal file
137
packages/goal/goal/src/domain.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Host-side vocabulary of the goal domain: live views, durable change
|
||||||
|
* payloads, message attribution, replay folds, and the scoped `goal/changed`
|
||||||
|
* event. Split from ./types.ts (the pure client-safe outlet) because these
|
||||||
|
* declarations pull dsh-agent, dsh-llm, and cordis into the program — the
|
||||||
|
* one-program-per-side layout forbids that on client aggregates.
|
||||||
|
* @module @deepseek-ai/dsh-goal
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
|
import type { GoalId, GoalRef, GoalSnapshot } from './types.ts'
|
||||||
|
|
||||||
|
/** Whether this live process may automatically continue an active goal. */
|
||||||
|
export type GoalActivation = 'armed' | 'disarmed'
|
||||||
|
|
||||||
|
/** Current goal projection, including values derived from the session log. */
|
||||||
|
export interface GoalView extends GoalSnapshot {
|
||||||
|
/** Highest admitted round number for this goal. */
|
||||||
|
readonly roundsStarted: number
|
||||||
|
/** Epoch milliseconds of the create mutation. */
|
||||||
|
readonly createdAt: number
|
||||||
|
/** Epoch milliseconds of the latest mutation. */
|
||||||
|
readonly updatedAt: number
|
||||||
|
/** Process-local continuation eligibility; never persisted. */
|
||||||
|
readonly activation: GoalActivation
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Goal state-changing verbs recorded in the durable source change. */
|
||||||
|
export type GoalOperation =
|
||||||
|
| 'create'
|
||||||
|
| 'edit'
|
||||||
|
| 'pause'
|
||||||
|
| 'resume'
|
||||||
|
| 'complete'
|
||||||
|
| 'block'
|
||||||
|
| 'clear'
|
||||||
|
|
||||||
|
/** Full-snapshot goal mutation retained in a model-visible context event. */
|
||||||
|
export interface GoalSnapshotChangeMeta {
|
||||||
|
readonly kind: 'goal/change'
|
||||||
|
readonly version: 1
|
||||||
|
readonly operation: Exclude<GoalOperation, 'clear'>
|
||||||
|
readonly goal: GoalSnapshot
|
||||||
|
readonly roundsStarted: number
|
||||||
|
readonly createdAt: number
|
||||||
|
readonly updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tombstone retained when the current goal is cleared. */
|
||||||
|
export interface GoalClearChangeMeta {
|
||||||
|
readonly kind: 'goal/change'
|
||||||
|
readonly version: 1
|
||||||
|
readonly operation: 'clear'
|
||||||
|
readonly cleared: GoalRef
|
||||||
|
readonly clearedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable change union carried by a goal-owned round-zero message source. */
|
||||||
|
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
||||||
|
|
||||||
|
/** Message attribution for durable goal state and continuation rounds. */
|
||||||
|
export interface GoalMessageSource {
|
||||||
|
readonly kind: 'goal'
|
||||||
|
readonly goalId: GoalId
|
||||||
|
readonly revision: number
|
||||||
|
/** Zero for state changes; positive for admitted continuation rounds. */
|
||||||
|
readonly round: number
|
||||||
|
/** Complete durable mutation carried only by round-zero state-change messages. */
|
||||||
|
readonly change?: GoalChangeMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@deepseek-ai/dsh-llm' {
|
||||||
|
interface MessageSourceMap {
|
||||||
|
goal: GoalMessageSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure replay fold of durable goal facts. */
|
||||||
|
export interface FoldedGoal {
|
||||||
|
/** Current goal, absent after a clear or before the first create. */
|
||||||
|
readonly goal?: GoalSnapshot
|
||||||
|
/** Highest admitted round for the current goal. */
|
||||||
|
readonly roundsStarted: number
|
||||||
|
/** Current goal creation time, absent without a current goal. */
|
||||||
|
readonly createdAt?: number
|
||||||
|
/** Current goal mutation time, absent without a current goal. */
|
||||||
|
readonly updatedAt?: number
|
||||||
|
/** Latest mutation ref, including a clear tombstone. */
|
||||||
|
readonly lastRef?: GoalRef
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input whose omitted round cap is resolved by the service configuration. */
|
||||||
|
export interface CreateGoalRequest {
|
||||||
|
readonly objective: string
|
||||||
|
readonly maxGoalRounds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields changed by an edit; at least one must be present. */
|
||||||
|
export interface EditGoalRequest {
|
||||||
|
readonly objective?: string
|
||||||
|
readonly maxGoalRounds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live notification after one goal mutation has been accepted for logging. */
|
||||||
|
export interface GoalChanged {
|
||||||
|
readonly operation: GoalOperation
|
||||||
|
readonly ref: GoalRef
|
||||||
|
/** Absent for a clear tombstone. */
|
||||||
|
readonly goal?: GoalView
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable error codes for rejected goal reads and mutations. */
|
||||||
|
export type GoalErrorCode =
|
||||||
|
| 'GOAL_AGENT_NOT_LIVE'
|
||||||
|
| 'GOAL_NOT_FOUND'
|
||||||
|
| 'GOAL_ALREADY_EXISTS'
|
||||||
|
| 'GOAL_STALE_REVISION'
|
||||||
|
| 'GOAL_INVALID_OBJECTIVE'
|
||||||
|
| 'GOAL_INVALID_MAX_ROUNDS'
|
||||||
|
| 'GOAL_INVALID_BLOCK_REASON'
|
||||||
|
| 'GOAL_INVALID_EDIT'
|
||||||
|
| 'GOAL_INVALID_TRANSITION'
|
||||||
|
|
||||||
|
declare module 'cordis' {
|
||||||
|
interface Events {
|
||||||
|
/**
|
||||||
|
* Goal mutation accepted by one live agent. The matching context event is
|
||||||
|
* already appended or queued in that agent's active tool-batch FIFO.
|
||||||
|
* Listener failures are contained.
|
||||||
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||||
|
* @param agent - agent whose session owns the goal.
|
||||||
|
* @param change - fresh current projection or clear tombstone.
|
||||||
|
* @mode emit
|
||||||
|
*/
|
||||||
|
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,15 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
|||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import { renderGoalChange } from './render.ts'
|
import { renderGoalChange } from './render.ts'
|
||||||
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
|
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
|
||||||
|
import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
|
||||||
import type {
|
import type {
|
||||||
FoldedGoal,
|
FoldedGoal,
|
||||||
GoalBlockReason,
|
|
||||||
GoalChangeMeta,
|
GoalChangeMeta,
|
||||||
GoalClearChangeMeta,
|
GoalClearChangeMeta,
|
||||||
GoalMessageSource,
|
GoalMessageSource,
|
||||||
GoalOperation,
|
GoalOperation,
|
||||||
GoalPhase,
|
|
||||||
GoalRef,
|
|
||||||
GoalSnapshot,
|
|
||||||
GoalSnapshotChangeMeta,
|
GoalSnapshotChangeMeta,
|
||||||
} from './types.ts'
|
} from './domain.ts'
|
||||||
|
|
||||||
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
|
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,14 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
|
import { z as zod } from 'zod'
|
||||||
|
import type { ZodType } from 'zod'
|
||||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Session } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
|
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
||||||
|
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||||
import {
|
import {
|
||||||
applyGoalChange,
|
applyGoalChange,
|
||||||
applyGoalEvent,
|
applyGoalEvent,
|
||||||
@@ -25,23 +29,31 @@ import {
|
|||||||
GoalError,
|
GoalError,
|
||||||
GoalId,
|
GoalId,
|
||||||
} from './runtime.ts'
|
} from './runtime.ts'
|
||||||
|
import type {
|
||||||
|
GoalBlockReason,
|
||||||
|
GoalPhase,
|
||||||
|
GoalProjection,
|
||||||
|
GoalRef,
|
||||||
|
GoalSnapshot,
|
||||||
|
} from './types.ts'
|
||||||
import type {
|
import type {
|
||||||
CreateGoalRequest,
|
CreateGoalRequest,
|
||||||
EditGoalRequest,
|
EditGoalRequest,
|
||||||
GoalActivation,
|
GoalActivation,
|
||||||
GoalBlockReason,
|
|
||||||
GoalChangeMeta,
|
GoalChangeMeta,
|
||||||
GoalChanged,
|
GoalChanged,
|
||||||
GoalClearChangeMeta,
|
GoalClearChangeMeta,
|
||||||
GoalOperation,
|
GoalOperation,
|
||||||
GoalPhase,
|
|
||||||
GoalRef,
|
|
||||||
GoalSnapshot,
|
|
||||||
GoalSnapshotChangeMeta,
|
GoalSnapshotChangeMeta,
|
||||||
GoalView,
|
GoalView,
|
||||||
} from './types.ts'
|
} from './domain.ts'
|
||||||
|
|
||||||
export * from './types.ts'
|
// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key
|
||||||
|
// declaration) re-exported onto the package root keeps the module edge in
|
||||||
|
// the emitted index.d.ts, so aggregate programs consuming the declarations
|
||||||
|
// still receive the SessionProjectionMap merge.
|
||||||
|
export type * from './types.ts'
|
||||||
|
export type * from './domain.ts'
|
||||||
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
|
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
|
||||||
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
|
export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
|
||||||
export { renderGoalChange } from './render.ts'
|
export { renderGoalChange } from './render.ts'
|
||||||
@@ -52,6 +64,52 @@ declare module 'cordis' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wire payload schema of the `goal` projection (whole current goal or pre-create/cleared null). */
|
||||||
|
const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
|
||||||
|
zod.object({
|
||||||
|
goal: zod.object({
|
||||||
|
id: zod.string().min(1),
|
||||||
|
revision: zod.number().int().positive(),
|
||||||
|
objective: zod.string().min(1),
|
||||||
|
phase: zod.union([zod.literal('active'), zod.literal('paused'), zod.literal('blocked'), zod.literal('complete')]),
|
||||||
|
blockedReason: zod.object({ code: zod.string(), message: zod.string() }).optional(),
|
||||||
|
maxGoalRounds: zod.number().int().positive(),
|
||||||
|
}),
|
||||||
|
roundsStarted: zod.number().int().nonnegative(),
|
||||||
|
createdAt: zod.number(),
|
||||||
|
updatedAt: zod.number(),
|
||||||
|
}),
|
||||||
|
zod.null(),
|
||||||
|
]) as ZodType<GoalProjection | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light last-wins fold of the `goal` projection unit. Unlike the strict
|
||||||
|
* replay fold (fold.ts: transition validation, fail-loud on malformed
|
||||||
|
* changes, Set-typed state), this transition is projection-grade: the state
|
||||||
|
* is plain JSON (persisted-cache precondition), any non-goal or malformed
|
||||||
|
* event returns the same reference (the registry's Object.is gate — the
|
||||||
|
* title/todos posture), and correctness of the written change is the write
|
||||||
|
* side's job (GoalService validated it before appending; the package
|
||||||
|
* invariant rejects a violating stream fail-loud where it is installed).
|
||||||
|
* @param state - the projection covering all prior events.
|
||||||
|
* @param event - the next committed session event.
|
||||||
|
* @returns the next projection (same reference when the event is not a goal change).
|
||||||
|
*/
|
||||||
|
export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
|
||||||
|
if (event.type !== 'user/message') return state
|
||||||
|
const source = event.data.source
|
||||||
|
if (source.kind !== 'goal' || source.round !== 0) return state
|
||||||
|
const change = source.change
|
||||||
|
if (change === undefined || change.kind !== 'goal/change') return state
|
||||||
|
if (change.operation === 'clear') return null
|
||||||
|
return {
|
||||||
|
goal: change.goal,
|
||||||
|
roundsStarted: change.roundsStarted,
|
||||||
|
createdAt: change.createdAt,
|
||||||
|
updatedAt: change.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deployment defaults for goal creation. */
|
/** Deployment defaults for goal creation. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** Total rounds used when a create request omits its own cap. */
|
/** Total rounds used when a create request omits its own cap. */
|
||||||
@@ -150,6 +208,19 @@ export class GoalService extends Service {
|
|||||||
ctx.on('agent/session-start', (agent) => {
|
ctx.on('agent/session-start', (agent) => {
|
||||||
this.cache(agent.session).activation = 'disarmed'
|
this.cache(agent.session).activation = 'disarmed'
|
||||||
})
|
})
|
||||||
|
// The `goal` projection unit: last-wins fold of goal/change whole values
|
||||||
|
// (see applyGoalProjection). The unit child activates only when a
|
||||||
|
// projection registry is composed (headless assemblies stay unaffected).
|
||||||
|
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||||
|
projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
|
||||||
|
key: 'goal',
|
||||||
|
schema: goalProjectionSchema,
|
||||||
|
init: () => null,
|
||||||
|
apply: applyGoalProjection,
|
||||||
|
view: state => state,
|
||||||
|
stateVersion: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Model-visible rendering for durable goal mutations. */
|
/** Model-visible rendering for durable goal mutations. */
|
||||||
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { GoalChangeMeta } from './types.ts'
|
import type { GoalChangeMeta } from './domain.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a complete goal snapshot or clear tombstone without hidden prose.
|
* Render a complete goal snapshot or clear tombstone without hidden prose.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/** Runtime constructors and protocol constants for the goal domain. */
|
/** Runtime constructors and protocol constants for the goal domain. */
|
||||||
|
|
||||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
|
import type { GoalId as GoalIdType } from './types.ts'
|
||||||
|
import type { GoalErrorCode } from './domain.ts'
|
||||||
|
|
||||||
/** Version of the goal change embedded in a round-zero message source. */
|
/** Version of the goal change embedded in a round-zero message source. */
|
||||||
export const GOAL_CHANGE_VERSION = 1
|
export const GOAL_CHANGE_VERSION = 1
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Durable and live vocabulary for one same-session goal.
|
* Pure types of the goal domain: the ONE home of the `goal` projection-key
|
||||||
|
* declaration plus the durable payload vocabulary it carries, free of this
|
||||||
|
* package's host-side imports (cordis events, dsh-agent, dsh-llm, the
|
||||||
|
* service). Two namespace projections serve it — `./types` for host
|
||||||
|
* consumers, `./client` (the browser half-entry's re-export) for client
|
||||||
|
* aggregates — with zero content duplication. Host-coupled domain
|
||||||
|
* vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
|
||||||
|
*
|
||||||
* @module @deepseek-ai/dsh-goal/types
|
* @module @deepseek-ai/dsh-goal/types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
||||||
|
|
||||||
/** Identifies one goal across its durable revisions. */
|
/** Identifies one goal across its durable revisions. */
|
||||||
export type GoalId = Branded<'GoalId'>
|
export type GoalId = Branded<'GoalId'>
|
||||||
@@ -44,128 +50,31 @@ export interface GoalSnapshot extends GoalRef {
|
|||||||
readonly maxGoalRounds: number
|
readonly maxGoalRounds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this live process may automatically continue an active goal. */
|
/**
|
||||||
export type GoalActivation = 'armed' | 'disarmed'
|
* The `goal` projection value: the current durable goal with its replay
|
||||||
|
* counters, exactly as the latest `goal/change` source carried them.
|
||||||
/** Current goal projection, including values derived from the session log. */
|
* Activation is process-local (never persisted) and deliberately absent —
|
||||||
export interface GoalView extends GoalSnapshot {
|
* the projection reflects durable phase only.
|
||||||
|
*/
|
||||||
|
export interface GoalProjection {
|
||||||
|
/** Current durable goal snapshot (the CAS ref for mutations rides on it). */
|
||||||
|
readonly goal: GoalSnapshot
|
||||||
/** Highest admitted round number for this goal. */
|
/** Highest admitted round number for this goal. */
|
||||||
readonly roundsStarted: number
|
readonly roundsStarted: number
|
||||||
/** Epoch milliseconds of the create mutation. */
|
/** Epoch milliseconds of the create mutation. */
|
||||||
readonly createdAt: number
|
readonly createdAt: number
|
||||||
/** Epoch milliseconds of the latest mutation. */
|
/** Epoch milliseconds of the latest mutation. */
|
||||||
readonly updatedAt: number
|
readonly updatedAt: number
|
||||||
/** Process-local continuation eligibility; never persisted. */
|
|
||||||
readonly activation: GoalActivation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Goal state-changing verbs recorded in the durable source change. */
|
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||||
export type GoalOperation =
|
interface SessionProjectionMap {
|
||||||
| 'create'
|
|
||||||
| 'edit'
|
|
||||||
| 'pause'
|
|
||||||
| 'resume'
|
|
||||||
| 'complete'
|
|
||||||
| 'block'
|
|
||||||
| 'clear'
|
|
||||||
|
|
||||||
/** Full-snapshot goal mutation retained in a model-visible context event. */
|
|
||||||
export interface GoalSnapshotChangeMeta {
|
|
||||||
readonly kind: 'goal/change'
|
|
||||||
readonly version: 1
|
|
||||||
readonly operation: Exclude<GoalOperation, 'clear'>
|
|
||||||
readonly goal: GoalSnapshot
|
|
||||||
readonly roundsStarted: number
|
|
||||||
readonly createdAt: number
|
|
||||||
readonly updatedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tombstone retained when the current goal is cleared. */
|
|
||||||
export interface GoalClearChangeMeta {
|
|
||||||
readonly kind: 'goal/change'
|
|
||||||
readonly version: 1
|
|
||||||
readonly operation: 'clear'
|
|
||||||
readonly cleared: GoalRef
|
|
||||||
readonly clearedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Durable change union carried by a goal-owned round-zero message source. */
|
|
||||||
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
|
||||||
|
|
||||||
/** Message attribution for durable goal state and continuation rounds. */
|
|
||||||
export interface GoalMessageSource {
|
|
||||||
readonly kind: 'goal'
|
|
||||||
readonly goalId: GoalId
|
|
||||||
readonly revision: number
|
|
||||||
/** Zero for state changes; positive for admitted continuation rounds. */
|
|
||||||
readonly round: number
|
|
||||||
/** Complete durable mutation carried only by round-zero state-change messages. */
|
|
||||||
readonly change?: GoalChangeMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@deepseek-ai/dsh-llm' {
|
|
||||||
interface MessageSourceMap {
|
|
||||||
goal: GoalMessageSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pure replay fold of durable goal facts. */
|
|
||||||
export interface FoldedGoal {
|
|
||||||
/** Current goal, absent after a clear or before the first create. */
|
|
||||||
readonly goal?: GoalSnapshot
|
|
||||||
/** Highest admitted round for the current goal. */
|
|
||||||
readonly roundsStarted: number
|
|
||||||
/** Current goal creation time, absent without a current goal. */
|
|
||||||
readonly createdAt?: number
|
|
||||||
/** Current goal mutation time, absent without a current goal. */
|
|
||||||
readonly updatedAt?: number
|
|
||||||
/** Latest mutation ref, including a clear tombstone. */
|
|
||||||
readonly lastRef?: GoalRef
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Input whose omitted round cap is resolved by the service configuration. */
|
|
||||||
export interface CreateGoalRequest {
|
|
||||||
readonly objective: string
|
|
||||||
readonly maxGoalRounds?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fields changed by an edit; at least one must be present. */
|
|
||||||
export interface EditGoalRequest {
|
|
||||||
readonly objective?: string
|
|
||||||
readonly maxGoalRounds?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live notification after one goal mutation has been accepted for logging. */
|
|
||||||
export interface GoalChanged {
|
|
||||||
readonly operation: GoalOperation
|
|
||||||
readonly ref: GoalRef
|
|
||||||
/** Absent for a clear tombstone. */
|
|
||||||
readonly goal?: GoalView
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable error codes for rejected goal reads and mutations. */
|
|
||||||
export type GoalErrorCode =
|
|
||||||
| 'GOAL_AGENT_NOT_LIVE'
|
|
||||||
| 'GOAL_NOT_FOUND'
|
|
||||||
| 'GOAL_ALREADY_EXISTS'
|
|
||||||
| 'GOAL_STALE_REVISION'
|
|
||||||
| 'GOAL_INVALID_OBJECTIVE'
|
|
||||||
| 'GOAL_INVALID_MAX_ROUNDS'
|
|
||||||
| 'GOAL_INVALID_BLOCK_REASON'
|
|
||||||
| 'GOAL_INVALID_EDIT'
|
|
||||||
| 'GOAL_INVALID_TRANSITION'
|
|
||||||
|
|
||||||
declare module 'cordis' {
|
|
||||||
interface Events {
|
|
||||||
/**
|
/**
|
||||||
* Goal mutation accepted by one live agent. The matching context event is
|
* The session's current goal (the latest `goal/change` whole value), or
|
||||||
* already appended or queued in that agent's active tool-batch FIFO.
|
* `null` before the first create and after a clear tombstone.
|
||||||
* Listener failures are contained.
|
* Whole-value rule: every goal change carries the complete post-change
|
||||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
* state, so the fold is last-wins.
|
||||||
* @param agent - agent whose session owns the goal.
|
|
||||||
* @param change - fresh current projection or clear tombstone.
|
|
||||||
* @mode emit
|
|
||||||
*/
|
*/
|
||||||
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
|
goal: GoalProjection | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
packages/goal/goal/tests/projection.spec.ts
Normal file
161
packages/goal/goal/tests/projection.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* The `goal` projection unit: mounting GoalService beside the registry
|
||||||
|
* serves the current whole goal on the history tail page with a consistent
|
||||||
|
* asOfSeq; before the first create the value is null; a clear tombstone
|
||||||
|
* returns it to null; a composition without the goal service has no `goal`
|
||||||
|
* key; unmounting drops it (HMR safety). Malformed goal-shaped events are
|
||||||
|
* ignored fail-soft (same-reference return) — strict replay validation
|
||||||
|
* belongs to the write side and foldGoal, never the projection drive.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { Context } from 'cordis'
|
||||||
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
|
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||||
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||||
|
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||||
|
import SessionStore from '@deepseek-ai/dsh-session'
|
||||||
|
import type { Session } from '@deepseek-ai/dsh-session'
|
||||||
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||||
|
import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal'
|
||||||
|
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||||
|
|
||||||
|
interface Bench {
|
||||||
|
ctx: Context
|
||||||
|
session: Session
|
||||||
|
agent: Agent
|
||||||
|
tailValues(): Record<string, unknown>
|
||||||
|
tailAsOfSeq(): number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a minimal registry-compatible live agent over a store session. */
|
||||||
|
function liveAgent(ctx: Context, session: Session): Agent {
|
||||||
|
const status: AgentStatus = 'idle'
|
||||||
|
const agent: Agent = {
|
||||||
|
id: session.id,
|
||||||
|
options: {},
|
||||||
|
session,
|
||||||
|
ctx,
|
||||||
|
get status() { return status },
|
||||||
|
get acceptsNextStep() { return false },
|
||||||
|
send: () => {},
|
||||||
|
followup: () => {},
|
||||||
|
steer: () => {},
|
||||||
|
inject(input: UserMessage) {
|
||||||
|
session.append('user/message', input, { surfaceOp: 'append' })
|
||||||
|
},
|
||||||
|
cancel() {},
|
||||||
|
whenIdle() { return Promise.resolve() },
|
||||||
|
} as Agent
|
||||||
|
ctx.agents.register(agent)
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
async function harness(withGoal: boolean): Promise<Bench> {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
await ctx.plugin(AgentRegistry)
|
||||||
|
await ctx.plugin(SessionProjectionRegistry)
|
||||||
|
if (withGoal) await ctx.plugin(GoalService)
|
||||||
|
const session = ctx.sessions.create()
|
||||||
|
const agent = liveAgent(ctx, session)
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
session,
|
||||||
|
agent,
|
||||||
|
tailValues: () => ctx.sessionProjections.snapshot(session).values as Record<string, unknown>,
|
||||||
|
tailAsOfSeq: () => ctx.sessionProjections.snapshot(session).asOfSeq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One paginable message so the tail is non-degenerate. */
|
||||||
|
function seedMessage(session: Session): void {
|
||||||
|
session.append('user/message', createUserMessage({
|
||||||
|
content: [{ type: 'text', text: 'hi' }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
}), { surfaceOp: 'append' })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('goal projection unit', () => {
|
||||||
|
it('serves null before the first create', async () => {
|
||||||
|
const bench = await harness(true)
|
||||||
|
seedMessage(bench.session)
|
||||||
|
expect(bench.tailValues()).toEqual({ goal: null })
|
||||||
|
expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves the whole current goal after create and tracks mutations last-wins', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(1_700_000_000_000)
|
||||||
|
try {
|
||||||
|
const bench = await harness(true)
|
||||||
|
seedMessage(bench.session)
|
||||||
|
const created = bench.ctx.goals.create(bench.agent, { objective: 'ship the goal bar' })
|
||||||
|
const afterCreate = bench.tailValues().goal
|
||||||
|
expect(afterCreate).toMatchObject({
|
||||||
|
goal: { id: created.id, revision: 1, objective: 'ship the goal bar', phase: 'active' },
|
||||||
|
roundsStarted: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ref: GoalRef = { id: created.id, revision: created.revision }
|
||||||
|
const paused = bench.ctx.goals.pause(bench.agent, ref)
|
||||||
|
expect(bench.tailValues().goal).toMatchObject({
|
||||||
|
goal: { revision: paused.revision, phase: 'paused' },
|
||||||
|
})
|
||||||
|
expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns to null after a clear tombstone', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(1_700_000_000_000)
|
||||||
|
try {
|
||||||
|
const bench = await harness(true)
|
||||||
|
seedMessage(bench.session)
|
||||||
|
const created = bench.ctx.goals.create(bench.agent, { objective: 'temporary' })
|
||||||
|
expect(bench.tailValues().goal).not.toBeNull()
|
||||||
|
bench.ctx.goals.clear(bench.agent, { id: created.id, revision: created.revision })
|
||||||
|
expect(bench.tailValues().goal).toBeNull()
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => {
|
||||||
|
// The package invariant rejects a violating stream loudly wherever it is
|
||||||
|
// installed — the unit itself must never throw on the projection drive
|
||||||
|
// (a throwing apply would tear down every registered unit's drive), so
|
||||||
|
// its transition is exercised directly as the pure function it is.
|
||||||
|
const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({
|
||||||
|
content: [{ type: 'text', text: 'hi' }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
}) } as never
|
||||||
|
expect(applyGoalProjection(null, user)).toBeNull()
|
||||||
|
|
||||||
|
const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
|
||||||
|
content: [{ type: 'text', text: 'broken' }],
|
||||||
|
source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never,
|
||||||
|
}) } as never
|
||||||
|
const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never
|
||||||
|
// Same-reference return: the registry's Object.is gate sees no change.
|
||||||
|
expect(applyGoalProjection(state, malformed)).toBe(state)
|
||||||
|
expect(applyGoalProjection(null, malformed)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no goal key when the goal service is not composed', async () => {
|
||||||
|
const bench = await harness(false)
|
||||||
|
seedMessage(bench.session)
|
||||||
|
expect('goal' in (bench.tailValues() ?? {})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the key when the goal fiber unloads (HMR safety)', async () => {
|
||||||
|
const bench = await harness(false)
|
||||||
|
seedMessage(bench.session)
|
||||||
|
const fiber = await bench.ctx.plugin(GoalService)
|
||||||
|
expect(bench.tailValues()).toEqual({ goal: null })
|
||||||
|
await fiber.dispose()
|
||||||
|
expect('goal' in (bench.tailValues() ?? {})).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../../core/agent"
|
"path": "../../core/agent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../session-projection/session-projection"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../support/invariants"
|
"path": "../../support/invariants"
|
||||||
}
|
}
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -2419,6 +2419,9 @@ importers:
|
|||||||
schemastery:
|
schemastery:
|
||||||
specifier: ^3.17.2
|
specifier: ^3.17.2
|
||||||
version: 3.18.0
|
version: 3.18.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@deepseek-ai/dsh-agent':
|
'@deepseek-ai/dsh-agent':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
@@ -2441,6 +2444,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-session':
|
'@deepseek-ai/dsh-session':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../core/session
|
version: link:../../core/session
|
||||||
|
'@deepseek-ai/dsh-session-projection':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../session-projection/session-projection
|
||||||
cordis:
|
cordis:
|
||||||
specifier: ^4.0.0-rc.7
|
specifier: ^4.0.0-rc.7
|
||||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||||
@@ -2670,6 +2676,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-commands':
|
'@deepseek-ai/dsh-commands':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../ui/commands
|
version: link:../../ui/commands
|
||||||
|
'@deepseek-ai/dsh-goal':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../goal/goal
|
||||||
'@deepseek-ai/dsh-llm':
|
'@deepseek-ai/dsh-llm':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../llm/llm
|
version: link:../../llm/llm
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
"@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"],
|
"@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"],
|
||||||
"@deepseek-ai/dsh-session-title/types": ["./packages/session-title/session-title/src/types.ts"],
|
"@deepseek-ai/dsh-session-title/types": ["./packages/session-title/session-title/src/types.ts"],
|
||||||
"@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
|
"@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
|
||||||
|
"@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
|
||||||
|
"@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
|
||||||
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
|
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
|
||||||
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
|
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
|
||||||
"@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
|
"@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user