@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.
- package/LICENSE +157 -0
- package/dist/command-decoder.d.ts +5 -0
- package/dist/command-decoder.js +329 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.js +15 -0
- package/dist/decoder-helpers.d.ts +58 -0
- package/dist/decoder-helpers.js +1757 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +28 -0
- package/dist/event-decoder.d.ts +5 -0
- package/dist/event-decoder.js +232 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/lifecycle.d.ts +67 -0
- package/dist/lifecycle.js +271 -0
- package/dist/model.d.ts +1058 -0
- package/dist/model.js +1 -0
- package/dist/primitives.d.ts +59 -0
- package/dist/primitives.js +135 -0
- package/dist/projection.d.ts +8 -0
- package/dist/projection.js +875 -0
- package/dist/prompt-template.d.ts +12 -0
- package/dist/prompt-template.js +83 -0
- package/dist/protocol-validation.d.ts +36 -0
- package/dist/protocol-validation.js +1139 -0
- package/dist/result.d.ts +20 -0
- package/dist/result.js +21 -0
- package/dist/workflow-helpers.d.ts +31 -0
- package/dist/workflow-helpers.js +193 -0
- package/dist/workflow.d.ts +10 -0
- package/dist/workflow.js +594 -0
- package/package.json +30 -0
- package/src/command-decoder.ts +292 -0
- package/src/constants.ts +27 -0
- package/src/decoder-helpers.ts +1672 -0
- package/src/errors.ts +38 -0
- package/src/event-decoder.ts +225 -0
- package/src/index.ts +43 -0
- package/src/lifecycle.ts +403 -0
- package/src/model.ts +1233 -0
- package/src/primitives.ts +196 -0
- package/src/projection.ts +1081 -0
- package/src/prompt-template.ts +97 -0
- package/src/protocol-validation.ts +1252 -0
- package/src/result.ts +35 -0
- package/src/workflow-helpers.ts +239 -0
- 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
|
+
}
|