@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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure types of the goal domain: the ONE home of the `goal` projection-key
|
|
3
|
+
* declaration plus the durable payload vocabulary it carries, free of this
|
|
4
|
+
* package's host-side imports (cordis events, dsh-agent, dsh-llm, the
|
|
5
|
+
* service). Two namespace projections serve it — `./types` for host
|
|
6
|
+
* consumers, `./client` (the browser half-entry's re-export) for client
|
|
7
|
+
* aggregates — with zero content duplication. Host-coupled domain
|
|
8
|
+
* vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-goal/types
|
|
11
|
+
*/
|
|
12
|
+
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
13
|
+
/** Identifies one goal across its durable revisions. */
|
|
14
|
+
export type GoalId = Branded<'GoalId'>;
|
|
15
|
+
/** Compare-and-set identity for one exact goal revision. */
|
|
16
|
+
export interface GoalRef {
|
|
17
|
+
/** Stable goal identity. */
|
|
18
|
+
readonly id: GoalId;
|
|
19
|
+
/** Positive revision; every durable mutation increments it. */
|
|
20
|
+
readonly revision: number;
|
|
21
|
+
}
|
|
22
|
+
/** Input whose omitted round cap is resolved by the service configuration. */
|
|
23
|
+
export interface CreateGoalRequest {
|
|
24
|
+
readonly objective: string;
|
|
25
|
+
readonly maxGoalRounds?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Wire-safe acknowledgement of one created goal. */
|
|
28
|
+
export interface CreateGoalResult {
|
|
29
|
+
readonly ref: GoalRef;
|
|
30
|
+
}
|
|
31
|
+
/** Fields changed by an edit; at least one must be present. */
|
|
32
|
+
export interface EditGoalRequest {
|
|
33
|
+
readonly objective?: string;
|
|
34
|
+
readonly maxGoalRounds?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Durable continuation phase. Activation is process-local and separate. */
|
|
37
|
+
export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete';
|
|
38
|
+
/** Machine-routable and human-readable explanation for a blocked goal. */
|
|
39
|
+
export interface GoalBlockReason {
|
|
40
|
+
/** Stable lower-kebab-case classification chosen by the blocking policy. */
|
|
41
|
+
readonly code: string;
|
|
42
|
+
/** Non-empty explanation shown to humans and models. */
|
|
43
|
+
readonly message: string;
|
|
44
|
+
}
|
|
45
|
+
/** Full durable state written by every non-clear goal mutation. */
|
|
46
|
+
export interface GoalSnapshot extends GoalRef {
|
|
47
|
+
/** Human-requested completion objective. */
|
|
48
|
+
readonly objective: string;
|
|
49
|
+
/** Durable lifecycle phase. */
|
|
50
|
+
readonly phase: GoalPhase;
|
|
51
|
+
/** Present exactly while `phase` is `blocked`. */
|
|
52
|
+
readonly blockedReason?: GoalBlockReason;
|
|
53
|
+
/** Total admitted goal-round cap. */
|
|
54
|
+
readonly maxGoalRounds: number;
|
|
55
|
+
}
|
|
56
|
+
/** Whether this live process may automatically continue an active goal. */
|
|
57
|
+
export type GoalActivation = 'armed' | 'disarmed';
|
|
58
|
+
/** Current goal projection, including values derived from the session log. */
|
|
59
|
+
export interface GoalView extends GoalSnapshot {
|
|
60
|
+
/** Highest admitted round number for this goal. */
|
|
61
|
+
readonly roundsStarted: number;
|
|
62
|
+
/** Epoch milliseconds of the create mutation. */
|
|
63
|
+
readonly createdAt: number;
|
|
64
|
+
/** Epoch milliseconds of the latest mutation. */
|
|
65
|
+
readonly updatedAt: number;
|
|
66
|
+
/** Process-local continuation eligibility; never persisted. */
|
|
67
|
+
readonly activation: GoalActivation;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The `goal` projection value: the current durable goal with its replay
|
|
71
|
+
* counters, exactly as the latest `goal/change` event carried them.
|
|
72
|
+
* Activation is process-local (never persisted) and deliberately absent —
|
|
73
|
+
* the projection reflects durable phase only.
|
|
74
|
+
*/
|
|
75
|
+
export interface GoalProjection {
|
|
76
|
+
/** Current durable goal snapshot (the CAS ref for mutations rides on it). */
|
|
77
|
+
readonly goal: GoalSnapshot;
|
|
78
|
+
/** Highest admitted round number for this goal. */
|
|
79
|
+
readonly roundsStarted: number;
|
|
80
|
+
/** Epoch milliseconds of the create mutation. */
|
|
81
|
+
readonly createdAt: number;
|
|
82
|
+
/** Epoch milliseconds of the latest mutation. */
|
|
83
|
+
readonly updatedAt: number;
|
|
84
|
+
}
|
|
85
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
86
|
+
interface SessionProjectionMap {
|
|
87
|
+
/**
|
|
88
|
+
* The session's current goal (the latest `goal/change` whole value), or
|
|
89
|
+
* `null` before the first create and after a clear tombstone.
|
|
90
|
+
* Whole-value rule: every goal change carries the complete post-change
|
|
91
|
+
* state, so the fold is last-wins.
|
|
92
|
+
*/
|
|
93
|
+
goal: GoalProjection | null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure types of the goal domain: the ONE home of the `goal` projection-key
|
|
3
|
+
* declaration plus the durable payload vocabulary it carries, free of this
|
|
4
|
+
* package's host-side imports (cordis events, dsh-agent, dsh-llm, the
|
|
5
|
+
* service). Two namespace projections serve it — `./types` for host
|
|
6
|
+
* consumers, `./client` (the browser half-entry's re-export) for client
|
|
7
|
+
* aggregates — with zero content duplication. Host-coupled domain
|
|
8
|
+
* vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-goal/types
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-goal",
|
|
3
|
+
"description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
|
|
4
|
+
"version": "0.0.1-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/goal/goal"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./types": {
|
|
26
|
+
"types": "./lib/types/types.d.ts",
|
|
27
|
+
"default": "./lib/types/types.js"
|
|
28
|
+
},
|
|
29
|
+
"./client": {
|
|
30
|
+
"types": "./lib/types/client.d.ts",
|
|
31
|
+
"default": "./lib/types/client.js"
|
|
32
|
+
},
|
|
33
|
+
"./typert": {
|
|
34
|
+
"types": "./lib/typert.host.d.ts",
|
|
35
|
+
"default": "./lib/typert.host.js"
|
|
36
|
+
},
|
|
37
|
+
"./remote": {
|
|
38
|
+
"types": "./lib/typert.remote-client.d.ts",
|
|
39
|
+
"default": "./lib/typert.remote-client.js"
|
|
40
|
+
},
|
|
41
|
+
"./src/*": "./src/*",
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"lib/index.js",
|
|
46
|
+
"lib/invariant.js",
|
|
47
|
+
"lib/types/**/*.js",
|
|
48
|
+
"lib/types/**/*.d.ts",
|
|
49
|
+
"lib/typert.host.js",
|
|
50
|
+
"lib/typert.host.d.ts",
|
|
51
|
+
"lib/typert.remote-client.js",
|
|
52
|
+
"lib/typert.remote-client.d.ts",
|
|
53
|
+
"lib/typert.remote-client.d.ts.map",
|
|
54
|
+
"src"
|
|
55
|
+
],
|
|
56
|
+
"license": "BSD-3-Clause",
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@deepseek-ai/dsh-session-projection": "^0.0.1-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-brand": "^0.0.1-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-scope": "^0.0.1-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-type-meta": "^0.0.1-rc.1",
|
|
64
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"zod": "^4.4.3",
|
|
70
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-session-projection": "^0.0.1-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-brand": "^0.0.1-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
77
|
+
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-type-meta": "^0.0.1-rc.1",
|
|
80
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-loader-smoke": "^0.0.1-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-scope": "^0.0.1-rc.1"
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-namespace projection of the goal domain: a pure re-export of the
|
|
3
|
+
* package's types outlet. Client code imports ONLY the client namespace
|
|
4
|
+
* (repo discipline), so `./client` projects the same single-source content
|
|
5
|
+
* `./types` serves to host consumers — zero duplication.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-goal/client
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type * from './types.ts'
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side vocabulary of the goal domain: live views, durable change
|
|
3
|
+
* payloads, message attribution, replay folds, and the scoped `goal/changed`
|
|
4
|
+
* event. Kept separate from ./types.ts (the pure client-safe outlet) because
|
|
5
|
+
* these declarations pull dsh-agent, dsh-llm, and cordis into the program —
|
|
6
|
+
* the one-program-per-side layout forbids that on client aggregates.
|
|
7
|
+
* @module @deepseek-ai/dsh-goal
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
11
|
+
import type { GoalId, GoalRef, GoalSnapshot, GoalView } from './types.ts'
|
|
12
|
+
|
|
13
|
+
/** Goal state-changing verbs recorded in the durable source change. */
|
|
14
|
+
export type GoalOperation =
|
|
15
|
+
| 'create'
|
|
16
|
+
| 'edit'
|
|
17
|
+
| 'pause'
|
|
18
|
+
| 'resume'
|
|
19
|
+
| 'complete'
|
|
20
|
+
| 'block'
|
|
21
|
+
| 'clear'
|
|
22
|
+
|
|
23
|
+
/** Full-snapshot goal mutation committed by a durable `goal/change` event. */
|
|
24
|
+
export interface GoalSnapshotChangeMeta {
|
|
25
|
+
readonly kind: 'goal/change'
|
|
26
|
+
readonly version: 1
|
|
27
|
+
readonly operation: Exclude<GoalOperation, 'clear'>
|
|
28
|
+
readonly goal: GoalSnapshot
|
|
29
|
+
readonly roundsStarted: number
|
|
30
|
+
readonly createdAt: number
|
|
31
|
+
readonly updatedAt: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tombstone retained when the current goal is cleared. */
|
|
35
|
+
export interface GoalClearChangeMeta {
|
|
36
|
+
readonly kind: 'goal/change'
|
|
37
|
+
readonly version: 1
|
|
38
|
+
readonly operation: 'clear'
|
|
39
|
+
readonly cleared: GoalRef
|
|
40
|
+
readonly clearedAt: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Durable change union carried by the goal domain's own session event. */
|
|
44
|
+
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
|
45
|
+
|
|
46
|
+
/** Message attribution for admitted continuation rounds. */
|
|
47
|
+
export interface GoalMessageSource {
|
|
48
|
+
readonly kind: 'goal'
|
|
49
|
+
readonly goalId: GoalId
|
|
50
|
+
readonly revision: number
|
|
51
|
+
/** Positive admitted continuation round. */
|
|
52
|
+
readonly round: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
56
|
+
interface MessageSourceMap {
|
|
57
|
+
goal: GoalMessageSource
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
62
|
+
interface SessionEventMap {
|
|
63
|
+
/**
|
|
64
|
+
* Complete post-mutation goal state or clear tombstone.
|
|
65
|
+
*/
|
|
66
|
+
'goal/change': GoalChangeMeta
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pure replay fold of durable goal facts. */
|
|
71
|
+
export interface FoldedGoal {
|
|
72
|
+
/** Current goal, absent after a clear or before the first create. */
|
|
73
|
+
readonly goal?: GoalSnapshot
|
|
74
|
+
/** Highest admitted round for the current goal. */
|
|
75
|
+
readonly roundsStarted: number
|
|
76
|
+
/** Current goal creation time, absent without a current goal. */
|
|
77
|
+
readonly createdAt?: number
|
|
78
|
+
/** Current goal mutation time, absent without a current goal. */
|
|
79
|
+
readonly updatedAt?: number
|
|
80
|
+
/** Latest mutation ref, including a clear tombstone. */
|
|
81
|
+
readonly lastRef?: GoalRef
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Live notification after one durable goal mutation commits. */
|
|
85
|
+
export interface GoalChanged {
|
|
86
|
+
readonly operation: GoalOperation
|
|
87
|
+
readonly ref: GoalRef
|
|
88
|
+
/** Absent for a clear tombstone. */
|
|
89
|
+
readonly goal?: GoalView
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stable error codes for rejected goal reads and mutations. */
|
|
93
|
+
export type GoalErrorCode =
|
|
94
|
+
| 'GOAL_AGENT_NOT_LIVE'
|
|
95
|
+
| 'GOAL_NOT_FOUND'
|
|
96
|
+
| 'GOAL_ALREADY_EXISTS'
|
|
97
|
+
| 'GOAL_STALE_REVISION'
|
|
98
|
+
| 'GOAL_INVALID_OBJECTIVE'
|
|
99
|
+
| 'GOAL_INVALID_MAX_ROUNDS'
|
|
100
|
+
| 'GOAL_INVALID_BLOCK_REASON'
|
|
101
|
+
| 'GOAL_INVALID_EDIT'
|
|
102
|
+
| 'GOAL_INVALID_TRANSITION'
|
|
103
|
+
|
|
104
|
+
declare module '@deepseek-ai/cordis' {
|
|
105
|
+
interface Events {
|
|
106
|
+
/**
|
|
107
|
+
* Goal mutation accepted by one live agent. The matching `goal/change`
|
|
108
|
+
* session event has already committed. Listener failures are contained.
|
|
109
|
+
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
|
110
|
+
* @param payload.agent - agent whose session owns the goal.
|
|
111
|
+
* @param payload.change - fresh current projection or clear tombstone.
|
|
112
|
+
* @mode emit
|
|
113
|
+
*/
|
|
114
|
+
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/fold.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/** Pure replay fold and strict decoder for durable goal changes. */
|
|
2
|
+
|
|
3
|
+
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
|
|
6
|
+
import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
|
|
7
|
+
import type {
|
|
8
|
+
FoldedGoal,
|
|
9
|
+
GoalChangeMeta,
|
|
10
|
+
GoalClearChangeMeta,
|
|
11
|
+
GoalMessageSource,
|
|
12
|
+
GoalOperation,
|
|
13
|
+
GoalSnapshotChangeMeta,
|
|
14
|
+
} from './domain.ts'
|
|
15
|
+
|
|
16
|
+
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
|
|
17
|
+
'create',
|
|
18
|
+
'edit',
|
|
19
|
+
'pause',
|
|
20
|
+
'resume',
|
|
21
|
+
'complete',
|
|
22
|
+
'block',
|
|
23
|
+
])
|
|
24
|
+
const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
|
|
25
|
+
|
|
26
|
+
/** Mutable accumulator kept private to the pure fold. */
|
|
27
|
+
export interface GoalFoldState {
|
|
28
|
+
goal: GoalSnapshot | undefined
|
|
29
|
+
roundsStarted: number
|
|
30
|
+
createdAt: number | undefined
|
|
31
|
+
updatedAt: number | undefined
|
|
32
|
+
lastRef: GoalRef | undefined
|
|
33
|
+
seenGoalIds: Set<GoalSnapshot['id']>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build an empty replay accumulator.
|
|
38
|
+
* @returns mutable state with no current goal or prior ref.
|
|
39
|
+
*/
|
|
40
|
+
export function emptyGoalFoldState(): GoalFoldState {
|
|
41
|
+
return {
|
|
42
|
+
goal: undefined,
|
|
43
|
+
roundsStarted: 0,
|
|
44
|
+
createdAt: undefined,
|
|
45
|
+
updatedAt: undefined,
|
|
46
|
+
lastRef: undefined,
|
|
47
|
+
seenGoalIds: new Set(),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a value is a JSON record rather than an array. */
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Require one positive safe integer. */
|
|
57
|
+
function positiveInteger(value: unknown, field: string): number {
|
|
58
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
59
|
+
throw new Error(`goal change ${field} must be a positive safe integer`)
|
|
60
|
+
}
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Require one non-negative safe integer. */
|
|
65
|
+
function nonNegativeInteger(value: unknown, field: string): number {
|
|
66
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
67
|
+
throw new Error(`goal change ${field} must be a non-negative safe integer`)
|
|
68
|
+
}
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Decode one canonical blocker explanation. */
|
|
73
|
+
function decodeBlockReason(value: unknown): GoalBlockReason {
|
|
74
|
+
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
|
|
75
|
+
throw new Error('goal change goal.blockedReason must have exactly code and message fields')
|
|
76
|
+
}
|
|
77
|
+
if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
|
|
78
|
+
throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
|
|
79
|
+
}
|
|
80
|
+
if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
|
|
81
|
+
|| value['message'] !== value['message'].trim()) {
|
|
82
|
+
throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
|
|
83
|
+
}
|
|
84
|
+
return { code: value['code'], message: value['message'] }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Decode and validate one snapshot. */
|
|
88
|
+
function decodeSnapshot(value: unknown): GoalSnapshot {
|
|
89
|
+
if (!isRecord(value)) throw new Error('goal change goal must be a record')
|
|
90
|
+
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
|
|
91
|
+
throw new Error('goal change goal.id must be a non-empty string')
|
|
92
|
+
}
|
|
93
|
+
if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0
|
|
94
|
+
|| value['objective'] !== value['objective'].trim()) {
|
|
95
|
+
throw new Error('goal change goal.objective must be non-empty and normalized')
|
|
96
|
+
}
|
|
97
|
+
if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
|
|
98
|
+
throw new Error('goal change goal.phase is invalid')
|
|
99
|
+
}
|
|
100
|
+
const phase = value['phase'] as GoalPhase
|
|
101
|
+
const expectedKeys = phase === 'blocked'
|
|
102
|
+
? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
|
|
103
|
+
: 'id,maxGoalRounds,objective,phase,revision'
|
|
104
|
+
if (Object.keys(value).sort().join(',') !== expectedKeys) {
|
|
105
|
+
throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`)
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
id: GoalId(value['id']),
|
|
109
|
+
revision: positiveInteger(value['revision'], 'goal.revision'),
|
|
110
|
+
objective: value['objective'],
|
|
111
|
+
phase,
|
|
112
|
+
maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
|
|
113
|
+
...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Decode and validate one ref. */
|
|
118
|
+
function decodeRef(value: unknown): GoalRef {
|
|
119
|
+
if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') {
|
|
120
|
+
throw new Error('goal clear tombstone must have exactly id and revision fields')
|
|
121
|
+
}
|
|
122
|
+
if (typeof value['id'] !== 'string' || value['id'].length === 0) {
|
|
123
|
+
throw new Error('goal clear tombstone id must be a non-empty string')
|
|
124
|
+
}
|
|
125
|
+
return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Decode a value that declares itself as a goal change. Unrelated values
|
|
130
|
+
* return `undefined`; malformed goal changes fail replay loudly.
|
|
131
|
+
* @param value - candidate source change.
|
|
132
|
+
* @returns validated goal change or `undefined` for another value kind.
|
|
133
|
+
*/
|
|
134
|
+
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
|
|
135
|
+
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
|
|
136
|
+
if (value['version'] !== GOAL_CHANGE_VERSION) {
|
|
137
|
+
throw new Error(`unsupported goal change version ${String(value['version'])}`)
|
|
138
|
+
}
|
|
139
|
+
if (value['operation'] === 'clear') {
|
|
140
|
+
const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version']
|
|
141
|
+
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
|
|
142
|
+
throw new Error(`goal clear change must have exactly ${allowed.sort().join(',')} fields`)
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
kind: 'goal/change',
|
|
146
|
+
version: GOAL_CHANGE_VERSION,
|
|
147
|
+
operation: 'clear',
|
|
148
|
+
cleared: decodeRef(value['cleared']),
|
|
149
|
+
clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'),
|
|
150
|
+
} satisfies GoalClearChangeMeta
|
|
151
|
+
}
|
|
152
|
+
if (typeof value['operation'] !== 'string'
|
|
153
|
+
|| !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude<GoalOperation, 'clear'>)) {
|
|
154
|
+
throw new Error('goal change operation is invalid')
|
|
155
|
+
}
|
|
156
|
+
const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']
|
|
157
|
+
if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
|
|
158
|
+
throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(',')} fields`)
|
|
159
|
+
}
|
|
160
|
+
const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')
|
|
161
|
+
const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')
|
|
162
|
+
if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')
|
|
163
|
+
return {
|
|
164
|
+
kind: 'goal/change',
|
|
165
|
+
version: GOAL_CHANGE_VERSION,
|
|
166
|
+
operation: value['operation'] as Exclude<GoalOperation, 'clear'>,
|
|
167
|
+
goal: decodeSnapshot(value['goal']),
|
|
168
|
+
roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),
|
|
169
|
+
createdAt,
|
|
170
|
+
updatedAt,
|
|
171
|
+
} satisfies GoalSnapshotChangeMeta
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Narrow model attribution to a valid goal source. */
|
|
175
|
+
function goalSource(source: MessageSource): GoalMessageSource | undefined {
|
|
176
|
+
if (source.kind !== 'goal') return undefined
|
|
177
|
+
if (typeof source.goalId !== 'string' || source.goalId.length === 0
|
|
178
|
+
|| !Number.isSafeInteger(source.revision) || source.revision < 1
|
|
179
|
+
|| !Number.isSafeInteger(source.round) || source.round < 1) {
|
|
180
|
+
throw new Error('goal message source is invalid')
|
|
181
|
+
}
|
|
182
|
+
return source
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Require two snapshots to retain fields that only `edit` may replace. */
|
|
186
|
+
function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {
|
|
187
|
+
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {
|
|
188
|
+
throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Require one exact next revision of the current goal. */
|
|
193
|
+
function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {
|
|
194
|
+
if (next.id !== current.id || next.revision !== current.revision + 1) {
|
|
195
|
+
throw new Error(`goal ${operation} must advance the current goal by one revision`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Validate one non-create snapshot operation against the preceding projection. */
|
|
200
|
+
function validateSnapshotTransition(
|
|
201
|
+
state: GoalFoldState,
|
|
202
|
+
change: GoalSnapshotChangeMeta,
|
|
203
|
+
current: GoalSnapshot,
|
|
204
|
+
): void {
|
|
205
|
+
const next = change.goal
|
|
206
|
+
requireNextRevision(current, next, change.operation)
|
|
207
|
+
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
|
|
208
|
+
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
|
|
209
|
+
if (change.createdAt !== state.createdAt
|
|
210
|
+
|| change.updatedAt < state.updatedAt
|
|
211
|
+
|| change.roundsStarted !== state.roundsStarted) {
|
|
212
|
+
throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)
|
|
213
|
+
}
|
|
214
|
+
switch (change.operation) {
|
|
215
|
+
case 'edit':
|
|
216
|
+
if (next.phase !== current.phase
|
|
217
|
+
|| JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
|
|
218
|
+
throw new Error('goal edit cannot change phase or blocked reason')
|
|
219
|
+
}
|
|
220
|
+
break
|
|
221
|
+
case 'pause':
|
|
222
|
+
requireSameDefinition(current, next, change.operation)
|
|
223
|
+
if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition')
|
|
224
|
+
break
|
|
225
|
+
case 'resume': {
|
|
226
|
+
requireSameDefinition(current, next, change.operation)
|
|
227
|
+
const resumable: ReadonlySet<GoalPhase> = new Set([
|
|
228
|
+
'active',
|
|
229
|
+
'paused',
|
|
230
|
+
'blocked',
|
|
231
|
+
])
|
|
232
|
+
if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
|
|
233
|
+
throw new Error('goal resume has an invalid phase transition or exhausted round budget')
|
|
234
|
+
}
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
case 'complete':
|
|
238
|
+
requireSameDefinition(current, next, change.operation)
|
|
239
|
+
if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition')
|
|
240
|
+
break
|
|
241
|
+
case 'block':
|
|
242
|
+
requireSameDefinition(current, next, change.operation)
|
|
243
|
+
if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
|
|
244
|
+
break
|
|
245
|
+
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
|
|
246
|
+
case 'create':
|
|
247
|
+
throw new Error('goal create cannot be validated as a current-goal transition')
|
|
248
|
+
default:
|
|
249
|
+
change.operation satisfies never
|
|
250
|
+
throw new Error('unknown goal snapshot operation')
|
|
251
|
+
/* v8 ignore stop */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Return the revision identity carried by a snapshot or tombstone.
|
|
257
|
+
* @param change - decoded goal mutation.
|
|
258
|
+
* @returns stable identity used to reconcile a deferred change with its log event.
|
|
259
|
+
*/
|
|
260
|
+
export function goalChangeRef(change: GoalChangeMeta): GoalRef {
|
|
261
|
+
return change.operation === 'clear'
|
|
262
|
+
? change.cleared
|
|
263
|
+
: { id: change.goal.id, revision: change.goal.revision }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Validate and apply one decoded change to a mutable accumulator.
|
|
268
|
+
* @param state - preceding durable goal projection.
|
|
269
|
+
* @param change - decoded full snapshot or clear tombstone.
|
|
270
|
+
*/
|
|
271
|
+
export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {
|
|
272
|
+
const ref = goalChangeRef(change)
|
|
273
|
+
if (change.operation === 'clear') {
|
|
274
|
+
const current = state.goal
|
|
275
|
+
if (current === undefined) throw new Error('goal clear requires a current goal')
|
|
276
|
+
requireNextRevision(current, change.cleared, change.operation)
|
|
277
|
+
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
|
|
278
|
+
if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
|
|
279
|
+
if (change.clearedAt < state.updatedAt) {
|
|
280
|
+
throw new Error('goal clear timestamp cannot precede the current goal update')
|
|
281
|
+
}
|
|
282
|
+
state.goal = undefined
|
|
283
|
+
state.roundsStarted = 0
|
|
284
|
+
state.createdAt = undefined
|
|
285
|
+
state.updatedAt = undefined
|
|
286
|
+
state.lastRef = ref
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
if (change.operation === 'create') {
|
|
290
|
+
if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0
|
|
291
|
+
|| (state.goal !== undefined && state.goal.phase !== 'complete')
|
|
292
|
+
|| state.seenGoalIds.has(change.goal.id)) {
|
|
293
|
+
throw new Error('goal create requires a fresh active revision-one goal with zero rounds')
|
|
294
|
+
}
|
|
295
|
+
state.seenGoalIds.add(change.goal.id)
|
|
296
|
+
} else {
|
|
297
|
+
const current = state.goal
|
|
298
|
+
if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)
|
|
299
|
+
validateSnapshotTransition(state, change, current)
|
|
300
|
+
}
|
|
301
|
+
state.goal = change.goal
|
|
302
|
+
state.roundsStarted = change.roundsStarted
|
|
303
|
+
state.createdAt = change.createdAt
|
|
304
|
+
state.updatedAt = change.updatedAt
|
|
305
|
+
state.lastRef = ref
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Apply one session event to the strict durable goal fold.
|
|
310
|
+
* @param state - mutable fold accumulator.
|
|
311
|
+
* @param event - next event in sequence order.
|
|
312
|
+
*/
|
|
313
|
+
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {
|
|
314
|
+
if (event.type === 'goal/change') {
|
|
315
|
+
const change = decodeGoalChange(event.data)
|
|
316
|
+
/* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */
|
|
317
|
+
if (change === undefined) throw new Error(`goal change at session event ${event.seq} has an invalid kind`)
|
|
318
|
+
applyGoalChange(state, change)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (event.type === 'user/message') {
|
|
322
|
+
const source = goalSource(event.data.source)
|
|
323
|
+
if (source === undefined) return
|
|
324
|
+
const current = state.goal
|
|
325
|
+
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
|
|
326
|
+
|| source.revision !== current.revision || source.round !== state.roundsStarted + 1
|
|
327
|
+
|| source.round > current.maxGoalRounds) {
|
|
328
|
+
throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
|
|
329
|
+
}
|
|
330
|
+
state.roundsStarted = source.round
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Fold current goal state from a contiguous session event log.
|
|
336
|
+
* @param events - session events in sequence order.
|
|
337
|
+
* @returns a fresh durable projection; activation is deliberately absent.
|
|
338
|
+
*/
|
|
339
|
+
export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {
|
|
340
|
+
const state = emptyGoalFoldState()
|
|
341
|
+
for (const event of events) applyGoalEvent(state, event)
|
|
342
|
+
return {
|
|
343
|
+
...state.goal === undefined ? {} : { goal: { ...state.goal } },
|
|
344
|
+
roundsStarted: state.roundsStarted,
|
|
345
|
+
...state.createdAt === undefined ? {} : { createdAt: state.createdAt },
|
|
346
|
+
...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },
|
|
347
|
+
...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },
|
|
348
|
+
}
|
|
349
|
+
}
|