@pikku/core 0.12.94 → 0.12.96

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 (62) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/dist/dev/hot-reload.js +24 -4
  3. package/dist/dev/module-runner.d.ts +20 -3
  4. package/dist/dev/module-runner.js +17 -4
  5. package/dist/services/email-template.d.ts +43 -0
  6. package/dist/services/email-template.js +139 -0
  7. package/dist/services/http-personas.d.ts +6 -1
  8. package/dist/services/http-personas.js +4 -1
  9. package/dist/services/index.d.ts +1 -0
  10. package/dist/services/index.js +1 -0
  11. package/dist/wirings/agent/agent-prepare.d.ts +14 -0
  12. package/dist/wirings/agent/agent-prepare.js +24 -0
  13. package/dist/wirings/agent/index.d.ts +1 -1
  14. package/dist/wirings/agent/index.js +1 -1
  15. package/dist/wirings/scheduler/scheduler-runner.js +0 -1
  16. package/dist/wirings/virtual-user/index.d.ts +1 -0
  17. package/dist/wirings/virtual-user/index.js +1 -0
  18. package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
  19. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
  20. package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
  21. package/dist/wirings/workflow/index.d.ts +1 -0
  22. package/dist/wirings/workflow/index.js +1 -0
  23. package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
  24. package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
  25. package/dist/wirings/workflow/scenario-prose.js +12 -3
  26. package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
  27. package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
  28. package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
  29. package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
  30. package/dist/wirings/workflow/workflow-status-stream.js +105 -0
  31. package/package.json +1 -1
  32. package/src/dev/hot-reload.test.ts +42 -0
  33. package/src/dev/hot-reload.ts +30 -4
  34. package/src/dev/module-runner.test.ts +56 -13
  35. package/src/dev/module-runner.ts +32 -10
  36. package/src/public-surface.json +17 -1
  37. package/src/services/email-template.test.ts +311 -0
  38. package/src/services/email-template.ts +254 -0
  39. package/src/services/http-personas.ts +10 -2
  40. package/src/services/index.ts +8 -0
  41. package/src/services/persona-sign-in.test.ts +22 -0
  42. package/src/wirings/agent/agent-helpers.test.ts +63 -0
  43. package/src/wirings/agent/agent-prepare.ts +25 -0
  44. package/src/wirings/agent/index.ts +1 -0
  45. package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
  46. package/src/wirings/scheduler/scheduler-runner.ts +0 -1
  47. package/src/wirings/virtual-user/index.ts +20 -0
  48. package/src/wirings/virtual-user/virtual-user-derive.test.ts +33 -5
  49. package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
  50. package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
  51. package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
  52. package/src/wirings/workflow/index.ts +4 -0
  53. package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
  54. package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
  55. package/src/wirings/workflow/scenario-prose.test.ts +134 -9
  56. package/src/wirings/workflow/scenario-prose.ts +37 -2
  57. package/src/wirings/workflow/scenario-run.types.ts +7 -0
  58. package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
  59. package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
  60. package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
  61. package/src/wirings/workflow/workflow-status-stream.ts +144 -0
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,354 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { streamWorkflowRunStatus } from './workflow-status-stream.js'
4
+ import type { WorkflowRunService } from './workflow.types.js'
5
+
6
+ const run = (over: Record<string, unknown> = {}) =>
7
+ ({
8
+ id: 'run-1',
9
+ workflow: 'checkout',
10
+ status: 'running',
11
+ input: {},
12
+ wire: {},
13
+ createdAt: new Date(),
14
+ updatedAt: new Date(),
15
+ ...over,
16
+ }) as any
17
+
18
+ const step = (stepName: string, status: string, over: any = {}) =>
19
+ ({ stepId: stepName, stepName, status, attemptCount: 1, ...over }) as any
20
+
21
+ /**
22
+ * Each poll is driven by a scripted sequence rather than a clock, so a test
23
+ * asserts what the stream sends without waiting for one.
24
+ */
25
+ const harness = (
26
+ polls: Array<{ run: any; steps: any[] }>,
27
+ { detailed = false, session = undefined as any } = {}
28
+ ) => {
29
+ const sent: any[] = []
30
+ let closed = false
31
+ let index = 0
32
+ const workflowRunService = {
33
+ getRun: async () => {
34
+ const frame = polls[Math.min(index, polls.length - 1)]
35
+ return frame!.run
36
+ },
37
+ getRunSteps: async () => {
38
+ const frame = polls[Math.min(index, polls.length - 1)]
39
+ index += 1
40
+ return frame!.steps
41
+ },
42
+ } as unknown as WorkflowRunService
43
+
44
+ return {
45
+ sent,
46
+ closed: () => closed,
47
+ stream: () =>
48
+ streamWorkflowRunStatus({
49
+ workflowRunService,
50
+ runId: 'run-1',
51
+ channel: {
52
+ send: async (data: any) => {
53
+ sent.push(data)
54
+ },
55
+ close: async () => {
56
+ closed = true
57
+ },
58
+ },
59
+ session,
60
+ detailed,
61
+ pollIntervalMs: 1,
62
+ }),
63
+ }
64
+ }
65
+
66
+ describe('streamWorkflowRunStatus', () => {
67
+ test('a run that is already finished never starts a timer', async () => {
68
+ const h = harness([
69
+ { run: run({ status: 'completed' }), steps: [step('a', 'succeeded')] },
70
+ ])
71
+ await h.stream()
72
+ assert.deepEqual(
73
+ h.sent.map((frame) => frame.type),
74
+ ['update', 'done']
75
+ )
76
+ assert.equal(h.closed(), true)
77
+ })
78
+
79
+ test('a run nobody can find closes the stream rather than hanging', async () => {
80
+ const h = harness([{ run: null, steps: [] }])
81
+ await h.stream()
82
+ assert.equal(h.sent.length, 0)
83
+ assert.equal(h.closed(), true)
84
+ })
85
+
86
+ // Checked on every poll, not just the first: a stream that outlives a session
87
+ // should stop rather than keep reporting.
88
+ test('a run belonging to someone else is refused', async () => {
89
+ const h = harness(
90
+ [{ run: run({ wire: { pikkuUserId: 'someone-else' } }), steps: [] }],
91
+ { session: { userId: 'me' } }
92
+ )
93
+ await assert.rejects(
94
+ h.stream(),
95
+ /Not authorized to access this workflow run/
96
+ )
97
+ })
98
+
99
+ // A deterministic run knows its whole shape up front, so the client can draw
100
+ // every step — including the ones not started — before anything runs.
101
+ test('a deterministic run sends its planned shape first', async () => {
102
+ const h = harness([
103
+ {
104
+ run: run({
105
+ status: 'completed',
106
+ deterministic: true,
107
+ plannedSteps: [{ stepName: 'a' }, { stepName: 'b' }],
108
+ }),
109
+ steps: [step('a', 'succeeded')],
110
+ },
111
+ ])
112
+ await h.stream()
113
+ const init = h.sent[0]
114
+ assert.equal(init.type, 'init')
115
+ assert.equal(init.deterministic, true)
116
+ assert.deepEqual(init.steps, [
117
+ { stepName: 'a', status: 'succeeded' },
118
+ // Planned but not started, which is the whole reason to send this frame.
119
+ { stepName: 'b', status: 'pending' },
120
+ ])
121
+ })
122
+
123
+ test('a dynamic run has no shape to announce, so it announces none', async () => {
124
+ const h = harness([
125
+ { run: run({ status: 'completed' }), steps: [step('a', 'succeeded')] },
126
+ ])
127
+ await h.stream()
128
+ assert.equal(
129
+ h.sent.some((frame) => frame.type === 'init'),
130
+ false
131
+ )
132
+ })
133
+
134
+ // A run sitting on a slow step should cost one message, not one per poll.
135
+ test('nothing is sent while nothing has changed', async () => {
136
+ const h = harness([
137
+ { run: run(), steps: [step('a', 'running')] },
138
+ { run: run(), steps: [step('a', 'running')] },
139
+ { run: run(), steps: [step('a', 'running')] },
140
+ {
141
+ run: run({ status: 'completed' }),
142
+ steps: [step('a', 'succeeded')],
143
+ },
144
+ ])
145
+ await h.stream()
146
+ assert.deepEqual(
147
+ h.sent.map((frame) => frame.type),
148
+ ['update', 'update', 'done']
149
+ )
150
+ })
151
+
152
+ test('every terminal status ends the stream', async () => {
153
+ for (const status of ['completed', 'failed', 'cancelled']) {
154
+ const h = harness([{ run: run({ status }), steps: [] }])
155
+ await h.stream()
156
+ assert.equal(h.sent.at(-1)!.type, 'done', status)
157
+ assert.equal(h.closed(), true, status)
158
+ }
159
+ })
160
+
161
+ // The whole difference between the two scaffolded routes. A workflow's output
162
+ // and its error messages are internal detail, and a step that spawned a child
163
+ // run says so only to tooling that can follow it.
164
+ test('the user-facing stream reports progress and nothing else', async () => {
165
+ const h = harness([
166
+ {
167
+ run: run({
168
+ status: 'failed',
169
+ output: { card: '4242' },
170
+ error: { message: 'declined at acquirer', name: 'Error' },
171
+ }),
172
+ steps: [step('a', 'failed', { childRunId: 'child-1' })],
173
+ },
174
+ ])
175
+ await h.stream()
176
+ const update = h.sent[0]
177
+ assert.equal(update.status, 'failed')
178
+ assert.equal('output' in update, false)
179
+ assert.equal('error' in update, false)
180
+ assert.deepEqual(update.steps, [{ stepName: 'a', status: 'failed' }])
181
+ })
182
+
183
+ test('the detailed stream carries what the run produced', async () => {
184
+ const h = harness(
185
+ [
186
+ {
187
+ run: run({
188
+ status: 'failed',
189
+ output: { card: '4242' },
190
+ error: { message: 'declined at acquirer', name: 'Error' },
191
+ }),
192
+ steps: [step('a', 'failed', { childRunId: 'child-1' })],
193
+ },
194
+ ],
195
+ { detailed: true }
196
+ )
197
+ await h.stream()
198
+ const update = h.sent[0]
199
+ assert.deepEqual(update.output, { card: '4242' })
200
+ assert.equal(update.error.message, 'declined at acquirer')
201
+ assert.deepEqual(update.steps, [
202
+ { stepName: 'a', status: 'failed', childRunId: 'child-1' },
203
+ ])
204
+ })
205
+
206
+ // The detailed stream compares output too, so a run whose steps are unchanged
207
+ // but whose output has moved on still reports it.
208
+ test('a change only the detailed stream can see still reaches it', async () => {
209
+ const frames = [
210
+ { run: run({ output: { progress: 1 } }), steps: [step('a', 'running')] },
211
+ { run: run({ output: { progress: 2 } }), steps: [step('a', 'running')] },
212
+ {
213
+ run: run({ status: 'completed', output: { progress: 2 } }),
214
+ steps: [step('a', 'succeeded')],
215
+ },
216
+ ]
217
+ const quiet = harness(frames)
218
+ await quiet.stream()
219
+ assert.equal(
220
+ quiet.sent.filter((frame) => frame.type === 'update').length,
221
+ 2
222
+ )
223
+
224
+ const loud = harness(frames, { detailed: true })
225
+ await loud.stream()
226
+ assert.equal(loud.sent.filter((frame) => frame.type === 'update').length, 3)
227
+ })
228
+
229
+ // Without this the throw is an unhandled rejection from a timer callback,
230
+ // which takes the process with it rather than failing the request.
231
+ test('a poll that throws after the first stops the stream, loudly', async () => {
232
+ let calls = 0
233
+ await assert.rejects(
234
+ streamWorkflowRunStatus({
235
+ workflowRunService: {
236
+ getRun: async () => {
237
+ calls += 1
238
+ if (calls > 1) {
239
+ throw new Error('the database went away')
240
+ }
241
+ return run()
242
+ },
243
+ getRunSteps: async () => [],
244
+ } as unknown as WorkflowRunService,
245
+ runId: 'run-1',
246
+ channel: { send: async () => {}, close: async () => {} },
247
+ session: undefined,
248
+ pollIntervalMs: 1,
249
+ }),
250
+ /the database went away/
251
+ )
252
+ })
253
+ })
254
+
255
+ /**
256
+ * A stream that throws still has a channel open on the other end. The owner
257
+ * check runs on every poll precisely so a session that loses access stops the
258
+ * stream — which is only true if stopping also closes it.
259
+ */
260
+ describe('streamWorkflowRunStatus closes the channel when a poll throws', () => {
261
+ const failingStream = (failOn: number) => {
262
+ let closed = false
263
+ let polls = 0
264
+ const workflowRunService = {
265
+ getRun: async () => {
266
+ polls += 1
267
+ if (polls >= failOn) {
268
+ throw new Error('run store unavailable')
269
+ }
270
+ return run()
271
+ },
272
+ getRunSteps: async () => [step('charge', 'running')],
273
+ } as unknown as WorkflowRunService
274
+
275
+ return {
276
+ closed: () => closed,
277
+ stream: () =>
278
+ streamWorkflowRunStatus({
279
+ workflowRunService,
280
+ runId: 'run-1',
281
+ channel: {
282
+ send: async () => {},
283
+ close: async () => {
284
+ closed = true
285
+ },
286
+ },
287
+ session: undefined as any,
288
+ pollIntervalMs: 1,
289
+ }),
290
+ }
291
+ }
292
+
293
+ test('the first poll throwing closes the channel and rethrows', async () => {
294
+ const h = failingStream(1)
295
+ await assert.rejects(h.stream(), /run store unavailable/)
296
+ assert.equal(h.closed(), true, 'a throw must not leave the channel open')
297
+ })
298
+
299
+ test('a later poll throwing closes the channel and rethrows', async () => {
300
+ const h = failingStream(3)
301
+ await assert.rejects(h.stream(), /run store unavailable/)
302
+ assert.equal(h.closed(), true, 'a throw must not leave the channel open')
303
+ })
304
+ })
305
+
306
+ /**
307
+ * The poll used to be on a fixed interval, which fires whether or not the
308
+ * previous one has come back. Two in flight at once both see `initSent` unset
309
+ * and send the init frame twice.
310
+ */
311
+ describe('streamWorkflowRunStatus never runs two polls at once', () => {
312
+ test('a poll slower than the interval does not overlap the next', async () => {
313
+ const sent: any[] = []
314
+ let inFlight = 0
315
+ let overlapped = false
316
+ let polls = 0
317
+
318
+ const workflowRunService = {
319
+ getRun: async () => {
320
+ inFlight += 1
321
+ if (inFlight > 1) overlapped = true
322
+ await new Promise((r) => setTimeout(r, 15))
323
+ polls += 1
324
+ inFlight -= 1
325
+ return run({
326
+ status: polls >= 3 ? 'completed' : 'running',
327
+ deterministic: true,
328
+ plannedSteps: [{ stepName: 'charge' }],
329
+ })
330
+ },
331
+ getRunSteps: async () => [step('charge', 'running')],
332
+ } as unknown as WorkflowRunService
333
+
334
+ await streamWorkflowRunStatus({
335
+ workflowRunService,
336
+ runId: 'run-1',
337
+ channel: {
338
+ send: async (d: any) => {
339
+ sent.push(d)
340
+ },
341
+ close: async () => {},
342
+ },
343
+ session: undefined as any,
344
+ pollIntervalMs: 1,
345
+ })
346
+
347
+ assert.equal(overlapped, false, 'two polls must never be in flight together')
348
+ assert.equal(
349
+ sent.filter((f) => f.type === 'init').length,
350
+ 1,
351
+ 'the init frame is sent exactly once'
352
+ )
353
+ })
354
+ })
@@ -0,0 +1,144 @@
1
+ import type { CoreUserSession } from '../../types/core.types.js'
2
+ import type { PikkuChannel } from '../channel/channel.types.js'
3
+ import { assertWorkflowRunOwner } from './workflow-run-ownership.js'
4
+ import type { WorkflowRunService, WorkflowStatus } from './workflow.types.js'
5
+
6
+ /**
7
+ * The status stream behind the scaffolded workflow SSE routes.
8
+ *
9
+ * Two routes share it, and they differ by one thing: whether the caller is
10
+ * trusted with what the run produced. A user-facing frontend gets step names
11
+ * and statuses; an admin console also gets the output, the error and the child
12
+ * run ids. That is a parameter, not a second copy of the loop.
13
+ */
14
+
15
+ const TERMINAL: ReadonlySet<string> = new Set<WorkflowStatus>([
16
+ 'completed',
17
+ 'failed',
18
+ 'cancelled',
19
+ ])
20
+
21
+ const DEFAULT_POLL_INTERVAL_MS = 500
22
+
23
+ export interface WorkflowStatusStreamParams {
24
+ workflowRunService: WorkflowRunService
25
+ runId: string
26
+ channel: Pick<PikkuChannel<unknown, any>, 'send' | 'close'>
27
+ session: CoreUserSession | undefined
28
+ /**
29
+ * Whether to include what the run produced. Off for the user-facing route:
30
+ * a workflow's output and its error messages are internal detail, and a step
31
+ * that spawned a child run says so only to tooling that can follow it.
32
+ */
33
+ detailed?: boolean
34
+ pollIntervalMs?: number
35
+ }
36
+
37
+ /**
38
+ * Streams one run's progress until it reaches a terminal state.
39
+ *
40
+ * Polled rather than subscribed because a run's steps are written by whichever
41
+ * worker picked them up, in whichever process — there is no in-memory event to
42
+ * listen for that every deployment shape would deliver.
43
+ *
44
+ * Each poll sends only when something changed, compared by a hash of exactly
45
+ * what this stream reports. A run that sits on a slow step for a minute costs
46
+ * one message, not a hundred and twenty.
47
+ */
48
+ export const streamWorkflowRunStatus = async ({
49
+ workflowRunService,
50
+ runId,
51
+ channel,
52
+ session,
53
+ detailed = false,
54
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
55
+ }: WorkflowStatusStreamParams): Promise<void> => {
56
+ let lastHash = ''
57
+ let initSent = false
58
+
59
+ const poll = async (): Promise<boolean> => {
60
+ const run = await workflowRunService.getRun(runId)
61
+ if (!run) {
62
+ await channel.close()
63
+ return false
64
+ }
65
+ // Checked on every poll, not just the first: ownership is read from the run
66
+ // itself, and a stream that outlives a session should stop rather than keep
67
+ // reporting.
68
+ assertWorkflowRunOwner(run.wire, session)
69
+
70
+ const steps = await workflowRunService.getRunSteps(runId)
71
+
72
+ // A deterministic run knows its whole shape up front, so the client can
73
+ // draw every step — including the ones not started — before anything runs.
74
+ // A dynamic run has nothing to send here, and gets no init frame.
75
+ if (!initSent && run.deterministic) {
76
+ const statusByStep = new Map(
77
+ steps.map((step) => [step.stepName, step.status])
78
+ )
79
+ await channel.send({
80
+ type: 'init',
81
+ deterministic: true,
82
+ steps: (run.plannedSteps ?? []).map((step) => ({
83
+ stepName: step.stepName,
84
+ status: statusByStep.get(step.stepName) ?? 'pending',
85
+ })),
86
+ })
87
+ initSent = true
88
+ }
89
+
90
+ const hash = JSON.stringify({
91
+ s: run.status,
92
+ ...(detailed ? { o: run.output } : {}),
93
+ steps: steps.map((step) => [step.stepName, step.status]),
94
+ })
95
+
96
+ if (hash !== lastHash) {
97
+ lastHash = hash
98
+ await channel.send({
99
+ type: 'update',
100
+ status: run.status,
101
+ ...(detailed ? { output: run.output, error: run.error } : {}),
102
+ steps: steps.map((step) => ({
103
+ stepName: step.stepName,
104
+ status: step.status,
105
+ ...(detailed && step.childRunId
106
+ ? { childRunId: step.childRunId }
107
+ : {}),
108
+ })),
109
+ })
110
+ }
111
+
112
+ if (TERMINAL.has(run.status)) {
113
+ await channel.send({ type: 'done' })
114
+ await channel.close()
115
+ return false
116
+ }
117
+ return true
118
+ }
119
+
120
+ // Every exit from here closes the channel, including the ones a throw takes:
121
+ // `assertWorkflowRunOwner` rejecting a session that lost access is exactly
122
+ // the case where the stream should end rather than be left hanging open.
123
+ try {
124
+ // A run that is already finished is answered without ever starting a timer.
125
+ if (!(await poll())) {
126
+ return
127
+ }
128
+
129
+ // The next poll is scheduled when the previous one resolves rather than on
130
+ // a fixed interval. A timer that fires regardless would let two polls
131
+ // overlap on a slow store — both seeing `initSent` unset and sending the
132
+ // init frame twice, and racing `lastHash` into out-of-order updates.
133
+ while (
134
+ await new Promise<boolean>((resolve, reject) => {
135
+ setTimeout(() => void poll().then(resolve, reject), pollIntervalMs)
136
+ })
137
+ ) {
138
+ // The condition is the whole loop: poll until it says to stop.
139
+ }
140
+ } catch (error) {
141
+ await channel.close()
142
+ throw error
143
+ }
144
+ }