@dotbep/core 0.2.7 → 0.2.9

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 (77) hide show
  1. package/dist/index.d.ts +7 -40
  2. package/dist/index.js +1142 -1179
  3. package/package.json +4 -1
  4. package/examples/01-participants.ts +0 -127
  5. package/examples/02-files.ts +0 -100
  6. package/examples/03-workflows.ts +0 -149
  7. package/examples/04-bim-uses.ts +0 -70
  8. package/examples/05-standards.ts +0 -60
  9. package/examples/06-schedule.ts +0 -124
  10. package/examples/07-loin.ts +0 -133
  11. package/examples/08-deliverables.ts +0 -126
  12. package/examples/09-notes.ts +0 -73
  13. package/examples/10-llm.ts +0 -109
  14. package/examples/11-resolved.ts +0 -133
  15. package/examples/12-history.ts +0 -166
  16. package/examples/13-engine.ts +0 -152
  17. package/examples/bep.d.ts +0 -38
  18. package/examples/example.bep +0 -0
  19. package/examples/run-all.ts +0 -38
  20. package/src/base/entity.ts +0 -148
  21. package/src/base/history.ts +0 -497
  22. package/src/base/index.ts +0 -5
  23. package/src/base/singleton.ts +0 -26
  24. package/src/entities/actions.ts +0 -25
  25. package/src/entities/adapters.ts +0 -16
  26. package/src/entities/annexes.ts +0 -17
  27. package/src/entities/assetTypes.ts +0 -30
  28. package/src/entities/automations.ts +0 -24
  29. package/src/entities/bimUses.ts +0 -50
  30. package/src/entities/deliverables.ts +0 -66
  31. package/src/entities/disciplines.ts +0 -21
  32. package/src/entities/effects.ts +0 -28
  33. package/src/entities/env.ts +0 -17
  34. package/src/entities/events.ts +0 -24
  35. package/src/entities/extensions.ts +0 -16
  36. package/src/entities/flags.ts +0 -17
  37. package/src/entities/guides.ts +0 -26
  38. package/src/entities/index.ts +0 -32
  39. package/src/entities/lbsNodes.ts +0 -193
  40. package/src/entities/lods.ts +0 -22
  41. package/src/entities/loin.ts +0 -127
  42. package/src/entities/lois.ts +0 -22
  43. package/src/entities/members.ts +0 -137
  44. package/src/entities/milestones.ts +0 -32
  45. package/src/entities/notes.ts +0 -27
  46. package/src/entities/objectives.ts +0 -17
  47. package/src/entities/phases.ts +0 -17
  48. package/src/entities/remoteData.ts +0 -17
  49. package/src/entities/resolvers.ts +0 -20
  50. package/src/entities/roles.ts +0 -29
  51. package/src/entities/softwares.ts +0 -26
  52. package/src/entities/standards.ts +0 -68
  53. package/src/entities/teams.ts +0 -42
  54. package/src/entities/workflows.ts +0 -256
  55. package/src/index.ts +0 -464
  56. package/src/runtime/Engine.ts +0 -352
  57. package/src/runtime/MemoryStorage.ts +0 -31
  58. package/src/runtime/Runtime.ts +0 -106
  59. package/src/runtime/index.ts +0 -4
  60. package/src/runtime/transitions.ts +0 -456
  61. package/src/runtime/types.ts +0 -279
  62. package/src/types/history.ts +0 -37
  63. package/src/types/index.ts +0 -24
  64. package/src/types/resolved.ts +0 -137
  65. package/src/types/schema.ts +0 -757
  66. package/src/utils/diff.ts +0 -109
  67. package/src/utils/index.ts +0 -9
  68. package/src/utils/integrity.ts +0 -108
  69. package/src/utils/lbs.ts +0 -116
  70. package/src/utils/mermaid.ts +0 -110
  71. package/src/utils/naming.ts +0 -62
  72. package/src/utils/nomenclature.ts +0 -107
  73. package/src/utils/normalize.ts +0 -35
  74. package/src/utils/raci.ts +0 -25
  75. package/src/utils/textFile.ts +0 -24
  76. package/tsconfig.json +0 -12
  77. package/vite.config.ts +0 -24
