@pikku/core 0.12.94 → 0.12.95

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 (49) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/services/email-template.d.ts +43 -0
  3. package/dist/services/email-template.js +139 -0
  4. package/dist/services/http-personas.d.ts +6 -1
  5. package/dist/services/http-personas.js +4 -1
  6. package/dist/services/index.d.ts +1 -0
  7. package/dist/services/index.js +1 -0
  8. package/dist/wirings/agent/agent-prepare.d.ts +14 -0
  9. package/dist/wirings/agent/agent-prepare.js +24 -0
  10. package/dist/wirings/agent/index.d.ts +1 -1
  11. package/dist/wirings/agent/index.js +1 -1
  12. package/dist/wirings/scheduler/scheduler-runner.js +0 -1
  13. package/dist/wirings/virtual-user/index.d.ts +1 -0
  14. package/dist/wirings/virtual-user/index.js +1 -0
  15. package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
  16. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
  17. package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
  18. package/dist/wirings/workflow/index.d.ts +1 -0
  19. package/dist/wirings/workflow/index.js +1 -0
  20. package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
  21. package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
  22. package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
  23. package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
  24. package/dist/wirings/workflow/workflow-status-stream.js +105 -0
  25. package/package.json +1 -1
  26. package/src/public-surface.json +17 -1
  27. package/src/services/email-template.test.ts +311 -0
  28. package/src/services/email-template.ts +254 -0
  29. package/src/services/http-personas.ts +10 -2
  30. package/src/services/index.ts +8 -0
  31. package/src/services/persona-sign-in.test.ts +22 -0
  32. package/src/wirings/agent/agent-helpers.test.ts +63 -0
  33. package/src/wirings/agent/agent-prepare.ts +25 -0
  34. package/src/wirings/agent/index.ts +1 -0
  35. package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
  36. package/src/wirings/scheduler/scheduler-runner.ts +0 -1
  37. package/src/wirings/virtual-user/index.ts +20 -0
  38. package/src/wirings/virtual-user/virtual-user-derive.test.ts +28 -0
  39. package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
  40. package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
  41. package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
  42. package/src/wirings/workflow/index.ts +4 -0
  43. package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
  44. package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
  45. package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
  46. package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
  47. package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
  48. package/src/wirings/workflow/workflow-status-stream.ts +144 -0
  49. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ }