@pikku/core 0.12.70 → 0.12.71

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 (48) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/LICENSE +21 -0
  3. package/dist/services/in-memory-queue-service.d.ts +6 -0
  4. package/dist/services/in-memory-queue-service.js +8 -1
  5. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  6. package/dist/services/in-memory-workflow-service.js +10 -19
  7. package/dist/services/workflow-service.d.ts +7 -5
  8. package/dist/types/core.types.d.ts +7 -0
  9. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  10. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  11. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  12. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  13. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  14. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  15. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  16. package/dist/wirings/workflow/graph/index.js +1 -1
  17. package/dist/wirings/workflow/index.d.ts +0 -1
  18. package/dist/wirings/workflow/index.js +0 -2
  19. package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
  20. package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
  21. package/dist/wirings/workflow/workflow.types.d.ts +1 -6
  22. package/package.json +1 -1
  23. package/src/services/in-memory-queue-service.test.ts +66 -1
  24. package/src/services/in-memory-queue-service.ts +13 -2
  25. package/src/services/in-memory-workflow-service.ts +12 -25
  26. package/src/services/workflow-service.ts +7 -4
  27. package/src/types/core.types.ts +7 -0
  28. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  29. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  30. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  31. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  32. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  33. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  34. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  35. package/src/wirings/workflow/graph/index.ts +1 -5
  36. package/src/wirings/workflow/index.ts +0 -6
  37. package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
  38. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  39. package/src/wirings/workflow/scenario-step.test.ts +1 -1
  40. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  41. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  42. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  43. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  44. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  45. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  46. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  47. package/src/wirings/workflow/workflow.types.ts +1 -4
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,177 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+
7
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
8
+
9
+ function service() {
10
+ pikkuState(null, 'package', 'singletonServices', {
11
+ queueService: { add: async () => {} },
12
+ logger: silentLogger,
13
+ } as any)
14
+ return new InMemoryWorkflowService() as any
15
+ }
16
+
17
+ const newRun = (ws: any, inline = false) =>
18
+ ws.createRun('flow', {}, inline, 'hash', { type: 'test' })
19
+
20
+ const trackDetaches = (ws: any): string[] => {
21
+ const detached: string[] = []
22
+ ws.setRunExtension(() => ({
23
+ attachRunContext: async () => {},
24
+ detachRunContext: (runId: string) => detached.push(runId),
25
+ decorateRunWire: () => {},
26
+ decorateWorkflowWire: () => {},
27
+ onBeforeRunFunc: async () => {},
28
+ onAfterRunFunc: async () => {},
29
+ }))
30
+ return detached
31
+ }
32
+
33
+ /**
34
+ * A long-lived process orchestrates many runs. Anything it keeps per run has to
35
+ * be released when that run stops executing here, or the engine grows for the
36
+ * life of the deployment.
37
+ */
38
+ describe('per-run state is released when the run stops executing here', () => {
39
+ test('a finished replay leaves nothing behind', async () => {
40
+ const ws = service()
41
+ const runId = await newRun(ws)
42
+
43
+ await ws.beginReplay(runId)
44
+ await ws.inlineStep(runId, 'a', async () => 1)
45
+ ws.endReplay(runId)
46
+
47
+ assert.equal(
48
+ ws.runContexts.size,
49
+ 0,
50
+ 'the replay held on to its ordinals, lineage or step snapshot'
51
+ )
52
+ })
53
+
54
+ test('the step the walk last reached does not survive its replay', async () => {
55
+ const ws = service()
56
+ const runId = await newRun(ws)
57
+
58
+ await ws.beginReplay(runId)
59
+ await ws.inlineStep(runId, 'a', async () => 1)
60
+ ws.endReplay(runId)
61
+
62
+ assert.equal(ws.lastStepName(runId), undefined)
63
+ })
64
+
65
+ test('unregistering an inline run releases it', async () => {
66
+ const ws = service()
67
+ const runId = await newRun(ws)
68
+
69
+ ws.registerInlineRun(runId)
70
+ assert.equal(ws.isInline(runId), true)
71
+
72
+ ws.unregisterInlineRun(runId)
73
+ assert.equal(ws.isInline(runId), false)
74
+ assert.equal(ws.runContexts.size, 0)
75
+ })
76
+
77
+ test('a replay inside an inline run does not release the inline flag', async () => {
78
+ const ws = service()
79
+ const runId = await newRun(ws)
80
+
81
+ ws.registerInlineRun(runId)
82
+ await ws.beginReplay(runId)
83
+ ws.endReplay(runId)
84
+
85
+ assert.equal(
86
+ ws.isInline(runId),
87
+ true,
88
+ 'ending a replay dropped state the run still needs'
89
+ )
90
+ })
91
+
92
+ test("a queued run's extension context is released when the run reaches a terminal state", async () => {
93
+ const ws = service()
94
+ const runId = await newRun(ws)
95
+ const detached = trackDetaches(ws)
96
+
97
+ await ws.updateRunStatus(runId, 'completed', null)
98
+
99
+ assert.deepEqual(
100
+ detached,
101
+ [runId],
102
+ 'an extension holds live per-run state; a completed run must not keep it'
103
+ )
104
+ assert.equal(ws.runContexts.size, 0)
105
+ })
106
+
107
+ test('a suspended run keeps its extension context, because it can still be resumed', async () => {
108
+ const ws = service()
109
+ const runId = await newRun(ws)
110
+ const detached = trackDetaches(ws)
111
+
112
+ await ws.updateRunStatus(runId, 'suspended')
113
+
114
+ assert.deepEqual(detached, [])
115
+ })
116
+ })
117
+
118
+ describe('a replay reads the run once', () => {
119
+ test('the steps of a replay share one read of the run', async () => {
120
+ const ws = service()
121
+ const runId = await newRun(ws)
122
+
123
+ let reads = 0
124
+ const getRun = ws.getRun.bind(ws)
125
+ ws.getRun = async (id: string) => {
126
+ reads++
127
+ return getRun(id)
128
+ }
129
+
130
+ await ws.beginReplay(runId)
131
+ await ws.getRunIdentity(runId)
132
+ await ws.getRunIdentity(runId)
133
+ await ws.getRunIdentity(runId)
134
+ ws.endReplay(runId)
135
+
136
+ assert.equal(reads, 1, `the run was read ${reads} times in one replay`)
137
+ })
138
+
139
+ test('the next replay re-reads the run rather than trusting the last one', async () => {
140
+ const ws = service()
141
+ const runId = await newRun(ws)
142
+
143
+ let reads = 0
144
+ const getRun = ws.getRun.bind(ws)
145
+ ws.getRun = async (id: string) => {
146
+ reads++
147
+ return getRun(id)
148
+ }
149
+
150
+ await ws.beginReplay(runId)
151
+ await ws.getRunIdentity(runId)
152
+ ws.endReplay(runId)
153
+ await ws.beginReplay(runId)
154
+ await ws.getRunIdentity(runId)
155
+ ws.endReplay(runId)
156
+
157
+ assert.equal(reads, 2)
158
+ })
159
+
160
+ test('outside a replay the run is read every time', async () => {
161
+ const ws = service()
162
+ const runId = await newRun(ws)
163
+
164
+ let reads = 0
165
+ const getRun = ws.getRun.bind(ws)
166
+ ws.getRun = async (id: string) => {
167
+ reads++
168
+ return getRun(id)
169
+ }
170
+
171
+ await ws.getRunIdentity(runId)
172
+ await ws.getRunIdentity(runId)
173
+
174
+ assert.equal(reads, 2, 'a cached run outside a replay would go stale')
175
+ assert.equal(ws.runContexts.size, 0, 'reading a run must not create state')
176
+ })
177
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+ import type { WorkflowRun } from './workflow.types.js'
7
+
8
+ /**
9
+ * A workflow service whose run reads are counted and timestamped, and whose
10
+ * run finishes only once it has been read `endAfter` times.
11
+ */
12
+ class PollProbe extends InMemoryWorkflowService {
13
+ readonly reads: number[] = []
14
+ /** Every wait the loop asked for, in order — the schedule, not the clock. */
15
+ readonly waits: number[] = []
16
+ endAfter = 1
17
+
18
+ override async waitBeforeNextRead(ms: number): Promise<void> {
19
+ this.waits.push(ms)
20
+ }
21
+
22
+ override async startWorkflow(): Promise<{ runId: string }> {
23
+ return { runId: 'run-1' }
24
+ }
25
+
26
+ override async getRun(_runId: string): Promise<WorkflowRun | null> {
27
+ this.reads.push(Date.now())
28
+ const done = this.reads.length >= this.endAfter
29
+ return {
30
+ id: 'run-1',
31
+ status: done ? 'completed' : 'running',
32
+ output: 'result',
33
+ } as WorkflowRun
34
+ }
35
+ }
36
+
37
+ const probe = () => {
38
+ pikkuState(null, 'package', 'singletonServices', {
39
+ logger: { error() {}, info() {}, warn() {}, debug() {} },
40
+ } as any)
41
+ return new PollProbe()
42
+ }
43
+
44
+ describe('waiting for a run to finish', () => {
45
+ test('a run that finishes quickly is not held for a whole poll interval', async () => {
46
+ const ws = probe()
47
+ ws.endAfter = 2
48
+
49
+ const output = await ws.runToCompletion('flow', {}, {} as any)
50
+
51
+ assert.equal(output, 'result')
52
+ assert.equal(ws.waits.length, 1)
53
+ assert.ok(
54
+ ws.waits[0]! < 1000,
55
+ `a run done after one poll waited the full ${ws.waits[0]}ms default — a fixed interval makes every fast workflow pay for it`
56
+ )
57
+ })
58
+
59
+ test('reads spread out as a run keeps running', async () => {
60
+ const ws = probe()
61
+ ws.endAfter = 8
62
+
63
+ await ws.runToCompletion('flow', {}, {} as any)
64
+
65
+ assert.ok(ws.waits.length >= 6)
66
+ assert.ok(
67
+ ws.waits.at(-1)! > ws.waits[0]! * 2,
68
+ `waits stayed flat (${ws.waits.join('ms, ')}ms) — a run that drags on keeps costing the same read rate forever`
69
+ )
70
+ for (let i = 1; i < ws.waits.length; i++) {
71
+ assert.ok(
72
+ ws.waits[i]! >= ws.waits[i - 1]!,
73
+ `wait ${i} shrank (${ws.waits.join('ms, ')}ms)`
74
+ )
75
+ }
76
+ })
77
+
78
+ test('the wait never grows past the configured interval', async () => {
79
+ const ws = probe()
80
+ ws.endAfter = 12
81
+
82
+ await ws.runToCompletion('flow', {}, {} as any, { pollIntervalMs: 40 })
83
+
84
+ for (const wait of ws.waits) {
85
+ assert.ok(wait <= 40, `a wait of ${wait}ms overshot the 40ms ceiling`)
86
+ }
87
+ assert.equal(
88
+ ws.waits.at(-1),
89
+ 40,
90
+ `the ceiling was never reached (${ws.waits.join('ms, ')}ms)`
91
+ )
92
+ })
93
+
94
+ test('a run that is already finished is returned without any wait', async () => {
95
+ const ws = probe()
96
+ ws.endAfter = 1
97
+
98
+ await ws.runToCompletion('flow', {}, {} as any)
99
+
100
+ assert.equal(ws.reads.length, 1)
101
+ assert.deepEqual(ws.waits, [])
102
+ })
103
+ })
104
+
105
+ describe('a run that ends badly', () => {
106
+ test('a failed run surfaces its error message', async () => {
107
+ const ws = probe()
108
+ ws.getRun = async () =>
109
+ ({
110
+ id: 'run-1',
111
+ status: 'failed',
112
+ error: { message: 'step blew up' },
113
+ }) as WorkflowRun
114
+
115
+ await assert.rejects(ws.runToCompletion('flow', {}, {} as any), /blew up/)
116
+ })
117
+
118
+ test('a cancelled run rejects', async () => {
119
+ const ws = probe()
120
+ ws.getRun = async () =>
121
+ ({ id: 'run-1', status: 'cancelled' }) as WorkflowRun
122
+
123
+ await assert.rejects(ws.runToCompletion('flow', {}, {} as any))
124
+ })
125
+
126
+ test('a run that disappears rejects rather than polling forever', async () => {
127
+ const ws = probe()
128
+ ws.getRun = async () => null
129
+
130
+ await assert.rejects(ws.runToCompletion('flow', {}, {} as any), /run-1/)
131
+ })
132
+ })
@@ -19,7 +19,7 @@ describe('same step name invoked multiple times in one run', () => {
19
19
  test('each reach gets its own row + invocationId (no clobber)', async () => {
20
20
  const ws = inlineService()
21
21
  const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
22
- ;(ws as any).inlineRuns.add(runId)
22
+ ws.registerInlineRun(runId)
23
23
 
24
24
  // Two `do('process', fn)` reaches in the SAME replay (no reset between).
25
25
  const r1 = await (ws as any).inlineStep(runId, 'process', async () => 'first')
@@ -45,7 +45,7 @@ describe('same step name invoked multiple times in one run', () => {
45
45
  test('ordinal 0 is unchanged: a single call keeps the bare name + its old invocationId', async () => {
46
46
  const ws = inlineService()
47
47
  const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
48
- ;(ws as any).inlineRuns.add(runId)
48
+ ws.registerInlineRun(runId)
49
49
 
50
50
  await (ws as any).inlineStep(runId, 'solo', async () => 'x')
51
51
  // Stored under the bare name, and the dedupe key matches the pre-ordinal hash.
@@ -57,7 +57,7 @@ describe('same step name invoked multiple times in one run', () => {
57
57
  test('a fresh replay resets ordinals so the same call resolves to the same row', async () => {
58
58
  const ws = inlineService()
59
59
  const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
60
- ;(ws as any).inlineRuns.add(runId)
60
+ ws.registerInlineRun(runId)
61
61
 
62
62
  let runs = 0
63
63
  const r1 = await (ws as any).inlineStep(runId, 'once', async () => {
@@ -66,7 +66,7 @@ describe('same step name invoked multiple times in one run', () => {
66
66
  })
67
67
  // Next replay resets the counter; the same `do('once')` must hit the cached
68
68
  // row, not mint 'once#1'.
69
- ;(ws as any).resetStepOrdinals(runId)
69
+ await (ws as any).beginReplay(runId)
70
70
  const r2 = await (ws as any).inlineStep(runId, 'once', async () => {
71
71
  runs++
72
72
  return 'v2'
@@ -254,9 +254,6 @@ export interface WorkflowRunService {
254
254
  name: string,
255
255
  graphHash: string
256
256
  ): Promise<{ graph: any; source: string } | null>
257
- getAIGeneratedWorkflows(
258
- agentName?: string
259
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>>
260
257
  deleteRun(id: string): Promise<boolean>
261
258
  }
262
259
 
@@ -484,7 +481,7 @@ export interface WorkflowRuntimeMeta {
484
481
  /** Pikku function name (for execution) */
485
482
  pikkuFuncId: string
486
483
  /** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'scenario' (complex + actor steps) */
487
- source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | 'scenario'
484
+ source: 'dsl' | 'complex' | 'graph' | 'scenario'
488
485
  /** Optional description */
489
486
  description?: string
490
487
  /** Tags for organization */