@stackstackstack/dsh-goal 0.1.5
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 +21 -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 +815 -0
- package/lib/typert.remote-client.d.ts +41 -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 +82 -0
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import "@stackstackstack/dsh-llm";
|
|
2
|
+
//#region lib/types/runtime.js
|
|
3
|
+
/**
|
|
4
|
+
* Brand a string as a goal id.
|
|
5
|
+
* @param id - raw goal identifier.
|
|
6
|
+
* @returns the same string with the compile-time brand.
|
|
7
|
+
*/
|
|
8
|
+
function GoalId(id) {
|
|
9
|
+
return id;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region lib/types/fold.js
|
|
13
|
+
/** Pure replay fold and strict decoder for durable goal changes. */
|
|
14
|
+
const SNAPSHOT_OPERATIONS = new Set([
|
|
15
|
+
"create",
|
|
16
|
+
"edit",
|
|
17
|
+
"pause",
|
|
18
|
+
"resume",
|
|
19
|
+
"complete",
|
|
20
|
+
"block"
|
|
21
|
+
]);
|
|
22
|
+
const PHASES = new Set([
|
|
23
|
+
"active",
|
|
24
|
+
"paused",
|
|
25
|
+
"blocked",
|
|
26
|
+
"complete"
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Build an empty replay accumulator.
|
|
30
|
+
* @returns mutable state with no current goal or prior ref.
|
|
31
|
+
*/
|
|
32
|
+
function emptyGoalFoldState() {
|
|
33
|
+
return {
|
|
34
|
+
goal: void 0,
|
|
35
|
+
roundsStarted: 0,
|
|
36
|
+
createdAt: void 0,
|
|
37
|
+
updatedAt: void 0,
|
|
38
|
+
lastRef: void 0,
|
|
39
|
+
seenGoalIds: /* @__PURE__ */ new Set()
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Whether a value is a JSON record rather than an array. */
|
|
43
|
+
function isRecord(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
/** Require one positive safe integer. */
|
|
47
|
+
function positiveInteger(value, field) {
|
|
48
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new Error(`goal change ${field} must be a positive safe integer`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
/** Require one non-negative safe integer. */
|
|
52
|
+
function nonNegativeInteger(value, field) {
|
|
53
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`goal change ${field} must be a non-negative safe integer`);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/** Decode one canonical blocker explanation. */
|
|
57
|
+
function decodeBlockReason(value) {
|
|
58
|
+
if (!isRecord(value) || Object.keys(value).sort().join(",") !== "code,message") throw new Error("goal change goal.blockedReason must have exactly code and message fields");
|
|
59
|
+
if (typeof value["code"] !== "string" || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value["code"])) throw new Error("goal change goal.blockedReason.code must be lower-kebab-case");
|
|
60
|
+
if (typeof value["message"] !== "string" || value["message"].trim().length === 0 || value["message"] !== value["message"].trim()) throw new Error("goal change goal.blockedReason.message must be non-empty and normalized");
|
|
61
|
+
return {
|
|
62
|
+
code: value["code"],
|
|
63
|
+
message: value["message"]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Decode and validate one snapshot. */
|
|
67
|
+
function decodeSnapshot(value) {
|
|
68
|
+
if (!isRecord(value)) throw new Error("goal change goal must be a record");
|
|
69
|
+
if (typeof value["id"] !== "string" || value["id"].length === 0) throw new Error("goal change goal.id must be a non-empty string");
|
|
70
|
+
if (typeof value["objective"] !== "string" || value["objective"].trim().length === 0 || value["objective"] !== value["objective"].trim()) throw new Error("goal change goal.objective must be non-empty and normalized");
|
|
71
|
+
if (typeof value["phase"] !== "string" || !PHASES.has(value["phase"])) throw new Error("goal change goal.phase is invalid");
|
|
72
|
+
const phase = value["phase"];
|
|
73
|
+
const expectedKeys = phase === "blocked" ? "blockedReason,id,maxGoalRounds,objective,phase,revision" : "id,maxGoalRounds,objective,phase,revision";
|
|
74
|
+
if (Object.keys(value).sort().join(",") !== expectedKeys) throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`);
|
|
75
|
+
return {
|
|
76
|
+
id: GoalId(value["id"]),
|
|
77
|
+
revision: positiveInteger(value["revision"], "goal.revision"),
|
|
78
|
+
objective: value["objective"],
|
|
79
|
+
phase,
|
|
80
|
+
maxGoalRounds: positiveInteger(value["maxGoalRounds"], "goal.maxGoalRounds"),
|
|
81
|
+
...phase === "blocked" ? { blockedReason: decodeBlockReason(value["blockedReason"]) } : {}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Decode and validate one ref. */
|
|
85
|
+
function decodeRef(value) {
|
|
86
|
+
if (!isRecord(value) || Object.keys(value).sort().join(",") !== "id,revision") throw new Error("goal clear tombstone must have exactly id and revision fields");
|
|
87
|
+
if (typeof value["id"] !== "string" || value["id"].length === 0) throw new Error("goal clear tombstone id must be a non-empty string");
|
|
88
|
+
return {
|
|
89
|
+
id: GoalId(value["id"]),
|
|
90
|
+
revision: positiveInteger(value["revision"], "cleared.revision")
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decode a value that declares itself as a goal change. Unrelated values
|
|
95
|
+
* return `undefined`; malformed goal changes fail replay loudly.
|
|
96
|
+
* @param value - candidate source change.
|
|
97
|
+
* @returns validated goal change or `undefined` for another value kind.
|
|
98
|
+
*/
|
|
99
|
+
function decodeGoalChange(value) {
|
|
100
|
+
if (!isRecord(value) || value["kind"] !== "goal/change") return void 0;
|
|
101
|
+
if (value["version"] !== 1) throw new Error(`unsupported goal change version ${String(value["version"])}`);
|
|
102
|
+
if (value["operation"] === "clear") {
|
|
103
|
+
const allowed = [
|
|
104
|
+
"cleared",
|
|
105
|
+
"clearedAt",
|
|
106
|
+
"kind",
|
|
107
|
+
"operation",
|
|
108
|
+
"version"
|
|
109
|
+
];
|
|
110
|
+
if (Object.keys(value).sort().join(",") !== allowed.sort().join(",")) throw new Error(`goal clear change must have exactly ${allowed.sort().join(",")} fields`);
|
|
111
|
+
return {
|
|
112
|
+
kind: "goal/change",
|
|
113
|
+
version: 1,
|
|
114
|
+
operation: "clear",
|
|
115
|
+
cleared: decodeRef(value["cleared"]),
|
|
116
|
+
clearedAt: nonNegativeInteger(value["clearedAt"], "clearedAt")
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (typeof value["operation"] !== "string" || !SNAPSHOT_OPERATIONS.has(value["operation"])) throw new Error("goal change operation is invalid");
|
|
120
|
+
const allowed = [
|
|
121
|
+
"createdAt",
|
|
122
|
+
"goal",
|
|
123
|
+
"kind",
|
|
124
|
+
"operation",
|
|
125
|
+
"roundsStarted",
|
|
126
|
+
"updatedAt",
|
|
127
|
+
"version"
|
|
128
|
+
];
|
|
129
|
+
if (Object.keys(value).sort().join(",") !== allowed.sort().join(",")) throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(",")} fields`);
|
|
130
|
+
const createdAt = nonNegativeInteger(value["createdAt"], "createdAt");
|
|
131
|
+
const updatedAt = nonNegativeInteger(value["updatedAt"], "updatedAt");
|
|
132
|
+
if (updatedAt < createdAt) throw new Error("goal change updatedAt cannot precede createdAt");
|
|
133
|
+
return {
|
|
134
|
+
kind: "goal/change",
|
|
135
|
+
version: 1,
|
|
136
|
+
operation: value["operation"],
|
|
137
|
+
goal: decodeSnapshot(value["goal"]),
|
|
138
|
+
roundsStarted: nonNegativeInteger(value["roundsStarted"], "roundsStarted"),
|
|
139
|
+
createdAt,
|
|
140
|
+
updatedAt
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Narrow model attribution to a valid goal source. */
|
|
144
|
+
function goalSource(source) {
|
|
145
|
+
if (source.kind !== "goal") return void 0;
|
|
146
|
+
if (typeof source.goalId !== "string" || source.goalId.length === 0 || !Number.isSafeInteger(source.revision) || source.revision < 1 || !Number.isSafeInteger(source.round) || source.round < 1) throw new Error("goal message source is invalid");
|
|
147
|
+
return source;
|
|
148
|
+
}
|
|
149
|
+
/** Require two snapshots to retain fields that only `edit` may replace. */
|
|
150
|
+
function requireSameDefinition(current, next, operation) {
|
|
151
|
+
if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`);
|
|
152
|
+
}
|
|
153
|
+
/** Require one exact next revision of the current goal. */
|
|
154
|
+
function requireNextRevision(current, next, operation) {
|
|
155
|
+
if (next.id !== current.id || next.revision !== current.revision + 1) throw new Error(`goal ${operation} must advance the current goal by one revision`);
|
|
156
|
+
}
|
|
157
|
+
/** Validate one non-create snapshot operation against the preceding projection. */
|
|
158
|
+
function validateSnapshotTransition(state, change, current) {
|
|
159
|
+
const next = change.goal;
|
|
160
|
+
requireNextRevision(current, next, change.operation);
|
|
161
|
+
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
|
|
162
|
+
if (state.updatedAt === void 0) throw new Error("current goal fold lacks updatedAt");
|
|
163
|
+
if (change.createdAt !== state.createdAt || change.updatedAt < state.updatedAt || change.roundsStarted !== state.roundsStarted) throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`);
|
|
164
|
+
switch (change.operation) {
|
|
165
|
+
case "edit":
|
|
166
|
+
if (next.phase !== current.phase || JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) throw new Error("goal edit cannot change phase or blocked reason");
|
|
167
|
+
break;
|
|
168
|
+
case "pause":
|
|
169
|
+
requireSameDefinition(current, next, change.operation);
|
|
170
|
+
if (current.phase !== "active" || next.phase !== "paused") throw new Error("goal pause has an invalid phase transition");
|
|
171
|
+
break;
|
|
172
|
+
case "resume":
|
|
173
|
+
requireSameDefinition(current, next, change.operation);
|
|
174
|
+
if (!new Set([
|
|
175
|
+
"active",
|
|
176
|
+
"paused",
|
|
177
|
+
"blocked"
|
|
178
|
+
]).has(current.phase) || next.phase !== "active" || state.roundsStarted >= next.maxGoalRounds) throw new Error("goal resume has an invalid phase transition or exhausted round budget");
|
|
179
|
+
break;
|
|
180
|
+
case "complete":
|
|
181
|
+
requireSameDefinition(current, next, change.operation);
|
|
182
|
+
if (current.phase === "complete" || next.phase !== "complete") throw new Error("goal complete has an invalid phase transition");
|
|
183
|
+
break;
|
|
184
|
+
case "block":
|
|
185
|
+
requireSameDefinition(current, next, change.operation);
|
|
186
|
+
if (current.phase !== "active" || next.phase !== "blocked") throw new Error("goal block has an invalid phase transition");
|
|
187
|
+
break;
|
|
188
|
+
/* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
|
|
189
|
+
case "create": throw new Error("goal create cannot be validated as a current-goal transition");
|
|
190
|
+
default:
|
|
191
|
+
change.operation;
|
|
192
|
+
throw new Error("unknown goal snapshot operation");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Return the revision identity carried by a snapshot or tombstone.
|
|
197
|
+
* @param change - decoded goal mutation.
|
|
198
|
+
* @returns stable identity used to reconcile a deferred change with its log event.
|
|
199
|
+
*/
|
|
200
|
+
function goalChangeRef(change) {
|
|
201
|
+
return change.operation === "clear" ? change.cleared : {
|
|
202
|
+
id: change.goal.id,
|
|
203
|
+
revision: change.goal.revision
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Validate and apply one decoded change to a mutable accumulator.
|
|
208
|
+
* @param state - preceding durable goal projection.
|
|
209
|
+
* @param change - decoded full snapshot or clear tombstone.
|
|
210
|
+
*/
|
|
211
|
+
function applyGoalChange(state, change) {
|
|
212
|
+
const ref = goalChangeRef(change);
|
|
213
|
+
if (change.operation === "clear") {
|
|
214
|
+
const current = state.goal;
|
|
215
|
+
if (current === void 0) throw new Error("goal clear requires a current goal");
|
|
216
|
+
requireNextRevision(current, change.cleared, change.operation);
|
|
217
|
+
/* v8 ignore next -- a current goal established by this fold always has an updatedAt */
|
|
218
|
+
if (state.updatedAt === void 0) throw new Error("current goal fold lacks updatedAt");
|
|
219
|
+
if (change.clearedAt < state.updatedAt) throw new Error("goal clear timestamp cannot precede the current goal update");
|
|
220
|
+
state.goal = void 0;
|
|
221
|
+
state.roundsStarted = 0;
|
|
222
|
+
state.createdAt = void 0;
|
|
223
|
+
state.updatedAt = void 0;
|
|
224
|
+
state.lastRef = ref;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (change.operation === "create") {
|
|
228
|
+
if (change.goal.revision !== 1 || change.goal.phase !== "active" || change.roundsStarted !== 0 || state.goal !== void 0 && state.goal.phase !== "complete" || state.seenGoalIds.has(change.goal.id)) throw new Error("goal create requires a fresh active revision-one goal with zero rounds");
|
|
229
|
+
state.seenGoalIds.add(change.goal.id);
|
|
230
|
+
} else {
|
|
231
|
+
const current = state.goal;
|
|
232
|
+
if (current === void 0) throw new Error(`goal ${change.operation} requires a current goal`);
|
|
233
|
+
validateSnapshotTransition(state, change, current);
|
|
234
|
+
}
|
|
235
|
+
state.goal = change.goal;
|
|
236
|
+
state.roundsStarted = change.roundsStarted;
|
|
237
|
+
state.createdAt = change.createdAt;
|
|
238
|
+
state.updatedAt = change.updatedAt;
|
|
239
|
+
state.lastRef = ref;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Apply one session event to the strict durable goal fold.
|
|
243
|
+
* @param state - mutable fold accumulator.
|
|
244
|
+
* @param event - next event in sequence order.
|
|
245
|
+
*/
|
|
246
|
+
function applyGoalEvent(state, event) {
|
|
247
|
+
if (event.type === "goal/change") {
|
|
248
|
+
const change = decodeGoalChange(event.data);
|
|
249
|
+
/* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */
|
|
250
|
+
if (change === void 0) throw new Error(`goal change at session event ${event.seq} has an invalid kind`);
|
|
251
|
+
applyGoalChange(state, change);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (event.type === "user/message") {
|
|
255
|
+
const source = goalSource(event.data.source);
|
|
256
|
+
if (source === void 0) return;
|
|
257
|
+
const current = state.goal;
|
|
258
|
+
if (current === void 0 || current.phase !== "active" || source.goalId !== current.id || source.revision !== current.revision || source.round !== state.roundsStarted + 1 || source.round > current.maxGoalRounds) throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`);
|
|
259
|
+
state.roundsStarted = source.round;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region lib/types/invariant.js
|
|
264
|
+
/** Package-owned durable goal-stream invariants. @module @stackstackstack/dsh-goal/invariant */
|
|
265
|
+
const PACKAGE_NAME = "@stackstackstack/dsh-goal";
|
|
266
|
+
/** Cordis companion plugin name. */
|
|
267
|
+
const name = "goal-invariant";
|
|
268
|
+
/** Service required before the companion can reserve package ownership. */
|
|
269
|
+
const inject = ["invariants"];
|
|
270
|
+
/** Copy the independent fold before validating one candidate event. */
|
|
271
|
+
function cloneState(state) {
|
|
272
|
+
return {
|
|
273
|
+
goal: state.goal,
|
|
274
|
+
roundsStarted: state.roundsStarted,
|
|
275
|
+
createdAt: state.createdAt,
|
|
276
|
+
updatedAt: state.updatedAt,
|
|
277
|
+
lastRef: state.lastRef,
|
|
278
|
+
seenGoalIds: new Set(state.seenGoalIds)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** Apply one event through the strict goal decoder and attribute failures. */
|
|
282
|
+
function applyChecked(state, event, fail) {
|
|
283
|
+
try {
|
|
284
|
+
applyGoalEvent(state, event);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
/* v8 ignore next -- the strict goal decoder throws Error instances */
|
|
287
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
288
|
+
fail(`session event ${event.seq} violates the durable goal stream: ${message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Install an independent incremental fold over every attached session. */
|
|
292
|
+
const install = Object.assign((ctx, fail) => {
|
|
293
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
294
|
+
const staged = /* @__PURE__ */ new WeakMap();
|
|
295
|
+
const seed = (session) => {
|
|
296
|
+
const state = emptyGoalFoldState();
|
|
297
|
+
for (const event of session.events) applyChecked(state, event, fail);
|
|
298
|
+
states.set(session, state);
|
|
299
|
+
return state;
|
|
300
|
+
};
|
|
301
|
+
/* v8 ignore next -- session/event always follows list() or session/created seeding */
|
|
302
|
+
const stateFor = (session) => states.get(session) ?? seed(session);
|
|
303
|
+
for (const session of ctx.sessions.list()) seed(session);
|
|
304
|
+
ctx.on("session/created", (session) => {
|
|
305
|
+
seed(session);
|
|
306
|
+
}, { global: true });
|
|
307
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
308
|
+
if (eventName !== "session/event") return;
|
|
309
|
+
const [session, event] = args;
|
|
310
|
+
const state = cloneState(stateFor(session));
|
|
311
|
+
applyChecked(state, event, fail);
|
|
312
|
+
staged.set(event, {
|
|
313
|
+
session,
|
|
314
|
+
state
|
|
315
|
+
});
|
|
316
|
+
}, { global: true });
|
|
317
|
+
ctx.on("session/event", (session, event) => {
|
|
318
|
+
const candidate = staged.get(event);
|
|
319
|
+
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
|
320
|
+
if (candidate === void 0 || candidate.session !== session) return fail("session/event reached publication without matching goal-fold validation");
|
|
321
|
+
staged.delete(event);
|
|
322
|
+
states.set(session, candidate.state);
|
|
323
|
+
}, { global: true });
|
|
324
|
+
}, { inject: ["sessions"] });
|
|
325
|
+
/**
|
|
326
|
+
* Register the goal-stream invariant companion.
|
|
327
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
328
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
329
|
+
*/
|
|
330
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
331
|
+
//#endregion
|
|
332
|
+
export { apply, inject, name };
|