@hunsu/protocol 0.1.0

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.
Files changed (47) hide show
  1. package/LICENSE +157 -0
  2. package/dist/command-decoder.d.ts +5 -0
  3. package/dist/command-decoder.js +329 -0
  4. package/dist/constants.d.ts +15 -0
  5. package/dist/constants.js +15 -0
  6. package/dist/decoder-helpers.d.ts +58 -0
  7. package/dist/decoder-helpers.js +1757 -0
  8. package/dist/errors.d.ts +14 -0
  9. package/dist/errors.js +28 -0
  10. package/dist/event-decoder.d.ts +5 -0
  11. package/dist/event-decoder.js +232 -0
  12. package/dist/index.d.ts +10 -0
  13. package/dist/index.js +10 -0
  14. package/dist/lifecycle.d.ts +67 -0
  15. package/dist/lifecycle.js +271 -0
  16. package/dist/model.d.ts +1058 -0
  17. package/dist/model.js +1 -0
  18. package/dist/primitives.d.ts +59 -0
  19. package/dist/primitives.js +135 -0
  20. package/dist/projection.d.ts +8 -0
  21. package/dist/projection.js +875 -0
  22. package/dist/prompt-template.d.ts +12 -0
  23. package/dist/prompt-template.js +83 -0
  24. package/dist/protocol-validation.d.ts +36 -0
  25. package/dist/protocol-validation.js +1139 -0
  26. package/dist/result.d.ts +20 -0
  27. package/dist/result.js +21 -0
  28. package/dist/workflow-helpers.d.ts +31 -0
  29. package/dist/workflow-helpers.js +193 -0
  30. package/dist/workflow.d.ts +10 -0
  31. package/dist/workflow.js +594 -0
  32. package/package.json +30 -0
  33. package/src/command-decoder.ts +292 -0
  34. package/src/constants.ts +27 -0
  35. package/src/decoder-helpers.ts +1672 -0
  36. package/src/errors.ts +38 -0
  37. package/src/event-decoder.ts +225 -0
  38. package/src/index.ts +43 -0
  39. package/src/lifecycle.ts +403 -0
  40. package/src/model.ts +1233 -0
  41. package/src/primitives.ts +196 -0
  42. package/src/projection.ts +1081 -0
  43. package/src/prompt-template.ts +97 -0
  44. package/src/protocol-validation.ts +1252 -0
  45. package/src/result.ts +35 -0
  46. package/src/workflow-helpers.ts +239 -0
  47. package/src/workflow.ts +753 -0