@@ -1,456 +0,0 @@
1
- // Pure workflow engine — no I/O, no side effects.
2
- // Takes BEP schema + instance state, returns new state + effects to fire.
3
-
4
- import type { BEP, FlowEdge, EdgeGuard } from '../types/schema.js'
5
- import type {
6
- IncomingEvent,
7
- TransitionEvent,
8
- WorkflowInstance,
9
- InstanceStatus,
10
- NodeConfig,
11
- RoleRef,
12
- TeamRef,
13
- RaciLevel,
14
- ProcessEventError,
15
- TransitionStep,
16
- PayloadFieldError,
17
- } from './types.js'
18
-
19
- // Safety limit to prevent infinite loops in malformed decision chains.
20
- const MAX_DECISION_DEPTH = 10
21
-
22
- // ─── Guard evaluation ─────────────────────────────────────────────────────────
23
-
24
- /** Evaluates a guard condition against an event payload. Pure. */
25
- export function evaluateGuard(guard: EdgeGuard, payload: Record<string, unknown>): boolean {
26
- const val = payload[guard.field]
27
- switch (guard.operator) {
28
- case 'exists': return val !== undefined && val !== null
29
- case 'eq': return val === guard.value
30
- case 'neq': return val !== guard.value
31
- case 'gt': return typeof val === 'number' && typeof guard.value === 'number' && val > guard.value
32
- case 'lt': return typeof val === 'number' && typeof guard.value === 'number' && val < guard.value
33
- case 'contains':
34
- if (typeof val === 'string' && typeof guard.value === 'string') return val.includes(guard.value)
35
- if (Array.isArray(val)) return (val as unknown[]).includes(guard.value)
36
- return false
37
- default: return false
38
- }
39
- }
40
-
41
- // ─── Authorization ────────────────────────────────────────────────────────────
42
-
43
- /**
44
- * Returns true if the actor satisfies the R or A constraints of a process node.
45
- * If no R or A is defined, the node is open to anyone.
46
- */
47
- function isActorAuthorized(bep: BEP, nodeId: string, workflowId: string, actorEmail: string): boolean {
48
- const workflow = bep.workflows.find(w => w.id === workflowId)
49
- const node = workflow?.diagram.nodes[nodeId]
50
- if (!node || node.type !== 'process') return true
51
-
52
- const hasResponsible = !!(node.responsibleRoleIds?.length || node.responsibleTeamIds?.length || node.responsibleEmails?.length)
53
- const hasAccountable = !!(node.accountableRoleIds?.length || node.accountableTeamIds?.length || node.accountableEmails?.length)
54
- if (!hasResponsible && !hasAccountable) return true
55
-
56
- const member = bep.members.find(m => m.email === actorEmail)
57
- const actorRoleId = member?.roleId
58
- const actorTeamIds = new Set(bep.teams.filter(t => (t.memberEmails ?? []).includes(actorEmail)).map(t => t.id))
59
-
60
- const matches = (roleIds?: string[], teamIds?: string[], emails?: string[]): boolean => {
61
- if (emails?.includes(actorEmail)) return true
62
- const hasRoles = !!roleIds?.length
63
- const hasTeams = !!teamIds?.length
64
- if (hasTeams && hasRoles)
65
- return !!actorRoleId && roleIds!.includes(actorRoleId) && teamIds!.some(tid => actorTeamIds.has(tid))
66
- if (hasTeams) return teamIds!.some(tid => actorTeamIds.has(tid))
67
- if (hasRoles) return !!actorRoleId && roleIds!.includes(actorRoleId)
68
- return false
69
- }
70
-
71
- return (
72
- (hasResponsible && matches(node.responsibleRoleIds, node.responsibleTeamIds, node.responsibleEmails)) ||
73
- (hasAccountable && matches(node.accountableRoleIds, node.accountableTeamIds, node.accountableEmails))
74
- )
75
- }
76
-
77
- // ─── Payload validation ───────────────────────────────────────────────────────
78
-
79
- const JS_TYPE: Record<string, string> = { string: 'string', number: 'number', boolean: 'boolean' }
80
-
81
- function validatePayload(
82
- bep: BEP,
83
- eventId: string,
84
- payload: Record<string, unknown> | undefined,
85
- ): PayloadFieldError[] {
86
- const def = bep.events.find(e => e.id === eventId)
87
- if (!def?.payload?.length) return []
88
-
89
- const errors: PayloadFieldError[] = []
90
- const incoming = payload ?? {}
91
-
92
- for (const field of def.payload) {
93
- const val = incoming[field.key]
94
- if (val === undefined || val === null) {
95
- if (field.required) errors.push({ field: field.key, reason: 'missing' })
96
- } else {
97
- const expected = JS_TYPE[field.type]
98
- if (expected && typeof val !== expected) {
99
- errors.push({ field: field.key, reason: 'wrong_type' })
100
- }
101
- }
102
- }
103
-
104
- const declaredKeys = new Set(def.payload.map(f => f.key))
105
- for (const key of Object.keys(incoming)) {
106
- if (!declaredKeys.has(key)) errors.push({ field: key, reason: 'unknown_field' })
107
- }
108
-
109
- return errors
110
- }
111
-
112
- // ─── Edge matching ────────────────────────────────────────────────────────────
113
-
114
- function edgeMatchesEvent(edge: FlowEdge, event: IncomingEvent): boolean {
115
- if (!('triggerEventId' in edge)) return false
116
- if (edge.triggerEventId !== event.eventId) return false
117
- return true
118
- }
119
-
120
- // ─── Internal result type ─────────────────────────────────────────────────────
121
-
122
- /** Internal result of processEvent — used by Engine to build EventResult. */
123
- export interface ProcessEventResult {
124
- ok: boolean
125
- /** Updated instance. Present when ok = true. */
126
- instance?: WorkflowInstance
127
- /** Ordered list of transitions applied (may include decision auto-traversals). */
128
- transitionsApplied?: TransitionStep[]
129
- /** Effects to execute after the transition. Caller is responsible for firing them. */
130
- effectsToFire?: { effectId: string; fromEdgeId: string }[]
131
- /** Present when the final node is an automation node — Engine must execute the automation then emit the returned eventId. */
132
- automationNodePending?: { nodeId: string; automationId: string }
133
- error?: ProcessEventError
134
- /** Present when error = 'INVALID_PAYLOAD'. */
135
- payloadErrors?: PayloadFieldError[]
136
- }
137
-
138
- // ─── Create instance ──────────────────────────────────────────────────────────
139
-
140
- /**
141
- * Creates a new WorkflowInstance and advances it past the start node.
142
- *
143
- * The start node is always a visual anchor — it has no RACI, no action, and no
144
- * trigger event on its outgoing edge. The instance is immediately moved to the
145
- * first node after start so that the first emit() acts on the real first step.
146
- *
147
- * Returns null if the workflow is not found or has no start node.
148
- */
149
- export function createInstance(
150
- bep: BEP,
151
- workflowId: string,
152
- trackedAsset: WorkflowInstance['trackedAsset'],
153
- initiatedBy: string,
154
- bepVersion: string,
155
- ): { instance: WorkflowInstance; startEffects: { effectId: string; fromEdgeId: string }[] } | null {
156
- const workflow = bep.workflows.find(w => w.id === workflowId)
157
- if (!workflow) return null
158
-
159
- const startNodeId = Object.keys(workflow.diagram.nodes).find(
160
- k => workflow.diagram.nodes[k]!.type === 'start',
161
- )
162
- if (!startNodeId) return null
163
-
164
- // Advance past the start node — find its single outgoing edge (no trigger required).
165
- const startEdgeEntry = Object.entries(workflow.diagram.edges).find(([, e]) => e.from === startNodeId)
166
- const firstNodeId = startEdgeEntry?.[1].to ?? startNodeId
167
- const startEffects = startEdgeEntry
168
- ? (startEdgeEntry[1].effectIds ?? []).map(effectId => ({ effectId, fromEdgeId: startEdgeEntry[0] }))
169
- : []
170
-
171
- const now = new Date().toISOString()
172
- return {
173
- instance: {
174
- id: globalThis.crypto.randomUUID(),
175
- workflowId,
176
- bepVersion,
177
- trackedAsset,
178
- currentNodeId: firstNodeId,
179
- status: 'active',
180
- history: [],
181
- createdAt: now,
182
- updatedAt: now,
183
- initiatedBy,
184
- },
185
- startEffects,
186
- }
187
- }
188
-
189
- // ─── Process event ────────────────────────────────────────────────────────────
190
-
191
- /**
192
- * Processes an incoming event against a workflow instance.
193
- *
194
- * - Finds the matching outgoing edge from the current node.
195
- * - Auto-traverses decision nodes using the same event payload.
196
- * - Returns the updated instance and the effects to fire. Pure — does not mutate.
197
- */
198
- export function processEvent(
199
- bep: BEP,
200
- instance: WorkflowInstance,
201
- event: IncomingEvent,
202
- options?: { skipRaci?: boolean },
203
- ): ProcessEventResult {
204
- if (instance.status !== 'active') {
205
- return { ok: false, error: 'INSTANCE_NOT_ACTIVE' }
206
- }
207
-
208
- if (!options?.skipRaci && !isActorAuthorized(bep, instance.currentNodeId, instance.workflowId, event.actor)) {
209
- return { ok: false, error: 'UNAUTHORIZED' }
210
- }
211
-
212
- const workflow = bep.workflows.find(w => w.id === instance.workflowId)
213
- if (!workflow) return { ok: false, error: 'NO_MATCHING_EDGE' }
214
-
215
- const { nodes, edges } = workflow.diagram
216
-
217
- // Working state — assembled immutably into the final instance at the end.
218
- let currentNodeId = instance.currentNodeId
219
- const newHistory: TransitionEvent[] = []
220
- const effectsToFire: { effectId: string; fromEdgeId: string }[] = []
221
- const transitionsApplied: TransitionStep[] = []
222
-
223
- // ── Step 1: match an edge from the current node ───────────────────────────
224
-
225
- const candidates = Object.entries(edges).filter(
226
- ([, e]) => e.from === currentNodeId && edgeMatchesEvent(e, event),
227
- )
228
-
229
- if (candidates.length === 0) return { ok: false, error: 'NO_MATCHING_EDGE' }
230
- if (candidates.length > 1) return { ok: false, error: 'AMBIGUOUS_TRANSITION' }
231
-
232
- const [edgeId, edge] = candidates[0]!
233
-
234
- if ('triggerEventId' in edge) {
235
- const payloadErrors = validatePayload(bep, edge.triggerEventId, event.payload)
236
- if (payloadErrors.length > 0) return { ok: false, error: 'INVALID_PAYLOAD', payloadErrors }
237
- }
238
-
239
- newHistory.push(buildTransitionEvent(edgeId, currentNodeId, edge.to, event))
240
- effectsToFire.push(...(edge.effectIds ?? []).map(effectId => ({ effectId, fromEdgeId: edgeId })))
241
- transitionsApplied.push({ edgeId, fromNodeId: currentNodeId, toNodeId: edge.to })
242
- currentNodeId = edge.to
243
-
244
- // ── Step 2: auto-traverse decision nodes ─────────────────────────────────
245
- //
246
- // Decision nodes are never stable resting points — the engine evaluates their
247
- // outgoing edges immediately using the original event's payload.
248
- // Guards on outgoing decision edges should be mutually exclusive.
249
-
250
- let depth = 0
251
- while (nodes[currentNodeId]?.type === 'decision') {
252
- if (++depth > MAX_DECISION_DEPTH) return { ok: false, error: 'DECISION_LOOP' }
253
-
254
- const outgoing = Object.entries(edges).filter(([, e]) => {
255
- if (e.from !== currentNodeId) return false
256
- if (!('guard' in e)) return false
257
- return evaluateGuard(e.guard, event.payload ?? {})
258
- })
259
-
260
- // No branch matches — leave instance on the decision node (diagram error).
261
- if (outgoing.length === 0) break
262
-
263
- // Take first matching branch — guards are expected to be mutually exclusive.
264
- const [decEdgeId, decEdge] = outgoing[0]!
265
-
266
- newHistory.push(buildTransitionEvent(decEdgeId, currentNodeId, decEdge.to, event, true))
267
- effectsToFire.push(...(decEdge.effectIds ?? []).map(effectId => ({ effectId, fromEdgeId: decEdgeId })))
268
- transitionsApplied.push({ edgeId: decEdgeId, fromNodeId: currentNodeId, toNodeId: decEdge.to })
269
- currentNodeId = decEdge.to
270
- }
271
-
272
- // ── Step 3: compute final status ─────────────────────────────────────────
273
-
274
- const finalNode = nodes[currentNodeId]
275
- const newStatus: InstanceStatus = finalNode?.type === 'end' ? 'completed' : 'active'
276
-
277
- const updatedInstance: WorkflowInstance = {
278
- ...instance,
279
- currentNodeId,
280
- status: newStatus,
281
- history: [...instance.history, ...newHistory],
282
- updatedAt: new Date().toISOString(),
283
- }
284
-
285
- const automationNodePending = finalNode?.type === 'automation' && finalNode.automationId
286
- ? { nodeId: currentNodeId, automationId: finalNode.automationId }
287
- : undefined
288
-
289
- return { ok: true, instance: updatedInstance, transitionsApplied, effectsToFire, automationNodePending }
290
- }
291
-
292
- // ─── Node config ──────────────────────────────────────────────────────────────
293
-
294
- /**
295
- * Returns what a specific actor can do from the current node of an instance.
296
- * Used by apps to render only the actions available to the logged-in user.
297
- */
298
- export function getNodeConfig(
299
- bep: BEP,
300
- instance: WorkflowInstance,
301
- actorEmail: string,
302
- ): NodeConfig {
303
- const workflow = bep.workflows.find(w => w.id === instance.workflowId)!
304
- const { nodes, edges } = workflow.diagram
305
- const currentNode = nodes[instance.currentNodeId]!
306
-
307
- // Resolve actor profile.
308
- const member = bep.members.find(m => m.email === actorEmail)
309
- const actorRoleId = member?.roleId
310
- const actorTeamIds = new Set(
311
- bep.teams.filter(t => (t.memberEmails ?? []).includes(actorEmail)).map(t => t.id)
312
- )
313
-
314
- // ── Helpers ────────────────────────────────────────────────────────────────
315
-
316
- const resolveRoles = (ids?: string[]): RoleRef[] =>
317
- (ids ?? []).flatMap(id => {
318
- const role = bep.roles.find(r => r.id === id)
319
- return role ? [{ id: role.id, name: role.name }] : []
320
- })
321
-
322
- const resolveTeams = (ids?: string[]): TeamRef[] =>
323
- (ids ?? []).flatMap(id => {
324
- const team = bep.teams.find(t => t.id === id)
325
- return team ? [{ id: team.id, name: team.name }] : []
326
- })
327
-
328
- const buildRaciLevel = (
329
- roleIds?: string[], teamIds?: string[], emails?: string[],
330
- ): RaciLevel => ({
331
- roles: resolveRoles(roleIds),
332
- teams: resolveTeams(teamIds),
333
- emails: emails ?? [],
334
- })
335
-
336
- /**
337
- * Three-level authorization check for a single RACI letter (R or A).
338
- * Only call this when the letter has at least one constraint defined.
339
- * 1. Email match — explicit member, always authorized.
340
- * 2. Team + Role — actor must be in a listed team AND have a listed role.
341
- * 3. Team only — actor must be in a listed team.
342
- * 4. Role only — actor must have a listed role.
343
- */
344
- const matchesConstraints = (
345
- roleIds?: string[], teamIds?: string[], emails?: string[],
346
- ): boolean => {
347
- if (emails?.includes(actorEmail)) return true
348
- const hasRoles = !!roleIds?.length
349
- const hasTeams = !!teamIds?.length
350
- if (hasTeams && hasRoles) {
351
- return (
352
- !!actorRoleId && roleIds!.includes(actorRoleId) &&
353
- teamIds!.some(tid => actorTeamIds.has(tid))
354
- )
355
- }
356
- if (hasTeams) return teamIds!.some(tid => actorTeamIds.has(tid))
357
- if (hasRoles) return !!actorRoleId && roleIds!.includes(actorRoleId)
358
- return false
359
- }
360
-
361
- const raciNode = currentNode.type === 'process' ? currentNode : null
362
-
363
- const hasResponsible = !!(
364
- raciNode?.responsibleRoleIds?.length ||
365
- raciNode?.responsibleTeamIds?.length ||
366
- raciNode?.responsibleEmails?.length
367
- )
368
- const hasAccountable = !!(
369
- raciNode?.accountableRoleIds?.length ||
370
- raciNode?.accountableTeamIds?.length ||
371
- raciNode?.accountableEmails?.length
372
- )
373
-
374
- // No R or A defined on the node → open to anyone.
375
- // Otherwise actor must satisfy at least one of the defined constraints.
376
- const actorIsAuthorized =
377
- (!hasResponsible && !hasAccountable) ||
378
- (hasResponsible && matchesConstraints(raciNode?.responsibleRoleIds, raciNode?.responsibleTeamIds, raciNode?.responsibleEmails)) ||
379
- (hasAccountable && matchesConstraints(raciNode?.accountableRoleIds, raciNode?.accountableTeamIds, raciNode?.accountableEmails))
380
-
381
- // Resolve required payload fields from the global FlowEvent catalog.
382
- const resolvePayload = (eventId: string) =>
383
- (bep.events.find(e => e.id === eventId)?.payload ?? []).map(p => ({
384
- key: p.key,
385
- type: p.type,
386
- required: p.required,
387
- }))
388
-
389
- const availableTransitions: NodeConfig['availableTransitions'] = []
390
- const blockedTransitions: NodeConfig['blockedTransitions'] = []
391
-
392
- for (const [edgeId, edge] of Object.entries(edges)) {
393
- if (edge.from !== instance.currentNodeId) continue
394
- if (!('triggerEventId' in edge)) continue
395
-
396
- const eventId = edge.triggerEventId
397
-
398
- if (actorIsAuthorized) {
399
- availableTransitions.push({
400
- edgeId,
401
- label: edge.label ?? eventId,
402
- emits: eventId,
403
- requiredPayload: resolvePayload(eventId),
404
- })
405
- } else {
406
- blockedTransitions.push({
407
- edgeId,
408
- label: edge.label ?? eventId,
409
- reason: 'UNAUTHORIZED',
410
- required: buildRaciLevel(
411
- [...(raciNode?.responsibleRoleIds ?? []), ...(raciNode?.accountableRoleIds ?? [])],
412
- [...(raciNode?.responsibleTeamIds ?? []), ...(raciNode?.accountableTeamIds ?? [])],
413
- [...(raciNode?.responsibleEmails ?? []), ...(raciNode?.accountableEmails ?? [])],
414
- ),
415
- })
416
- }
417
- }
418
-
419
- return {
420
- currentNode: {
421
- id: instance.currentNodeId,
422
- type: currentNode.type,
423
- label: instance.currentNodeId,
424
- },
425
- availableTransitions,
426
- blockedTransitions,
427
- raci: {
428
- responsible: buildRaciLevel(raciNode?.responsibleRoleIds, raciNode?.responsibleTeamIds, raciNode?.responsibleEmails),
429
- accountable: buildRaciLevel(raciNode?.accountableRoleIds, raciNode?.accountableTeamIds, raciNode?.accountableEmails),
430
- consulted: buildRaciLevel(raciNode?.consultedRoleIds, raciNode?.consultedTeamIds, raciNode?.consultedEmails),
431
- informed: buildRaciLevel(raciNode?.informedRoleIds, raciNode?.informedTeamIds, raciNode?.informedEmails),
432
- },
433
- isTerminal: currentNode.type === 'end',
434
- }
435
- }
436
-
437
- // ─── Internal helpers ─────────────────────────────────────────────────────────
438
-
439
- function buildTransitionEvent(
440
- edgeId: string,
441
- fromNodeId: string,
442
- toNodeId: string,
443
- trigger: IncomingEvent,
444
- auto?: boolean,
445
- ): TransitionEvent {
446
- return {
447
- id: globalThis.crypto.randomUUID(),
448
- edgeId,
449
- fromNodeId,
450
- toNodeId,
451
- trigger,
452
- actor: trigger.actor,
453
- timestamp: new Date().toISOString(),
454
- ...(auto ? { auto: true } : {}),
455
- }
456
- }