silvermoon 0.1.3 → 0.2.0-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/domain.js ADDED
@@ -0,0 +1,592 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { respond } from "./response.js";
4
+ import {
5
+ emitDomainMessage,
6
+ traceAsync,
7
+ } from "./trace.js";
8
+
9
+ export const DOMAIN_MESSAGE_SCHEMA_VERSION = 1;
10
+
11
+ const PROGRESS = new Set(["initial", "accepted", "observing", "ready", "blocked"]);
12
+ const TERMINAL_PROGRESS = new Set(["ready", "blocked"]);
13
+
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+
18
+ function clone(value) {
19
+ return value === undefined ? undefined : structuredClone(value);
20
+ }
21
+
22
+ function deepFreeze(value) {
23
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
24
+ return value;
25
+ }
26
+ for (const child of Object.values(value)) deepFreeze(child);
27
+ return Object.freeze(value);
28
+ }
29
+
30
+ function frozenClone(value) {
31
+ return deepFreeze(clone(value));
32
+ }
33
+
34
+ export class DomainInvariantError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "DomainInvariantError";
38
+ }
39
+ }
40
+
41
+ export function initialInternalObservation() {
42
+ return deepFreeze({
43
+ actions: {},
44
+ intention: null,
45
+ nextSequence: 1,
46
+ observation: null,
47
+ progress: "initial",
48
+ responseContext: null,
49
+ responseKind: null,
50
+ responseMetadata: null,
51
+ });
52
+ }
53
+
54
+ function requireMessage(state, message) {
55
+ if (message?.schemaVersion !== DOMAIN_MESSAGE_SCHEMA_VERSION) {
56
+ throw new DomainInvariantError(
57
+ `Unsupported domain message schema version: ${message?.schemaVersion}`,
58
+ );
59
+ }
60
+ if (message.sequence !== state.nextSequence) {
61
+ throw new DomainInvariantError(
62
+ `Expected domain message sequence ${state.nextSequence}, received ${message.sequence}`,
63
+ );
64
+ }
65
+ if (typeof message.type !== "string" || message.type.length === 0) {
66
+ throw new DomainInvariantError("Domain message type must be a non-empty string");
67
+ }
68
+ }
69
+
70
+ function transitionProgress(current, next) {
71
+ if (!PROGRESS.has(next)) {
72
+ throw new DomainInvariantError(`Unknown command progress: ${next}`);
73
+ }
74
+ if (TERMINAL_PROGRESS.has(current)) {
75
+ throw new DomainInvariantError(
76
+ `Cannot transition command progress from terminal state ${current}`,
77
+ );
78
+ }
79
+ if (current === "initial" && next !== "accepted") {
80
+ throw new DomainInvariantError(
81
+ `Command progress must transition from initial to accepted, not ${next}`,
82
+ );
83
+ }
84
+ if (current === "accepted" && next === "initial") {
85
+ throw new DomainInvariantError("Command progress cannot return to initial");
86
+ }
87
+ return next;
88
+ }
89
+
90
+ function applyObservationFact(state, fact) {
91
+ if (typeof fact?.type !== "string" || fact.type.length === 0) {
92
+ throw new DomainInvariantError("Observation fact type must be a non-empty string");
93
+ }
94
+ if (!fact.observation || typeof fact.observation.state !== "string") {
95
+ throw new DomainInvariantError(
96
+ `${fact.type} requires a structured observation`,
97
+ );
98
+ }
99
+ const progress = transitionProgress(state.progress, fact.progress);
100
+ return {
101
+ ...state,
102
+ observation: frozenClone(fact.observation),
103
+ progress,
104
+ responseContext: frozenClone(fact.responseContext ?? {}),
105
+ };
106
+ }
107
+
108
+ export function reduceObservation(state, message) {
109
+ requireMessage(state, message);
110
+ let next = { ...state, nextSequence: state.nextSequence + 1 };
111
+
112
+ if (message.type === "intention.accepted") {
113
+ if (state.intention !== null) {
114
+ throw new DomainInvariantError("A command can accept only one intention");
115
+ }
116
+ if (
117
+ !isRecord(message.intention)
118
+ || typeof message.intention.command !== "string"
119
+ || !isRecord(message.intention.args)
120
+ ) {
121
+ throw new DomainInvariantError(
122
+ "intention.accepted requires a normalized command and args",
123
+ );
124
+ }
125
+ next.intention = frozenClone(message.intention);
126
+ next.progress = transitionProgress(state.progress, "accepted");
127
+ return deepFreeze(next);
128
+ }
129
+
130
+ if (state.intention === null) {
131
+ throw new DomainInvariantError(
132
+ "The first domain message must be intention.accepted",
133
+ );
134
+ }
135
+
136
+ if (message.type === "observation.fact") {
137
+ next = applyObservationFact(next, message.fact);
138
+ return deepFreeze(next);
139
+ }
140
+
141
+ if (message.type === "action.requested") {
142
+ if (TERMINAL_PROGRESS.has(state.progress)) {
143
+ throw new DomainInvariantError("Cannot request an action after a terminal observation");
144
+ }
145
+ if (state.actions[message.actionId] !== undefined) {
146
+ throw new DomainInvariantError(`Duplicate action id: ${message.actionId}`);
147
+ }
148
+ if (!isRecord(message.action) || typeof message.action.type !== "string") {
149
+ throw new DomainInvariantError(
150
+ `Action ${message.actionId} requires a structured request`,
151
+ );
152
+ }
153
+ next.actions = {
154
+ ...state.actions,
155
+ [message.actionId]: {
156
+ status: "requested",
157
+ type: message.action.type,
158
+ },
159
+ };
160
+ return deepFreeze(next);
161
+ }
162
+
163
+ if (message.type === "action.finished") {
164
+ const requested = state.actions[message.actionId];
165
+ if (requested === undefined) {
166
+ throw new DomainInvariantError(
167
+ `Action ${message.actionId} finished without being requested`,
168
+ );
169
+ }
170
+ if (requested.status !== "requested") {
171
+ throw new DomainInvariantError(
172
+ `Action ${message.actionId} finished more than once`,
173
+ );
174
+ }
175
+ if (requested.type !== message.actionType) {
176
+ throw new DomainInvariantError(
177
+ `Action ${message.actionId} changed type from ${requested.type} to ${message.actionType}`,
178
+ );
179
+ }
180
+ if (message.status !== "success" && message.status !== "failure") {
181
+ throw new DomainInvariantError(
182
+ `Action ${message.actionId} has invalid status ${message.status}`,
183
+ );
184
+ }
185
+ if (!Array.isArray(message.facts)) {
186
+ throw new DomainInvariantError(
187
+ `Action ${message.actionId} facts must be an array`,
188
+ );
189
+ }
190
+ if (message.status === "success" && !isRecord(message.result)) {
191
+ throw new DomainInvariantError(
192
+ `Successful action ${message.actionId} requires a structured result`,
193
+ );
194
+ }
195
+ if (
196
+ message.status === "failure"
197
+ && (
198
+ !isRecord(message.problem)
199
+ || typeof message.problem.type !== "string"
200
+ || message.problem.type.length === 0
201
+ )
202
+ ) {
203
+ throw new DomainInvariantError(
204
+ `Failed action ${message.actionId} requires a structured problem`,
205
+ );
206
+ }
207
+ next.actions = {
208
+ ...state.actions,
209
+ [message.actionId]: {
210
+ status: message.status,
211
+ type: message.actionType,
212
+ },
213
+ };
214
+ for (const fact of message.facts ?? []) {
215
+ next = applyObservationFact(next, fact);
216
+ }
217
+ return deepFreeze(next);
218
+ }
219
+
220
+ if (message.type === "response.created") {
221
+ if (!TERMINAL_PROGRESS.has(state.progress) || state.observation === null) {
222
+ throw new DomainInvariantError(
223
+ "Cannot create a response before a terminal observation",
224
+ );
225
+ }
226
+ if (
227
+ Object.values(state.actions).some(({ status }) => status === "requested")
228
+ ) {
229
+ throw new DomainInvariantError(
230
+ "Cannot create a response while an action is pending",
231
+ );
232
+ }
233
+ if (state.responseKind !== null) {
234
+ throw new DomainInvariantError("A command can create only one response");
235
+ }
236
+ if (
237
+ typeof message.kind !== "string"
238
+ || !isRecord(message.metadata)
239
+ || !/^[0-9a-f]{64}$/.test(message.metadata.hash)
240
+ || !Number.isInteger(message.metadata.nextStepCount)
241
+ || !Number.isInteger(message.metadata.itemCount)
242
+ ) {
243
+ throw new DomainInvariantError(
244
+ "response.created requires a kind and deterministic metadata",
245
+ );
246
+ }
247
+ next.responseKind = message.kind;
248
+ next.responseMetadata = frozenClone(message.metadata);
249
+ return deepFreeze(next);
250
+ }
251
+
252
+ throw new DomainInvariantError(`Unknown domain message type: ${message.type}`);
253
+ }
254
+
255
+ export function replayObservation(messages) {
256
+ return messages.reduce(
257
+ (state, message) => reduceObservation(state, message),
258
+ initialInternalObservation(),
259
+ );
260
+ }
261
+
262
+ export function projectIntention(messages) {
263
+ const accepted = messages.filter(({ type }) => type === "intention.accepted");
264
+ if (accepted.length !== 1 || messages[0] !== accepted[0]) {
265
+ throw new DomainInvariantError(
266
+ "A domain stream must begin with exactly one intention.accepted message",
267
+ );
268
+ }
269
+ return clone(accepted[0].intention);
270
+ }
271
+
272
+ export function projectActions(messages, { allowPending = false } = {}) {
273
+ const records = new Map();
274
+ for (const message of messages) {
275
+ if (message.type === "action.requested") {
276
+ if (records.has(message.actionId)) {
277
+ throw new DomainInvariantError(`Duplicate action id: ${message.actionId}`);
278
+ }
279
+ records.set(message.actionId, {
280
+ id: message.actionId,
281
+ type: message.action.type,
282
+ status: "requested",
283
+ });
284
+ continue;
285
+ }
286
+ if (message.type !== "action.finished") continue;
287
+ const record = records.get(message.actionId);
288
+ if (record === undefined) {
289
+ throw new DomainInvariantError(
290
+ `Action ${message.actionId} finished without being requested`,
291
+ );
292
+ }
293
+ if (record.status !== "requested") {
294
+ throw new DomainInvariantError(
295
+ `Action ${message.actionId} finished more than once`,
296
+ );
297
+ }
298
+ if (record.type !== message.actionType) {
299
+ throw new DomainInvariantError(
300
+ `Action ${message.actionId} changed type from ${record.type} to ${message.actionType}`,
301
+ );
302
+ }
303
+ record.status = message.status;
304
+ if (message.status === "success") {
305
+ record.result = clone(message.result ?? {});
306
+ } else {
307
+ record.problem = clone(message.problem);
308
+ }
309
+ }
310
+ if (
311
+ !allowPending
312
+ && [...records.values()].some(({ status }) => status === "requested")
313
+ ) {
314
+ throw new DomainInvariantError("A final report cannot contain a pending action");
315
+ }
316
+ return [...records.values()].map((record) => clone(record));
317
+ }
318
+
319
+ export function projectPublicObservation(internalObservation) {
320
+ const observation = clone(internalObservation);
321
+ if (observation?.guidance?.content !== undefined) {
322
+ const { content: _content, ...provenance } = observation.guidance;
323
+ observation.guidance = provenance;
324
+ }
325
+ return observation;
326
+ }
327
+
328
+ function responseHash(response) {
329
+ return createHash("sha256")
330
+ .update(JSON.stringify(response))
331
+ .digest("hex");
332
+ }
333
+
334
+ function responseMetadata(response) {
335
+ return {
336
+ hash: responseHash(response),
337
+ nextStepCount: response.nextSteps?.length ?? 0,
338
+ itemCount: response.items?.length
339
+ ?? response.choices?.length
340
+ ?? 0,
341
+ };
342
+ }
343
+
344
+ function completionFactType(intention, observation) {
345
+ if (intention.command === "check") {
346
+ return observation.state === "project-ready"
347
+ ? "validation.completed"
348
+ : "validation.failed";
349
+ }
350
+ const byState = {
351
+ "idea-create-failed": "idea.creation-failed",
352
+ "idea-created": "idea.created",
353
+ "idea-not-found": "idea.selection-missed",
354
+ "idea-selected": observation.guidance === undefined
355
+ ? "idea.selected"
356
+ : "guidance.selected",
357
+ "ideas-listed": "ideas.listed",
358
+ "navigation-ready": "idea.navigation-ready",
359
+ "phase-guidance-invalid": "guidance.validation-failed",
360
+ "project-setup-required": "project.readiness-blocked",
361
+ "repository-preparation-required": "repository.readiness-blocked",
362
+ "repository-sync-required": "repository.readiness-blocked",
363
+ };
364
+ return byState[observation.state] ?? "command.completed";
365
+ }
366
+
367
+ function buildReport(messages, { requireResponse = true } = {}) {
368
+ const state = replayObservation(messages);
369
+ if (state.observation === null || !TERMINAL_PROGRESS.has(state.progress)) {
370
+ throw new DomainInvariantError(
371
+ "A final report requires a terminal observation",
372
+ );
373
+ }
374
+ const intention = projectIntention(messages);
375
+ const response = respond(intention, state);
376
+ if (requireResponse && state.responseKind === null) {
377
+ throw new DomainInvariantError(
378
+ "A final report requires one response.created message",
379
+ );
380
+ }
381
+ if (state.responseKind !== null && state.responseKind !== response.kind) {
382
+ throw new DomainInvariantError(
383
+ `Response kind changed from ${state.responseKind} to ${response.kind}`,
384
+ );
385
+ }
386
+ if (
387
+ state.responseMetadata !== null
388
+ && JSON.stringify(state.responseMetadata) !== JSON.stringify(responseMetadata(response))
389
+ ) {
390
+ throw new DomainInvariantError(
391
+ "Response metadata does not match the projected response",
392
+ );
393
+ }
394
+ return {
395
+ intention,
396
+ observation: projectPublicObservation(state.observation),
397
+ actions: projectActions(messages),
398
+ response,
399
+ };
400
+ }
401
+
402
+ export function projectReport(messages) {
403
+ return buildReport(messages);
404
+ }
405
+
406
+ export class CommandRun {
407
+ #events = [];
408
+ #nextActionId = 1;
409
+ #state = initialInternalObservation();
410
+
411
+ constructor(intention, { eventSink = emitDomainMessage } = {}) {
412
+ this.eventSink = eventSink;
413
+ this.#emit({
414
+ type: "intention.accepted",
415
+ intention,
416
+ });
417
+ }
418
+
419
+ get events() {
420
+ return this.#events.map((event) => clone(event));
421
+ }
422
+
423
+ get observation() {
424
+ return this.#state;
425
+ }
426
+
427
+ #emit(message) {
428
+ const event = deepFreeze({
429
+ schemaVersion: DOMAIN_MESSAGE_SCHEMA_VERSION,
430
+ sequence: this.#events.length + 1,
431
+ ...clone(message),
432
+ });
433
+ const nextState = reduceObservation(this.#state, event);
434
+ this.#events.push(event);
435
+ this.#state = nextState;
436
+ this.eventSink(event);
437
+ return event;
438
+ }
439
+
440
+ observe(observation, {
441
+ factType = "command.observation",
442
+ progress = "observing",
443
+ responseContext = {},
444
+ } = {}) {
445
+ return this.#emit({
446
+ type: "observation.fact",
447
+ fact: {
448
+ type: factType,
449
+ observation,
450
+ progress,
451
+ responseContext,
452
+ },
453
+ });
454
+ }
455
+
456
+ requestAction(action) {
457
+ if (!action || typeof action.type !== "string" || action.type.length === 0) {
458
+ throw new DomainInvariantError("An action requires a non-empty type");
459
+ }
460
+ const actionId = `action-${this.#nextActionId}`;
461
+ this.#nextActionId += 1;
462
+ this.#emit({
463
+ type: "action.requested",
464
+ actionId,
465
+ action,
466
+ });
467
+ return actionId;
468
+ }
469
+
470
+ finishAction(actionId, actionType, completion) {
471
+ return this.#emit({
472
+ type: "action.finished",
473
+ actionId,
474
+ actionType,
475
+ status: completion.status,
476
+ ...(completion.status === "success"
477
+ ? { result: completion.result ?? {} }
478
+ : { problem: completion.problem }),
479
+ facts: completion.facts ?? [],
480
+ });
481
+ }
482
+
483
+ async performAction(action, execute, mapError, mapSuccess) {
484
+ const actionId = this.requestAction(action);
485
+ return traceAsync(
486
+ `action.${action.type}`,
487
+ { actionId, actionType: action.type },
488
+ async () => {
489
+ try {
490
+ const executed = await execute({ actionId });
491
+ const mapped = mapSuccess?.(executed);
492
+ const completion = {
493
+ status: "success",
494
+ result: mapped?.result ?? executed ?? {},
495
+ facts: mapped?.facts ?? [],
496
+ };
497
+ this.finishAction(actionId, action.type, completion);
498
+ return completion;
499
+ } catch (caught) {
500
+ const mapped = mapError?.(caught);
501
+ const completion = {
502
+ status: "failure",
503
+ problem: mapped?.problem ?? {
504
+ type: "unexpected-action-failure",
505
+ errorName: caught instanceof Error ? caught.name : typeof caught,
506
+ },
507
+ facts: mapped?.facts ?? [],
508
+ internal: mapped?.internal,
509
+ };
510
+ this.finishAction(actionId, action.type, completion);
511
+ if (mapped === undefined) throw caught;
512
+ return completion;
513
+ }
514
+ },
515
+ (completion) => ({
516
+ status: completion.status === "success" ? "ok" : "error",
517
+ }),
518
+ );
519
+ }
520
+
521
+ finish() {
522
+ const report = buildReport(this.#events, { requireResponse: false });
523
+ this.#emit({
524
+ type: "response.created",
525
+ kind: report.response.kind,
526
+ metadata: responseMetadata(report.response),
527
+ });
528
+ return buildReport(this.#events);
529
+ }
530
+
531
+ complete(observation, responseContext = {}, { factType } = {}) {
532
+ const progress = observation.problems?.length > 0 ? "blocked" : "ready";
533
+ this.observe(observation, {
534
+ factType: factType
535
+ ?? completionFactType(this.#state.intention, observation),
536
+ progress,
537
+ responseContext,
538
+ });
539
+ return this.finish();
540
+ }
541
+ }
542
+
543
+ export function createCommandRun(intention, options) {
544
+ return new CommandRun(intention, options);
545
+ }
546
+
547
+ export async function driveCommand({
548
+ decide,
549
+ intention,
550
+ ports,
551
+ responseContext = {},
552
+ }) {
553
+ const run = createCommandRun(intention);
554
+ for (let step = 0; step < 100; step += 1) {
555
+ const decision = decide(intention, run.observation);
556
+ if (decision.type === "response") {
557
+ if (
558
+ decision.observation === undefined
559
+ && TERMINAL_PROGRESS.has(run.observation.progress)
560
+ ) {
561
+ return run.finish();
562
+ }
563
+ return run.complete(
564
+ decision.observation ?? run.observation.observation,
565
+ decision.responseContext ?? responseContext,
566
+ );
567
+ }
568
+ if (decision.type === "probe") {
569
+ const observed = await ports.probe(decision.effect);
570
+ run.observe(observed.observation, {
571
+ factType: observed.factType ?? decision.factType ?? "command.probe",
572
+ progress: observed.progress ?? "observing",
573
+ responseContext: observed.responseContext ?? {},
574
+ });
575
+ continue;
576
+ }
577
+ if (decision.type === "action") {
578
+ await run.performAction(
579
+ decision.effect,
580
+ ({ actionId }) => ports.action(decision.effect, { actionId }),
581
+ decision.mapError,
582
+ (completed) => ({
583
+ result: completed?.result ?? completed ?? {},
584
+ facts: completed?.facts ?? [],
585
+ }),
586
+ );
587
+ continue;
588
+ }
589
+ throw new DomainInvariantError(`Unknown decision type: ${decision.type}`);
590
+ }
591
+ throw new DomainInvariantError("Command driver exceeded 100 decisions");
592
+ }