@@ -0,0 +1,1081 @@
1
+ import {
2
+ abandonLine,
3
+ acceptSkillDraft,
4
+ blockDestination,
5
+ claimDestination,
6
+ completeLine,
7
+ createPendingDestination,
8
+ discardSkillDraft,
9
+ failLine,
10
+ normalizeMoveRecord,
11
+ patchMoveRecordBase,
12
+ pauseLine,
13
+ reachDestination,
14
+ resumeLine,
15
+ startDestinationWork,
16
+ type DomainModelError
17
+ } from "./lifecycle.ts";
18
+ import {
19
+ toDomainWorkflowError,
20
+ workflowError,
21
+ type DomainWorkflowError
22
+ } from "./errors.ts";
23
+ import type {
24
+ BoardEdge,
25
+ BoardProjection,
26
+ ArtifactActionDefinition,
27
+ Destination,
28
+ DestinationId,
29
+ DestinationSeed,
30
+ DestinationSource,
31
+ DomainEvent,
32
+ HarnessSnapshot,
33
+ HunsuRecord,
34
+ LineRecord,
35
+ MoveRecord,
36
+ NodeId,
37
+ NodeRecord,
38
+ RequestId
39
+ } from "./model.ts";
40
+ import { makeTeamName, makeNonEmptyArray } from "./primitives.ts";
41
+ import {
42
+ cloneHarnessEntity,
43
+ cloneHarness,
44
+ createDefaultHarness,
45
+ harnessEntityFromSnapshot
46
+ } from "./protocol-validation.ts";
47
+ import { unwrapDomainModelResult } from "./errors.ts";
48
+ import {
49
+ nextTeamName,
50
+ requireLine,
51
+ requireNode,
52
+ requireRootNode,
53
+ rootNodeId,
54
+ unique,
55
+ upsertById
56
+ } from "./workflow-helpers.ts";
57
+ import { err, ok, type Result } from "./result.ts";
58
+
59
+ export function emptyBoardProjection(): BoardProjection {
60
+ return {
61
+ origins: [],
62
+ requests: [],
63
+ destinations: [],
64
+ nodes: [],
65
+ edges: [],
66
+ lines: [],
67
+ moves: [],
68
+ hunsus: [],
69
+ skillDrafts: [],
70
+ artifacts: [],
71
+ artifactActions: [],
72
+ futureConstraints: []
73
+ };
74
+ }
75
+
76
+ export function projectBoard(events: DomainEvent[]): BoardProjection {
77
+ return refreshVisibleDestinations(events.reduce((state, event) => projectEvent(state, event), emptyBoardProjection()));
78
+ }
79
+
80
+ export function tryProjectBoard(events: DomainEvent[]): Result<BoardProjection, DomainWorkflowError> {
81
+ const valid = validateEventStream(events);
82
+ if (!valid.ok) {
83
+ return valid;
84
+ }
85
+ try {
86
+ return ok(projectBoard(events));
87
+ } catch (error) {
88
+ return err(toDomainWorkflowError(error));
89
+ }
90
+ }
91
+
92
+ export function validateEventStream(events: DomainEvent[]): Result<DomainEvent[], DomainWorkflowError> {
93
+ const seen = {
94
+ requestIds: new Set<string>(),
95
+ destinationIds: new Set<string>(),
96
+ lineIds: new Set<string>(),
97
+ moveIds: new Set<string>(),
98
+ hunsuIds: new Set<string>(),
99
+ skillDraftIds: new Set<string>(),
100
+ artifactIds: new Set<string>()
101
+ };
102
+ let state = emptyBoardProjection();
103
+ for (let index = 0; index < events.length; index += 1) {
104
+ const event = events[index];
105
+ const validation = validateEventAggregateIds(event, index, seen);
106
+ if (!validation.ok) {
107
+ return validation;
108
+ }
109
+ const stateValidation = validateEventState(event, index, state);
110
+ if (!stateValidation.ok) {
111
+ return stateValidation;
112
+ }
113
+ try {
114
+ state = projectEvent(state, event);
115
+ } catch (error) {
116
+ return err(toDomainWorkflowError(error));
117
+ }
118
+ const edges = validateProjectedEdges(state, index);
119
+ if (!edges.ok) {
120
+ return edges;
121
+ }
122
+ }
123
+ return ok(events);
124
+ }
125
+
126
+ export function projectEvent(state: BoardProjection, event: DomainEvent): BoardProjection {
127
+ switch (event.type) {
128
+ case "HunsuOriginRegistered":
129
+ return { ...state, origins: upsertOrigin(state.origins, event.origin) };
130
+ case "InitialTeamCreated":
131
+ return projectInitialTeamCreated(state, event);
132
+ case "RequestCreated":
133
+ return refreshVisibleDestinations({ ...state, requests: upsertById(state.requests, event.request) });
134
+ case "DestinationDeclared":
135
+ return projectDestinationDeclared(state, event.requestId, event.destination, event.at);
136
+ case "HarnessSeeded":
137
+ return projectHarnessSeeded(state, event.requestId, event.harness, event.at);
138
+ case "LineStarted":
139
+ return refreshVisibleDestinations({ ...state, lines: upsertById(state.lines, normalizeStartedLine(state, event.line)) });
140
+ case "SkillDraftCreated":
141
+ return { ...state, skillDrafts: upsertById(state.skillDrafts, event.draft) };
142
+ case "SkillDraftAccepted":
143
+ return {
144
+ ...state,
145
+ skillDrafts: state.skillDrafts.map(draft => draft.id === event.draftId
146
+ ? unwrapDomainModelResult(acceptSkillDraft(draft, { ...event.skill }))
147
+ : draft)
148
+ };
149
+ case "SkillDraftDiscarded":
150
+ return {
151
+ ...state,
152
+ skillDrafts: state.skillDrafts.map(draft => draft.id === event.draftId ? unwrapDomainModelResult(discardSkillDraft(draft)) : draft)
153
+ };
154
+ case "NodeCreated":
155
+ return refreshVisibleDestinations({ ...state, nodes: upsertById(state.nodes, event.node) });
156
+ case "LinePaused":
157
+ return transitionLine(state, event.lineId, pauseLine);
158
+ case "LineResumed":
159
+ return transitionLine(state, event.lineId, resumeLine);
160
+ case "LineAccepted":
161
+ return transitionLine(state, event.lineId, completeLine);
162
+ case "LineRejected":
163
+ return transitionLine(state, event.lineId, abandonLine);
164
+ case "DestinationClaimed":
165
+ return transitionDestinationInCurrentNodes(state, event.destinationId, destination => claimDestination(destination, { claimedBy: event.actor, updatedBy: "TEAM", updatedAt: event.at }));
166
+ case "DestinationWorkStarted":
167
+ return transitionDestinationInCurrentNodes(state, event.destinationId, destination => startDestinationWork(destination, { claimedBy: event.actor, updatedBy: "TEAM", updatedAt: event.at }));
168
+ case "DestinationBlocked":
169
+ return transitionDestinationInCurrentNodes(state, event.destinationId, destination => blockDestination(destination, { blockedReason: event.reason, updatedBy: "TEAM", updatedAt: event.at }));
170
+ case "MoveRecorded":
171
+ return projectMoveRecorded(state, event.move);
172
+ case "DestinationReached":
173
+ return state;
174
+ case "HunsuRecorded":
175
+ return projectHunsuRecorded(state, event.hunsu);
176
+ case "LineForkedByHunsu":
177
+ return projectLineForkedByHunsu(state, event);
178
+ case "ArtifactRecorded":
179
+ return { ...state, artifacts: upsertById(state.artifacts, event.artifact) };
180
+ }
181
+ }
182
+
183
+ function upsertOrigin(origins: BoardProjection["origins"], origin: BoardProjection["origins"][number]): BoardProjection["origins"] {
184
+ if (origins.some(existing => existing.name === origin.name)) {
185
+ return origins.map(existing => existing.name === origin.name ? { ...origin } : { ...existing });
186
+ }
187
+ return [...origins.map(existing => ({ ...existing })), { ...origin }];
188
+ }
189
+
190
+ function validateEventAggregateIds(
191
+ event: DomainEvent,
192
+ index: number,
193
+ seen: {
194
+ requestIds: Set<string>;
195
+ destinationIds: Set<string>;
196
+ lineIds: Set<string>;
197
+ moveIds: Set<string>;
198
+ hunsuIds: Set<string>;
199
+ skillDraftIds: Set<string>;
200
+ artifactIds: Set<string>;
201
+ }
202
+ ): Result<void, DomainWorkflowError> {
203
+ switch (event.type) {
204
+ case "InitialTeamCreated": {
205
+ const request = rememberAggregateId(seen.requestIds, "RequestId", event.request.id, index);
206
+ if (!request.ok) return request;
207
+ const line = rememberAggregateId(seen.lineIds, "LineId", event.line.id, index);
208
+ if (!line.ok) return line;
209
+ return rememberDestinationSeeds(seen.destinationIds, event.destinations, index);
210
+ }
211
+ case "RequestCreated":
212
+ return rememberAggregateId(seen.requestIds, "RequestId", event.request.id, index);
213
+ case "DestinationDeclared":
214
+ return rememberAggregateId(seen.destinationIds, "DestinationId", event.destination.id, index);
215
+ case "LineStarted":
216
+ return rememberAggregateId(seen.lineIds, "LineId", event.line.id, index);
217
+ case "SkillDraftCreated":
218
+ return rememberAggregateId(seen.skillDraftIds, "SkillDraftId", event.draft.id, index);
219
+ case "MoveRecorded":
220
+ return rememberAggregateId(seen.moveIds, "MoveId", event.move.id, index);
221
+ case "HunsuRecorded": {
222
+ return rememberAggregateId(seen.hunsuIds, "HunsuId", event.hunsu.id, index);
223
+ }
224
+ case "LineForkedByHunsu":
225
+ return rememberAggregateId(seen.lineIds, "LineId", event.newLineId, index);
226
+ case "ArtifactRecorded":
227
+ return rememberAggregateId(seen.artifactIds, "ArtifactId", event.artifact.id, index);
228
+ case "HunsuOriginRegistered":
229
+ case "HarnessSeeded":
230
+ case "SkillDraftAccepted":
231
+ case "SkillDraftDiscarded":
232
+ case "NodeCreated":
233
+ case "LinePaused":
234
+ case "LineResumed":
235
+ case "LineAccepted":
236
+ case "LineRejected":
237
+ case "DestinationClaimed":
238
+ case "DestinationWorkStarted":
239
+ case "DestinationBlocked":
240
+ case "DestinationReached":
241
+ return ok(undefined);
242
+ }
243
+ }
244
+
245
+ function validateEventState(event: DomainEvent, index: number, state: BoardProjection): Result<void, DomainWorkflowError> {
246
+ switch (event.type) {
247
+ case "InitialTeamCreated":
248
+ return validateInitialTeamCreatedEvent(event, index);
249
+ case "DestinationDeclared":
250
+ return state.requests.some(request => request.id === event.requestId)
251
+ ? ok(undefined)
252
+ : eventStreamError(index, `Unknown request: ${event.requestId}`);
253
+ case "HarnessSeeded":
254
+ return state.nodes.some(node => node.id === rootNodeId(event.requestId))
255
+ ? ok(undefined)
256
+ : eventStreamError(index, `Unknown root node for request: ${event.requestId}`);
257
+ case "LineStarted":
258
+ return validateLineStartedEvent(state, event.line, index);
259
+ case "SkillDraftAccepted":
260
+ return validateSkillDraftTransitionEvent(state, event.draftId, index, draft => acceptSkillDraft(draft, event.skill));
261
+ case "SkillDraftDiscarded":
262
+ return validateSkillDraftTransitionEvent(state, event.draftId, index, discardSkillDraft);
263
+ case "NodeCreated":
264
+ return validateNodeCreatedEvent(state, event.node, index);
265
+ case "LinePaused":
266
+ return validateLineTransitionEvent(state, event.lineId, index, pauseLine);
267
+ case "LineResumed":
268
+ return validateLineTransitionEvent(state, event.lineId, index, resumeLine);
269
+ case "LineAccepted":
270
+ return validateLineTransitionEvent(state, event.lineId, index, completeLine);
271
+ case "LineRejected":
272
+ return validateLineTransitionEvent(state, event.lineId, index, abandonLine);
273
+ case "DestinationClaimed":
274
+ return validateDestinationTransitionEvent(state, event.destinationId, index, destination => claimDestination(destination, { claimedBy: event.actor, updatedBy: "TEAM", updatedAt: event.at }));
275
+ case "DestinationWorkStarted":
276
+ return validateDestinationTransitionEvent(state, event.destinationId, index, destination => startDestinationWork(destination, { claimedBy: event.actor, updatedBy: "TEAM", updatedAt: event.at }));
277
+ case "DestinationBlocked":
278
+ return validateDestinationTransitionEvent(state, event.destinationId, index, destination => blockDestination(destination, { blockedReason: event.reason, updatedBy: "TEAM", updatedAt: event.at }));
279
+ case "MoveRecorded":
280
+ return validateMoveRecordedEvent(state, event.move, index);
281
+ case "HunsuRecorded":
282
+ return validateHunsuRecordedEvent(state, event.hunsu, index);
283
+ case "LineForkedByHunsu":
284
+ return validateLineForkedByHunsuEvent(state, event, index);
285
+ case "ArtifactRecorded":
286
+ return validateArtifactRecordedEvent(state, event.artifact, index);
287
+ case "DestinationReached":
288
+ return eventStreamError(index, "DestinationReached is a legacy no-op event and cannot be projected");
289
+ case "HunsuOriginRegistered":
290
+ case "RequestCreated":
291
+ case "SkillDraftCreated":
292
+ return ok(undefined);
293
+ }
294
+ }
295
+
296
+ function validateInitialTeamCreatedEvent(
297
+ event: Extract<DomainEvent, { type: "InitialTeamCreated" }>,
298
+ index: number
299
+ ): Result<void, DomainWorkflowError> {
300
+ if (event.line.requestId !== event.request.id) {
301
+ return eventStreamError(index, `Initial line ${event.line.id} request ${event.line.requestId} does not match request ${event.request.id}`);
302
+ }
303
+ if (event.line.status !== "active") {
304
+ return eventStreamError(index, `Initial line ${event.line.id} cannot start from status ${event.line.status}`);
305
+ }
306
+ if (event.line.rootNodeId !== event.line.currentNodeId) {
307
+ return eventStreamError(index, `Initial line ${event.line.id} currentNodeId must start at rootNodeId`);
308
+ }
309
+ if (!event.line.nodeIds.includes(event.line.rootNodeId)) {
310
+ return eventStreamError(index, `Initial line ${event.line.id} nodeIds must include rootNodeId ${event.line.rootNodeId}`);
311
+ }
312
+ if (event.line.moveIds.length !== 0) {
313
+ return eventStreamError(index, `Initial line ${event.line.id} cannot start with MOVEs`);
314
+ }
315
+ if ("parentLineId" in event.line || "forkedFromMoveId" in event.line) {
316
+ return eventStreamError(index, `Initial line ${event.line.id} cannot carry fork metadata`);
317
+ }
318
+ return ok(undefined);
319
+ }
320
+
321
+ function validateLineStartedEvent(
322
+ state: BoardProjection,
323
+ line: LineRecord,
324
+ index: number
325
+ ): Result<void, DomainWorkflowError> {
326
+ if (!state.requests.some(request => request.id === line.requestId)) {
327
+ return eventStreamError(index, `Unknown request: ${line.requestId}`);
328
+ }
329
+ if (line.status !== "active") {
330
+ return eventStreamError(index, `Line ${line.id} cannot start from status ${line.status}`);
331
+ }
332
+ const rootNode = state.nodes.find(node => node.id === rootNodeId(line.requestId));
333
+ if (!rootNode) {
334
+ return eventStreamError(index, `Unknown root node for request: ${line.requestId}`);
335
+ }
336
+ if (line.rootNodeId !== rootNode.id || line.currentNodeId !== rootNode.id || !line.nodeIds.includes(rootNode.id)) {
337
+ return eventStreamError(index, `Line ${line.id} must start at request root node ${rootNode.id}`);
338
+ }
339
+ if (line.moveIds.length !== 0) {
340
+ return eventStreamError(index, `Line ${line.id} cannot start with MOVEs`);
341
+ }
342
+ if ("parentLineId" in line || "forkedFromMoveId" in line) {
343
+ return eventStreamError(index, `LineStarted ${line.id} cannot carry fork metadata`);
344
+ }
345
+ return ok(undefined);
346
+ }
347
+
348
+ function validateSkillDraftTransitionEvent(
349
+ state: BoardProjection,
350
+ draftId: string,
351
+ index: number,
352
+ transition: (draft: BoardProjection["skillDrafts"][number]) => Result<BoardProjection["skillDrafts"][number], DomainModelError>
353
+ ): Result<void, DomainWorkflowError> {
354
+ const draft = state.skillDrafts.find(candidate => candidate.id === draftId);
355
+ if (!draft) {
356
+ return eventStreamError(index, `Unknown Skill Draft: ${draftId}`);
357
+ }
358
+ return validateEventModelTransition(index, transition(draft));
359
+ }
360
+
361
+ function validateNodeCreatedEvent(
362
+ state: BoardProjection,
363
+ node: NodeRecord,
364
+ index: number
365
+ ): Result<void, DomainWorkflowError> {
366
+ if (node.lineId) {
367
+ const line = state.lines.find(candidate => candidate.id === node.lineId);
368
+ if (!line) {
369
+ return eventStreamError(index, `Unknown line: ${node.lineId}`);
370
+ }
371
+ if (line.requestId !== node.requestId) {
372
+ return eventStreamError(index, `Node ${node.id} request ${node.requestId} does not match line ${line.id} request ${line.requestId}`);
373
+ }
374
+ }
375
+ const source = node.source;
376
+ switch (source.type) {
377
+ case "initial-execute-team":
378
+ case "request":
379
+ return state.requests.some(request => request.id === source.requestId)
380
+ ? ok(undefined)
381
+ : eventStreamError(index, `Unknown request: ${source.requestId}`);
382
+ case "move": {
383
+ const move = state.moves.find(candidate => candidate.id === source.moveId);
384
+ if (!move) {
385
+ return eventStreamError(index, `Unknown MOVE: ${source.moveId}`);
386
+ }
387
+ return move.toNodeId === node.id && move.fromNodeId === source.fromNodeId
388
+ ? ok(undefined)
389
+ : eventStreamError(index, `Node ${node.id} does not match MOVE ${move.id} topology`);
390
+ }
391
+ case "hunsu": {
392
+ const hunsu = state.hunsus.find(candidate => candidate.id === source.hunsuId);
393
+ if (!hunsu) {
394
+ return eventStreamError(index, `Unknown HUNSU: ${source.hunsuId}`);
395
+ }
396
+ return hunsu.toNodeId === node.id && hunsu.fromNodeId === source.fromNodeId
397
+ ? ok(undefined)
398
+ : eventStreamError(index, `Node ${node.id} does not match HUNSU ${hunsu.id} topology`);
399
+ }
400
+ }
401
+ }
402
+
403
+ function validateLineTransitionEvent(
404
+ state: BoardProjection,
405
+ lineId: string,
406
+ index: number,
407
+ transition: (line: LineRecord) => Result<LineRecord, DomainModelError>
408
+ ): Result<void, DomainWorkflowError> {
409
+ const line = state.lines.find(candidate => candidate.id === lineId);
410
+ if (!line) {
411
+ return eventStreamError(index, `Unknown line: ${lineId}`);
412
+ }
413
+ return validateEventModelTransition(index, transition(line));
414
+ }
415
+
416
+ function validateDestinationTransitionEvent(
417
+ state: BoardProjection,
418
+ destinationId: DestinationId,
419
+ index: number,
420
+ transition: (destination: Destination) => Result<Destination, DomainModelError>
421
+ ): Result<void, DomainWorkflowError> {
422
+ const activeNodeIds = new Set(state.lines.map(line => line.currentNodeId));
423
+ const destinations = state.nodes
424
+ .filter(node => activeNodeIds.has(node.id))
425
+ .flatMap(node => node.destinations.filter(destination => destination.id === destinationId));
426
+ if (destinations.length === 0) {
427
+ return eventStreamError(index, `Unknown Destination in active route nodes: ${destinationId}`);
428
+ }
429
+ for (const destination of destinations) {
430
+ const validation = validateEventModelTransition(index, transition(destination));
431
+ if (!validation.ok) {
432
+ return validation;
433
+ }
434
+ }
435
+ return ok(undefined);
436
+ }
437
+
438
+ function validateMoveRecordedEvent(
439
+ state: BoardProjection,
440
+ move: MoveRecord,
441
+ index: number
442
+ ): Result<void, DomainWorkflowError> {
443
+ const line = state.lines.find(candidate => candidate.id === move.lineId);
444
+ if (!line) {
445
+ return eventStreamError(index, `Unknown line: ${move.lineId}`);
446
+ }
447
+ if (line.status !== "active") {
448
+ return eventStreamError(index, `TEAM line ${line.id} cannot continue from status ${line.status}`);
449
+ }
450
+ const fromNode = state.nodes.find(node => node.id === move.fromNodeId);
451
+ if (!fromNode) {
452
+ return eventStreamError(index, `Unknown node: ${move.fromNodeId}`);
453
+ }
454
+ if (fromNode.id !== line.currentNodeId || fromNode.requestId !== line.requestId) {
455
+ return eventStreamError(index, `MOVE ${move.id} source node ${fromNode.id} does not match current node for line ${line.id}`);
456
+ }
457
+ if (move.toNodeId === move.fromNodeId) {
458
+ return eventStreamError(index, `MOVE ${move.id} cannot be a self-loop`);
459
+ }
460
+ if (state.nodes.some(node => node.id === move.toNodeId)) {
461
+ return eventStreamError(index, `MOVE ${move.id} target node already exists: ${move.toNodeId}`);
462
+ }
463
+ const normalized = normalizeMoveRecord(move);
464
+ if (!normalized.ok) {
465
+ return eventStreamError(index, normalized.error.message);
466
+ }
467
+ for (const destinationId of move.reachedDestinationIds) {
468
+ const destination = validateDestinationInEventSourceNode(fromNode, destinationId, index);
469
+ if (!destination.ok) {
470
+ return destination;
471
+ }
472
+ const reachable = validateEventModelTransition(index, reachDestination(destination.value, { reachedByMoveId: move.id, updatedBy: "TEAM", updatedAt: move.recordedAt }));
473
+ if (!reachable.ok) {
474
+ return reachable;
475
+ }
476
+ }
477
+ return ok(undefined);
478
+ }
479
+
480
+ function validateHunsuRecordedEvent(
481
+ state: BoardProjection,
482
+ hunsu: HunsuRecord,
483
+ index: number
484
+ ): Result<void, DomainWorkflowError> {
485
+ const line = state.lines.find(candidate => candidate.id === hunsu.lineId);
486
+ if (!line) {
487
+ return eventStreamError(index, `Unknown line: ${hunsu.lineId}`);
488
+ }
489
+ const fromNode = state.nodes.find(candidate => candidate.id === hunsu.fromNodeId);
490
+ if (!fromNode) {
491
+ return eventStreamError(index, `Unknown node: ${hunsu.fromNodeId}`);
492
+ }
493
+ if (fromNode.requestId !== line.requestId || !line.nodeIds.includes(fromNode.id)) {
494
+ return eventStreamError(index, `Node ${fromNode.id} does not belong to line ${line.id}`);
495
+ }
496
+ if (hunsu.toNodeId === hunsu.fromNodeId) {
497
+ return eventStreamError(index, `HUNSU ${hunsu.id} cannot be a self-loop`);
498
+ }
499
+ if (state.nodes.some(node => node.id === hunsu.toNodeId)) {
500
+ return eventStreamError(index, `HUNSU ${hunsu.id} target node already exists: ${hunsu.toNodeId}`);
501
+ }
502
+ const target = validateHunsuEventTarget(state, hunsu, line, fromNode, index);
503
+ if (!target.ok) {
504
+ return target;
505
+ }
506
+ return validateHunsuRuntimeSnapshot(state, hunsu, fromNode, index);
507
+ }
508
+
509
+ function validateHunsuEventTarget(
510
+ state: BoardProjection,
511
+ hunsu: HunsuRecord,
512
+ line: LineRecord,
513
+ fromNode: NodeRecord,
514
+ index: number
515
+ ): Result<void, DomainWorkflowError> {
516
+ switch (hunsu.target.type) {
517
+ case "line": {
518
+ const targetLine = state.lines.find(candidate => candidate.id === hunsu.target.id);
519
+ if (!targetLine) {
520
+ return eventStreamError(index, `Unknown line: ${hunsu.target.id}`);
521
+ }
522
+ return targetLine.id === hunsu.lineId
523
+ ? ok(undefined)
524
+ : eventStreamError(index, `HUNSU target line ${targetLine.id} does not match event line ${hunsu.lineId}`);
525
+ }
526
+ case "node": {
527
+ const targetNode = state.nodes.find(candidate => candidate.id === hunsu.target.id);
528
+ if (!targetNode) {
529
+ return eventStreamError(index, `Unknown node: ${hunsu.target.id}`);
530
+ }
531
+ return targetNode.requestId === line.requestId && line.nodeIds.includes(targetNode.id)
532
+ ? ok(undefined)
533
+ : eventStreamError(index, `Node ${targetNode.id} does not belong to line ${line.id}`);
534
+ }
535
+ case "move": {
536
+ const move = validateMoveBelongsToEventLine(state, line, hunsu.target.id, index);
537
+ return move.ok ? ok(undefined) : move;
538
+ }
539
+ case "destination":
540
+ return fromNode.destinations.some(destination => destination.id === hunsu.target.id)
541
+ ? ok(undefined)
542
+ : eventStreamError(index, `Unknown Destination in node ${fromNode.id}: ${hunsu.target.id}`);
543
+ }
544
+ return eventStreamError(index, `Unsupported HUNSU target: ${JSON.stringify(hunsu.target)}`);
545
+ }
546
+
547
+ function validateHunsuRuntimeSnapshot(
548
+ state: BoardProjection,
549
+ hunsu: HunsuRecord,
550
+ fromNode: NodeRecord,
551
+ index: number
552
+ ): Result<void, DomainWorkflowError> {
553
+ if (hunsu.changedFiles.length === 0) {
554
+ return eventStreamError(index, `HUNSU ${hunsu.id} must include changed runtime files`);
555
+ }
556
+ const changedPaths = new Set<string>();
557
+ for (const file of hunsu.changedFiles) {
558
+ const path = String(file.path);
559
+ if (!path.startsWith(".hunsu-request/")) {
560
+ return eventStreamError(index, `HUNSU changed file must be under .hunsu-request: ${path}`);
561
+ }
562
+ if (changedPaths.has(path)) {
563
+ return eventStreamError(index, `Duplicate HUNSU changed file: ${path}`);
564
+ }
565
+ changedPaths.add(path);
566
+ }
567
+ if (hunsu.teamSnapshot.moveOrdinal !== fromNode.ordinal) {
568
+ return eventStreamError(index, `HUNSU ${hunsu.id} Team snapshot ordinal ${hunsu.teamSnapshot.moveOrdinal} does not match source node ordinal ${fromNode.ordinal}`);
569
+ }
570
+ const existingSourceDestinationIds = new Set(fromNode.destinations.map(destination => String(destination.id)));
571
+ const existingGlobalDestinationIds = new Set(state.destinations.map(destination => String(destination.id)));
572
+ const snapshotDestinationIds = new Set<string>();
573
+ for (const destination of hunsu.teamSnapshot.destinations) {
574
+ const destinationId = String(destination.id);
575
+ if (snapshotDestinationIds.has(destinationId)) {
576
+ return eventStreamError(index, `Duplicate DestinationId in HUNSU Team snapshot: ${destinationId}`);
577
+ }
578
+ snapshotDestinationIds.add(destinationId);
579
+ if (destination.requestId !== fromNode.requestId) {
580
+ return eventStreamError(index, `Destination ${destination.id} request ${destination.requestId} does not match source request ${fromNode.requestId}`);
581
+ }
582
+ if (!existingSourceDestinationIds.has(destinationId) && existingGlobalDestinationIds.has(destinationId)) {
583
+ return eventStreamError(index, `Duplicate DestinationId in HUNSU Team snapshot: ${destinationId}`);
584
+ }
585
+ }
586
+ return ok(undefined);
587
+ }
588
+
589
+ function validateDestinationInEventSourceNode(
590
+ node: NodeRecord,
591
+ destinationId: DestinationId,
592
+ index: number
593
+ ): Result<Destination, DomainWorkflowError> {
594
+ const destination = node.destinations.find(candidate => candidate.id === destinationId);
595
+ return destination
596
+ ? ok(destination)
597
+ : eventStreamError(index, `Unknown Destination in node ${node.id}: ${destinationId}`);
598
+ }
599
+
600
+ function validateMoveBelongsToEventLine(
601
+ state: BoardProjection,
602
+ line: LineRecord,
603
+ moveId: string,
604
+ index: number
605
+ ): Result<MoveRecord, DomainWorkflowError> {
606
+ const move = state.moves.find(candidate => candidate.id === moveId);
607
+ if (!move) {
608
+ return eventStreamError(index, `Unknown MOVE: ${moveId}`);
609
+ }
610
+ return move.lineId === line.id || line.moveIds.includes(move.id)
611
+ ? ok(move)
612
+ : eventStreamError(index, `MOVE ${move.id} does not belong to line ${line.id}`);
613
+ }
614
+
615
+ function validateMoveEndsAtEventSourceNode(
616
+ state: BoardProjection,
617
+ line: LineRecord,
618
+ moveId: string,
619
+ fromNode: NodeRecord,
620
+ index: number
621
+ ): Result<void, DomainWorkflowError> {
622
+ const move = validateMoveBelongsToEventLine(state, line, moveId, index);
623
+ if (!move.ok) {
624
+ return move;
625
+ }
626
+ return move.value.toNodeId === fromNode.id
627
+ ? ok(undefined)
628
+ : eventStreamError(index, `MOVE ${move.value.id} does not end at HUNSU source node ${fromNode.id}`);
629
+ }
630
+
631
+ function validateLineForkedByHunsuEvent(
632
+ state: BoardProjection,
633
+ event: Extract<DomainEvent, { type: "LineForkedByHunsu" }>,
634
+ index: number
635
+ ): Result<void, DomainWorkflowError> {
636
+ const parentLine = state.lines.find(candidate => candidate.id === event.fromLineId);
637
+ if (!parentLine) {
638
+ return eventStreamError(index, `Unknown line: ${event.fromLineId}`);
639
+ }
640
+ if (event.requestId !== parentLine.requestId) {
641
+ return eventStreamError(index, `LineForkedByHunsu request ${event.requestId} does not match parent line ${parentLine.id}`);
642
+ }
643
+ const hunsu = state.hunsus.find(candidate => candidate.id === event.hunsuId);
644
+ if (!hunsu) {
645
+ return eventStreamError(index, `Unknown HUNSU: ${event.hunsuId}`);
646
+ }
647
+ if (hunsu.lineId !== event.fromLineId) {
648
+ return eventStreamError(index, `HUNSU ${hunsu.id} does not belong to parent line ${event.fromLineId}`);
649
+ }
650
+ if (hunsu.newLineId !== event.newLineId) {
651
+ return eventStreamError(index, `Line fork ${event.newLineId} does not match HUNSU recorded new line ${hunsu.newLineId}`);
652
+ }
653
+ if (hunsu.sourceMoveId !== event.fromMoveId) {
654
+ return eventStreamError(index, `Line fork ${event.newLineId} fromMoveId does not match HUNSU source move`);
655
+ }
656
+ if (hunsu.newTeamName !== event.newTeamName) {
657
+ return eventStreamError(index, `Line fork ${event.newLineId} Team name does not match HUNSU record`);
658
+ }
659
+ if (event.fromMoveId) {
660
+ const move = validateMoveBelongsToEventLine(state, parentLine, event.fromMoveId, index);
661
+ if (!move.ok) {
662
+ return move;
663
+ }
664
+ if (move.value.toNodeId !== hunsu.fromNodeId) {
665
+ return eventStreamError(index, `Line fork source MOVE ${move.value.id} does not end at HUNSU source node ${hunsu.fromNodeId}`);
666
+ }
667
+ }
668
+ return state.nodes.some(node => node.id === hunsu.toNodeId)
669
+ ? ok(undefined)
670
+ : eventStreamError(index, `Unknown HUNSU target node: ${hunsu.toNodeId}`);
671
+ }
672
+
673
+ function validateArtifactRecordedEvent(
674
+ state: BoardProjection,
675
+ artifact: BoardProjection["artifacts"][number],
676
+ index: number
677
+ ): Result<void, DomainWorkflowError> {
678
+ switch (artifact.owner.type) {
679
+ case "move":
680
+ return state.moves.some(move => move.id === artifact.owner.id)
681
+ ? ok(undefined)
682
+ : eventStreamError(index, `Unknown MOVE: ${artifact.owner.id}`);
683
+ case "hunsu":
684
+ return state.hunsus.some(hunsu => hunsu.id === artifact.owner.id)
685
+ ? ok(undefined)
686
+ : eventStreamError(index, `Unknown HUNSU: ${artifact.owner.id}`);
687
+ case "line":
688
+ return state.lines.some(line => line.id === artifact.owner.id)
689
+ ? ok(undefined)
690
+ : eventStreamError(index, `Unknown line: ${artifact.owner.id}`);
691
+ }
692
+ }
693
+
694
+ function validateProjectedEdges(state: BoardProjection, index: number): Result<void, DomainWorkflowError> {
695
+ for (const edge of state.edges) {
696
+ const fromNode = state.nodes.find(node => node.id === edge.fromNodeId);
697
+ if (!fromNode) {
698
+ return eventStreamError(index, `Edge ${edge.id} references missing fromNode ${edge.fromNodeId}`);
699
+ }
700
+ const toNode = state.nodes.find(node => node.id === edge.toNodeId);
701
+ if (!toNode) {
702
+ return eventStreamError(index, `Edge ${edge.id} references missing toNode ${edge.toNodeId}`);
703
+ }
704
+ switch (edge.type) {
705
+ case "move": {
706
+ const move = state.moves.find(candidate => candidate.id === edge.moveId);
707
+ if (!move) {
708
+ return eventStreamError(index, `MOVE edge ${edge.id} references missing MOVE ${edge.moveId}`);
709
+ }
710
+ if (move.lineId !== edge.lineId || move.fromNodeId !== edge.fromNodeId || move.toNodeId !== edge.toNodeId || move.id !== edge.id) {
711
+ return eventStreamError(index, `MOVE edge ${edge.id} does not match MOVE ${move.id}`);
712
+ }
713
+ break;
714
+ }
715
+ case "hunsu": {
716
+ const hunsu = state.hunsus.find(candidate => candidate.id === edge.hunsuId);
717
+ if (!hunsu) {
718
+ return eventStreamError(index, `HUNSU edge ${edge.id} references missing HUNSU ${edge.hunsuId}`);
719
+ }
720
+ if (hunsu.lineId !== edge.lineId || hunsu.fromNodeId !== edge.fromNodeId || hunsu.toNodeId !== edge.toNodeId || hunsu.id !== edge.id) {
721
+ return eventStreamError(index, `HUNSU edge ${edge.id} does not match HUNSU ${hunsu.id}`);
722
+ }
723
+ break;
724
+ }
725
+ }
726
+ }
727
+ return ok(undefined);
728
+ }
729
+
730
+ function validateEventModelTransition<T>(
731
+ index: number,
732
+ transition: Result<T, DomainModelError>
733
+ ): Result<void, DomainWorkflowError> {
734
+ return transition.ok ? ok(undefined) : eventStreamError(index, transition.error.message);
735
+ }
736
+
737
+ function eventStreamError(index: number, message: string): Result<never, DomainWorkflowError> {
738
+ return workflowError(`Invalid event stream at event ${index}: ${message}`);
739
+ }
740
+
741
+ function rememberDestinationSeeds(
742
+ seen: Set<string>,
743
+ destinations: DestinationSeed[],
744
+ index: number
745
+ ): Result<void, DomainWorkflowError> {
746
+ for (const destination of destinations) {
747
+ const remembered = rememberAggregateId(seen, "DestinationId", destination.id, index);
748
+ if (!remembered.ok) {
749
+ return remembered;
750
+ }
751
+ }
752
+ return ok(undefined);
753
+ }
754
+
755
+ function rememberAggregateId(seen: Set<string>, label: string, id: string, index: number): Result<void, DomainWorkflowError> {
756
+ if (seen.has(id)) {
757
+ return workflowError(`Duplicate ${label} in event stream at event ${index}: ${id}`);
758
+ }
759
+ seen.add(id);
760
+ return ok(undefined);
761
+ }
762
+
763
+ function projectInitialTeamCreated(
764
+ state: BoardProjection,
765
+ event: Extract<DomainEvent, { type: "InitialTeamCreated" }>
766
+ ): BoardProjection {
767
+ const destinations = event.destinations.map(destination => createDestination(event.request.id, destination, "initial-execute-team", event.at));
768
+ const harness = cloneHarness(event.harness ?? createDefaultHarness());
769
+ const rootNode: NodeRecord = {
770
+ id: event.line.rootNodeId,
771
+ requestId: event.request.id,
772
+ lineId: event.line.id,
773
+ teamName: event.line.teamName,
774
+ ordinal: 0,
775
+ destinations,
776
+ harness,
777
+ harnessGraph: harnessEntityFromSnapshot(harness),
778
+ harnessLock: event.harnessLock ? { ...event.harnessLock } : undefined,
779
+ executorPackageBindings: [],
780
+ resourcePackageBindings: [],
781
+ artifactActions: [],
782
+ source: { type: "initial-execute-team", requestId: event.request.id },
783
+ createdAt: event.at ?? event.request.createdAt
784
+ };
785
+ const line = normalizeStartedLine({ ...state, requests: upsertById(state.requests, event.request), nodes: upsertById(state.nodes, rootNode) }, {
786
+ ...event.line,
787
+ rootNodeId: rootNode.id,
788
+ currentNodeId: event.line.currentNodeId,
789
+ nodeIds: event.line.nodeIds
790
+ });
791
+ return refreshVisibleDestinations({
792
+ ...state,
793
+ requests: upsertById(state.requests, event.request),
794
+ nodes: upsertById(state.nodes, rootNode),
795
+ lines: upsertById(state.lines, line)
796
+ });
797
+ }
798
+
799
+ function projectDestinationDeclared(state: BoardProjection, requestId: RequestId, seed: DestinationSeed, at?: string): BoardProjection {
800
+ const destination = createDestination(requestId, seed, "initial-request", at);
801
+ const existing = state.nodes.find(node => node.id === rootNodeId(requestId));
802
+ const rootNode: NodeRecord = existing
803
+ ? { ...existing, destinations: upsertById(existing.destinations, destination) }
804
+ : {
805
+ id: rootNodeId(requestId),
806
+ requestId,
807
+ ordinal: 0,
808
+ destinations: [destination],
809
+ harness: createDefaultHarness(),
810
+ harnessGraph: harnessEntityFromSnapshot(createDefaultHarness()),
811
+ artifactActions: [],
812
+ source: { type: "request", requestId },
813
+ createdAt: at
814
+ };
815
+ return refreshVisibleDestinations({ ...state, nodes: upsertById(state.nodes, rootNode) });
816
+ }
817
+
818
+ function projectHarnessSeeded(
819
+ state: BoardProjection,
820
+ requestId: RequestId,
821
+ harness: HarnessSnapshot,
822
+ at?: string
823
+ ): BoardProjection {
824
+ const rootNode = requireRootNode(state, requestId);
825
+ return refreshVisibleDestinations({
826
+ ...state,
827
+ nodes: upsertById(state.nodes, {
828
+ ...rootNode,
829
+ harness: cloneHarness(harness),
830
+ harnessGraph: harnessEntityFromSnapshot(harness),
831
+ createdAt: rootNode.createdAt ?? at
832
+ })
833
+ });
834
+ }
835
+
836
+ function projectMoveRecorded(state: BoardProjection, move: MoveRecord): BoardProjection {
837
+ const line = requireLine(state, move.lineId);
838
+ const fromNode = requireNode(state, move.fromNodeId);
839
+ const toNodeId = move.toNodeId;
840
+ const baseMove = unwrapDomainModelResult(normalizeMoveRecord(patchMoveRecordBase(move, {
841
+ fromNodeId: fromNode.id,
842
+ toNodeId,
843
+ teamName: move.teamName ?? line.teamName ?? fromNode.teamName ?? nextTeamName(state),
844
+ ordinal: move.ordinal ?? fromNode.ordinal + 1
845
+ })));
846
+ const targetNode = state.nodes.find(node => node.id === toNodeId) ?? createMoveNode(fromNode, baseMove, toNodeId);
847
+ const recordedMove = unwrapDomainModelResult(normalizeMoveRecord(patchMoveRecordBase(baseMove, { snapshot: baseMove.snapshot ?? snapshotForNode(targetNode) })));
848
+ const edge: BoardEdge = { id: recordedMove.id, type: "move", lineId: recordedMove.lineId, fromNodeId: fromNode.id, toNodeId, moveId: recordedMove.id };
849
+ const withRecord = {
850
+ ...state,
851
+ nodes: upsertById(state.nodes, targetNode),
852
+ moves: upsertById(state.moves, recordedMove),
853
+ edges: upsertById(state.edges, edge)
854
+ };
855
+ const advanced = advanceLineToNode(appendMoveToLine(withRecord, recordedMove), recordedMove.lineId, toNodeId);
856
+ return refreshVisibleDestinations(recordedMove.outcome === "accident" ? transitionLine(advanced, recordedMove.lineId, failLine) : advanced);
857
+ }
858
+
859
+ function projectHunsuRecorded(state: BoardProjection, hunsu: HunsuRecord): BoardProjection {
860
+ requireLine(state, hunsu.lineId);
861
+ const fromNode = requireNode(state, hunsu.fromNodeId);
862
+ const toNodeId = hunsu.toNodeId;
863
+ const recordedHunsu: HunsuRecord = { ...hunsu, fromNodeId: fromNode.id, toNodeId };
864
+ const targetNode = state.nodes.find(node => node.id === toNodeId) ?? createHunsuNode(fromNode, recordedHunsu, toNodeId);
865
+ const edge: BoardEdge = { id: recordedHunsu.id, type: "hunsu", lineId: recordedHunsu.lineId, fromNodeId: fromNode.id, toNodeId, hunsuId: recordedHunsu.id };
866
+ const withRecord = {
867
+ ...state,
868
+ nodes: upsertById(state.nodes, targetNode),
869
+ hunsus: upsertById(state.hunsus, recordedHunsu),
870
+ edges: upsertById(state.edges, edge)
871
+ };
872
+ if (recordedHunsu.newLineId) {
873
+ return refreshVisibleDestinations(withRecord);
874
+ }
875
+ return refreshVisibleDestinations(advanceLineToNode(withRecord, recordedHunsu.lineId, toNodeId));
876
+ }
877
+
878
+ function projectLineForkedByHunsu(
879
+ state: BoardProjection,
880
+ event: Extract<DomainEvent, { type: "LineForkedByHunsu" }>
881
+ ): BoardProjection {
882
+ const parentLine = requireLine(state, event.fromLineId);
883
+ const hunsu = state.hunsus.find(candidate => candidate.id === event.hunsuId);
884
+ const move = event.fromMoveId ? state.moves.find(candidate => candidate.id === event.fromMoveId) : undefined;
885
+ const branchNodeId = hunsu?.toNodeId ?? move?.toNodeId ?? parentLine.currentNodeId;
886
+ const fromMoveIndex = event.fromMoveId ? parentLine.moveIds.indexOf(event.fromMoveId) : -1;
887
+ const inheritedMoveIds = fromMoveIndex >= 0 ? parentLine.moveIds.slice(0, fromMoveIndex + 1) : [];
888
+ return refreshVisibleDestinations({
889
+ ...state,
890
+ lines: upsertById(state.lines, {
891
+ id: event.newLineId,
892
+ requestId: event.requestId,
893
+ teamName: event.newTeamName ?? nextTeamName(state),
894
+ status: "active",
895
+ moveIds: inheritedMoveIds,
896
+ rootNodeId: parentLine.rootNodeId,
897
+ currentNodeId: branchNodeId,
898
+ nodeIds: [branchNodeId],
899
+ parentLineId: event.fromLineId,
900
+ forkedFromMoveId: event.fromMoveId
901
+ })
902
+ });
903
+ }
904
+
905
+ function normalizeStartedLine(state: BoardProjection, line: LineRecord): LineRecord {
906
+ return {
907
+ ...line,
908
+ teamName: line.teamName ?? nextTeamName(state)
909
+ };
910
+ }
911
+
912
+ function createMoveNode(fromNode: NodeRecord, move: MoveRecord, toNodeId: NodeId): NodeRecord {
913
+ const reachedDestinationIds = new Set<DestinationId>(move.reachedDestinationIds);
914
+ return {
915
+ id: toNodeId,
916
+ requestId: fromNode.requestId,
917
+ lineId: move.lineId,
918
+ teamName: move.teamName ?? fromNode.teamName,
919
+ ordinal: move.ordinal ?? fromNode.ordinal + 1,
920
+ destinations: fromNode.destinations.map(destination => reachedDestinationIds.has(destination.id)
921
+ ? unwrapDomainModelResult(reachDestination(destination, {
922
+ reachedByMoveId: move.id,
923
+ updatedBy: "TEAM",
924
+ updatedAt: move.recordedAt
925
+ }))
926
+ : { ...destination }),
927
+ harness: cloneHarness(fromNode.harness),
928
+ harnessGraph: cloneHarnessEntity(fromNode.harnessGraph),
929
+ harnessLock: fromNode.harnessLock ? { ...fromNode.harnessLock } : undefined,
930
+ executorPackageBindings: fromNode.executorPackageBindings?.map(binding => ({ executorId: binding.executorId, lock: { ...binding.lock } })),
931
+ resourcePackageBindings: fromNode.resourcePackageBindings?.map(binding => ({ name: binding.name, lock: { ...binding.lock } })),
932
+ artifactActions: cloneArtifactActions(fromNode.artifactActions),
933
+ source: { type: "move", moveId: move.id, fromNodeId: fromNode.id },
934
+ createdAt: move.recordedAt
935
+ };
936
+ }
937
+
938
+ function createHunsuNode(fromNode: NodeRecord, hunsu: HunsuRecord, toNodeId: NodeId): NodeRecord {
939
+ const snapshot = hunsu.teamSnapshot;
940
+ return {
941
+ id: toNodeId,
942
+ requestId: fromNode.requestId,
943
+ lineId: hunsu.newLineId ?? hunsu.lineId,
944
+ teamName: snapshot.teamName,
945
+ ordinal: snapshot.moveOrdinal,
946
+ destinations: snapshot.destinations.map(destination => ({ ...destination })),
947
+ harness: cloneHarness(snapshot.harness),
948
+ harnessGraph: cloneHarnessEntity(snapshot.harnessGraph),
949
+ harnessLock: snapshot.harnessLock ? { ...snapshot.harnessLock } : undefined,
950
+ executorPackageBindings: snapshot.executorPackageBindings?.map(binding => ({ executorId: binding.executorId, lock: { ...binding.lock } })),
951
+ resourcePackageBindings: snapshot.resourcePackageBindings?.map(binding => ({ name: binding.name, lock: { ...binding.lock } })),
952
+ artifactActions: cloneArtifactActions(snapshot.artifactActions),
953
+ source: { type: "hunsu", hunsuId: hunsu.id, fromNodeId: fromNode.id },
954
+ createdAt: hunsu.recordedAt
955
+ };
956
+ }
957
+
958
+ function cloneArtifactActions(actions: ArtifactActionDefinition[] | undefined): ArtifactActionDefinition[] {
959
+ return sortArtifactActions((actions ?? []).map(cloneArtifactAction));
960
+ }
961
+
962
+ function cloneArtifactAction(action: ArtifactActionDefinition): ArtifactActionDefinition {
963
+ return {
964
+ ...action,
965
+ env: action.env ? Object.fromEntries(Object.entries(action.env).map(([key, value]) => [key, { ...value }])) : undefined,
966
+ runner: { ...action.runner },
967
+ aliases: action.aliases ? Object.fromEntries(Object.entries(action.aliases).map(([key, value]) => [key, { ...value }])) : undefined,
968
+ evidence: action.evidence ? { ...action.evidence, paths: action.evidence.paths?.slice() } : undefined
969
+ };
970
+ }
971
+
972
+ function sortArtifactActions(actions: ArtifactActionDefinition[]): ArtifactActionDefinition[] {
973
+ return actions.slice().sort((left, right) => left.displayOrder - right.displayOrder || String(left.id).localeCompare(String(right.id)));
974
+ }
975
+
976
+ function uniqueArtifactActions(actions: ArtifactActionDefinition[]): ArtifactActionDefinition[] {
977
+ const seen = new Set<string>();
978
+ const uniqueActions: ArtifactActionDefinition[] = [];
979
+ for (const action of actions) {
980
+ if (seen.has(action.id)) {
981
+ continue;
982
+ }
983
+ seen.add(action.id);
984
+ uniqueActions.push(cloneArtifactAction(action));
985
+ }
986
+ return uniqueActions;
987
+ }
988
+
989
+ function snapshotForNode(node: NodeRecord) {
990
+ return {
991
+ teamName: node.teamName ?? unwrapDomainModelResult(makeTeamName("Team")),
992
+ moveOrdinal: node.ordinal,
993
+ destinations: node.destinations.map(destination => ({ ...destination })),
994
+ harness: cloneHarness(node.harness),
995
+ harnessGraph: cloneHarnessEntity(node.harnessGraph),
996
+ harnessLock: node.harnessLock ? { ...node.harnessLock } : undefined,
997
+ executorPackageBindings: node.executorPackageBindings?.map(binding => ({ executorId: binding.executorId, lock: { ...binding.lock } })),
998
+ resourcePackageBindings: node.resourcePackageBindings?.map(binding => ({ name: binding.name, lock: { ...binding.lock } })),
999
+ artifactActions: cloneArtifactActions(node.artifactActions)
1000
+ };
1001
+ }
1002
+
1003
+ function createDestination(requestId: RequestId, destination: DestinationSeed, source: DestinationSource, at?: string): Destination {
1004
+ return createPendingDestination(requestId, destination, source, at);
1005
+ }
1006
+
1007
+ function updateDestination(
1008
+ destinations: Destination[],
1009
+ destinationId: DestinationId,
1010
+ transition: (destination: Destination) => Result<Destination, DomainModelError>
1011
+ ): Destination[] {
1012
+ return destinations.map(destination => (destination.id === destinationId ? unwrapDomainModelResult(transition(destination)) : { ...destination }));
1013
+ }
1014
+
1015
+ function transitionDestinationInCurrentNodes(
1016
+ state: BoardProjection,
1017
+ destinationId: DestinationId,
1018
+ transition: (destination: Destination) => Result<Destination, DomainModelError>
1019
+ ): BoardProjection {
1020
+ const activeNodeIds = new Set(state.lines.map(line => line.currentNodeId));
1021
+ const nodes = state.nodes.map(node => {
1022
+ if (!activeNodeIds.has(node.id) || !node.destinations.some(destination => destination.id === destinationId)) {
1023
+ return node;
1024
+ }
1025
+ return { ...node, destinations: updateDestination(node.destinations, destinationId, transition) };
1026
+ });
1027
+ return refreshVisibleDestinations({ ...state, nodes });
1028
+ }
1029
+
1030
+ function transitionLine(
1031
+ state: BoardProjection,
1032
+ lineId: string,
1033
+ transition: (line: LineRecord) => Result<LineRecord, DomainModelError>
1034
+ ): BoardProjection {
1035
+ requireLine(state, lineId);
1036
+ return refreshVisibleDestinations({
1037
+ ...state,
1038
+ lines: state.lines.map(line => (line.id === lineId ? unwrapDomainModelResult(transition(line)) : line))
1039
+ });
1040
+ }
1041
+
1042
+ function appendMoveToLine(state: BoardProjection, move: MoveRecord): BoardProjection {
1043
+ requireLine(state, move.lineId);
1044
+ return {
1045
+ ...state,
1046
+ lines: state.lines.map(line => (line.id === move.lineId ? { ...line, moveIds: unique([...line.moveIds, move.id]) } : line))
1047
+ };
1048
+ }
1049
+
1050
+ function advanceLineToNode(state: BoardProjection, lineId: string, nodeId: NodeId): BoardProjection {
1051
+ requireLine(state, lineId);
1052
+ return {
1053
+ ...state,
1054
+ lines: state.lines.map(line => (line.id === lineId
1055
+ ? { ...line, currentNodeId: nodeId, nodeIds: unwrapDomainModelResult(makeNonEmptyArray(unique([...line.nodeIds, nodeId]), "nodeIds")) }
1056
+ : line))
1057
+ };
1058
+ }
1059
+
1060
+ function refreshVisibleDestinations(state: BoardProjection): BoardProjection {
1061
+ const visibleNodes = state.requests.flatMap(request => {
1062
+ const node = visibleNodeForRequest(state, request.id);
1063
+ return node ? [node] : [];
1064
+ });
1065
+ const visibleDestinations = visibleNodes.flatMap(node => node.destinations);
1066
+ const artifactActions = visibleNodes.flatMap(node => node.artifactActions);
1067
+ return {
1068
+ ...state,
1069
+ destinations: visibleDestinations.map(destination => ({ ...destination })),
1070
+ artifactActions: sortArtifactActions(uniqueArtifactActions(artifactActions))
1071
+ };
1072
+ }
1073
+
1074
+ function visibleNodeForRequest(state: BoardProjection, requestId: RequestId): NodeRecord | undefined {
1075
+ const requestLines = state.lines.filter(line => line.requestId === requestId && line.status !== "abandoned");
1076
+ const line = requestLines.findLast(candidate => candidate.status === "active") ?? requestLines.at(-1);
1077
+ if (line) {
1078
+ return state.nodes.find(node => node.id === line.currentNodeId);
1079
+ }
1080
+ return state.nodes.find(node => node.id === rootNodeId(requestId));
1081
+ }