@pikku/core 0.12.78 → 0.12.79

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.
@@ -8,6 +8,7 @@ import {
8
8
  addGlobalPermission,
9
9
  clearPermissionsCache,
10
10
  } from '../../permissions.js'
11
+ import { addTagMiddleware } from '../../middleware-runner.js'
11
12
  import type {
12
13
  GatewayAdapter,
13
14
  GatewayInboundMessage,
@@ -84,6 +85,31 @@ const seedCompiledMeta = () => {
84
85
  }
85
86
  }
86
87
 
88
+ /**
89
+ * What the inspector records for a gateway and the function it was wired with.
90
+ * These tests wire gateways by hand, so nothing populates it otherwise — and it
91
+ * has to be in place before `wireGateway`, exactly as the generated bootstrap
92
+ * loads every meta file before any wiring file.
93
+ */
94
+ const seedDeclaredHandler = (
95
+ gatewayName: string,
96
+ funcId: string,
97
+ funcMeta: Record<string, any>,
98
+ gatewayMeta: Record<string, any> = {}
99
+ ) => {
100
+ ;(pikkuState(null, 'function', 'meta') as any)[funcId] = {
101
+ pikkuFuncId: funcId,
102
+ inputSchemaName: null,
103
+ outputSchemaName: null,
104
+ ...funcMeta,
105
+ }
106
+ ;(pikkuState(null, 'gateway', 'meta') as any)[gatewayName] = {
107
+ pikkuFuncId: funcId,
108
+ name: gatewayName,
109
+ ...gatewayMeta,
110
+ }
111
+ }
112
+
87
113
  const postMessage = async (route: string) => {
88
114
  const request = new Request(`http://localhost${route}`, {
89
115
  method: 'POST',
@@ -229,6 +255,68 @@ describe('gateway handler authorization', () => {
229
255
  assert.deepEqual(calls, ['ran'])
230
256
  })
231
257
 
258
+ test('a handler declared with pikkuFunc keeps its session requirement', async () => {
259
+ const calls: string[] = []
260
+
261
+ seedDeclaredHandler('declared-session', 'myHandler', {
262
+ sessionless: false,
263
+ })
264
+
265
+ wireGateway({
266
+ name: 'declared-session',
267
+ type: 'webhook',
268
+ route: '/webhooks/declared-session',
269
+ adapter: createMockAdapter(),
270
+ func: {
271
+ func: async () => {
272
+ calls.push('ran')
273
+ return { text: 'reply' }
274
+ },
275
+ } as any,
276
+ })
277
+
278
+ seedCompiledMeta()
279
+ httpRouter.initialize()
280
+
281
+ const response = await postMessage('/webhooks/declared-session')
282
+
283
+ assert.equal(response.status, 403)
284
+ assert.deepEqual(
285
+ calls,
286
+ [],
287
+ 'a session-required pikkuFunc must not be silently made sessionless'
288
+ )
289
+ })
290
+
291
+ test('a handler declared sessionless keeps running without a session', async () => {
292
+ const calls: string[] = []
293
+
294
+ seedDeclaredHandler('declared-sessionless', 'mySessionlessHandler', {
295
+ sessionless: true,
296
+ })
297
+
298
+ wireGateway({
299
+ name: 'declared-sessionless',
300
+ type: 'webhook',
301
+ route: '/webhooks/declared-sessionless',
302
+ adapter: createMockAdapter(),
303
+ func: {
304
+ func: async () => {
305
+ calls.push('ran')
306
+ return { text: 'reply' }
307
+ },
308
+ } as any,
309
+ })
310
+
311
+ seedCompiledMeta()
312
+ httpRouter.initialize()
313
+
314
+ const response = await postMessage('/webhooks/declared-sessionless')
315
+
316
+ assert.equal(response.status, 200)
317
+ assert.deepEqual(calls, ['ran'])
318
+ })
319
+
232
320
  test('gateway-level auth: true requires a session', async () => {
233
321
  const calls: string[] = []
234
322
 
@@ -281,6 +369,49 @@ describe('gateway handler authorization', () => {
281
369
  assert.deepEqual(calls, [], 'auth: true must require a session')
282
370
  })
283
371
 
372
+ test('tag middleware declared for the gateway actually runs', async () => {
373
+ const order: string[] = []
374
+
375
+ addTagMiddleware('audited', [
376
+ async (_s: any, _wire: any, next: any) => {
377
+ order.push('middleware')
378
+ await next()
379
+ },
380
+ ] as any)
381
+
382
+ seedDeclaredHandler(
383
+ 'tagged',
384
+ 'myTaggedHandler',
385
+ { sessionless: true },
386
+ { tags: ['audited'], middleware: [{ type: 'tag', tag: 'audited' }] }
387
+ )
388
+
389
+ wireGateway({
390
+ name: 'tagged',
391
+ type: 'webhook',
392
+ route: '/webhooks/tagged',
393
+ adapter: createMockAdapter(),
394
+ func: {
395
+ func: async () => {
396
+ order.push('handler')
397
+ return { text: 'reply' }
398
+ },
399
+ } as any,
400
+ })
401
+
402
+ seedCompiledMeta()
403
+ httpRouter.initialize()
404
+
405
+ const response = await postMessage('/webhooks/tagged')
406
+
407
+ assert.equal(response.status, 200)
408
+ assert.deepEqual(
409
+ order,
410
+ ['middleware', 'handler'],
411
+ 'addTagMiddleware must gate a gateway carrying the tag'
412
+ )
413
+ })
414
+
284
415
  test('the adapter still auto-sends the handler reply', async () => {
285
416
  const adapter = createMockAdapter()
286
417
 
@@ -23,20 +23,46 @@ const bridgeMiddlewareSession = async (wire: PikkuRawWire): Promise<void> => {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Metadata the inspector recorded for the function the gateway was wired with.
28
+ * The bootstrap loads every meta file before any wiring file, so this is
29
+ * already populated by the time a gateway wires itself. A gateway wired by
30
+ * hand rather than through codegen has no entry, and falls back to the
31
+ * sessionless default below.
32
+ */
33
+ const declaredHandlerMeta = (config: CoreGateway) => {
34
+ const declaredFuncId = pikkuState(null, 'gateway', 'meta')[config.name]
35
+ ?.pikkuFuncId
36
+ return declaredFuncId
37
+ ? pikkuState(null, 'function', 'meta')[declaredFuncId]
38
+ : undefined
39
+ }
40
+
26
41
  // knowledge: decisions/security/gateway-handlers-run-through-the-function-runner-gate.md
27
42
  const registerGatewayHandler = (config: CoreGateway): string => {
28
43
  const funcId = gatewayHandlerFuncId(config.name)
29
44
  const funcMeta = pikkuState(null, 'function', 'meta')
45
+ const declared = declaredHandlerMeta(config)
30
46
  funcMeta[funcId] = {
47
+ ...declared,
31
48
  pikkuFuncId: funcId,
32
- inputSchemaName: null,
33
- outputSchemaName: null,
34
- sessionless: true,
49
+ inputSchemaName: declared?.inputSchemaName ?? null,
50
+ outputSchemaName: declared?.outputSchemaName ?? null,
51
+ sessionless: declared?.sessionless ?? true,
35
52
  }
36
53
  addFunction(funcId, config.func as any)
37
54
  return funcId
38
55
  }
39
56
 
57
+ /**
58
+ * Tag middleware the inspector resolved for this gateway. It is keyed by
59
+ * gateway name rather than reachable from `config`, because `tags` is a
60
+ * compile-time input everywhere — nothing at runtime maps a tag to its
61
+ * middleware group.
62
+ */
63
+ const gatewayInheritedMiddleware = (config: CoreGateway) =>
64
+ pikkuState(null, 'gateway', 'meta')[config.name]?.middleware
65
+
40
66
  export const resolveGatewayAdapter = (
41
67
  config: CoreGateway,
42
68
  services: CoreSingletonServices
@@ -134,6 +160,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
134
160
  const createWebhookPostHandler = (config: CoreGateway) => {
135
161
  const { name, middleware: userMiddleware } = config
136
162
  const handlerFuncId = registerGatewayHandler(config)
163
+ const inheritedMiddleware = gatewayInheritedMiddleware(config)
137
164
 
138
165
  return async (
139
166
  services: CoreSingletonServices,
@@ -169,6 +196,7 @@ const createWebhookPostHandler = (config: CoreGateway) => {
169
196
  singletonServices: services,
170
197
  data: () => parsed,
171
198
  auth: config.auth,
199
+ inheritedMiddleware,
172
200
  wire: wire as any,
173
201
  })
174
202
  }
@@ -253,6 +281,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
253
281
 
254
282
  const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
255
283
  const handlerFuncId = registerGatewayHandler(config)
284
+ const inheritedMiddleware = gatewayInheritedMiddleware(config)
256
285
 
257
286
  addFunction(connectFuncId, {
258
287
  auth: false,
@@ -292,6 +321,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
292
321
  singletonServices: services,
293
322
  data: () => parsed,
294
323
  auth: config.auth,
324
+ inheritedMiddleware,
295
325
  wire: wire as any,
296
326
  })
297
327
  }
@@ -333,6 +363,7 @@ export const createListenerMessageHandler = (
333
363
  ): ((rawData: unknown) => Promise<void>) => {
334
364
  const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
335
365
  const handlerFuncId = registerGatewayHandler(config)
366
+ const inheritedMiddleware = gatewayInheritedMiddleware(config)
336
367
 
337
368
  return async (rawData: unknown): Promise<void> => {
338
369
  const adapter = await resolveGatewayAdapter(config, singletonServices)
@@ -354,6 +385,7 @@ export const createListenerMessageHandler = (
354
385
  singletonServices,
355
386
  data: () => parsed,
356
387
  auth: config.auth,
388
+ inheritedMiddleware,
357
389
  wire,
358
390
  })
359
391
  }
@@ -108,6 +108,53 @@ describe('validateAndBuildSecretDefinitionsMeta', () => {
108
108
  )
109
109
  })
110
110
 
111
+ test('should carry allowedHosts into the meta', () => {
112
+ const definitions = [
113
+ {
114
+ name: 'example-api',
115
+ displayName: 'Example API',
116
+ secretId: 'EXAMPLE_API_CREDENTIALS',
117
+ allowedHosts: ['api.example.com', '*.example.com'],
118
+ sourceFile: 'a.ts',
119
+ },
120
+ ]
121
+ const result = validateAndBuildSecretDefinitionsMeta(
122
+ definitions as any,
123
+ new Map()
124
+ )
125
+ assert.deepStrictEqual(result['example-api']!.allowedHosts, [
126
+ 'api.example.com',
127
+ '*.example.com',
128
+ ])
129
+ })
130
+
131
+ test('should carry allowedHosts into the meta for a shared secretId', () => {
132
+ const definitions = [
133
+ {
134
+ name: 'cred1',
135
+ displayName: 'Cred 1',
136
+ secretId: 'SHARED',
137
+ allowedHosts: ['first.example.com'],
138
+ sourceFile: 'a.ts',
139
+ },
140
+ {
141
+ name: 'cred2',
142
+ displayName: 'Cred 2',
143
+ secretId: 'SHARED',
144
+ allowedHosts: ['second.example.com'],
145
+ sourceFile: 'b.ts',
146
+ },
147
+ ]
148
+ const result = validateAndBuildSecretDefinitionsMeta(
149
+ definitions as any,
150
+ new Map()
151
+ )
152
+ assert.deepStrictEqual(result['cred1']!.allowedHosts, ['first.example.com'])
153
+ assert.deepStrictEqual(result['cred2']!.allowedHosts, [
154
+ 'second.example.com',
155
+ ])
156
+ })
157
+
111
158
  test('should handle empty definitions', () => {
112
159
  const result = validateAndBuildSecretDefinitionsMeta([], new Map())
113
160
  assert.deepStrictEqual(result, {})
@@ -57,6 +57,7 @@ export function validateAndBuildSecretDefinitionsMeta(
57
57
  oauth2: def.oauth2,
58
58
  rotationPeriod: def.rotationPeriod,
59
59
  docsUrl: def.docsUrl,
60
+ allowedHosts: def.allowedHosts,
60
61
  sourceFile: def.sourceFile,
61
62
  }
62
63
  }
@@ -75,6 +76,7 @@ export function validateAndBuildSecretDefinitionsMeta(
75
76
  oauth2: def.oauth2,
76
77
  rotationPeriod: def.rotationPeriod,
77
78
  docsUrl: def.docsUrl,
79
+ allowedHosts: def.allowedHosts,
78
80
  sourceFile: def.sourceFile,
79
81
  }
80
82
  }
@@ -281,6 +281,25 @@ const DEFAULT_STALLED_RUN_MS = 5 * 60_000
281
281
  /** Runs re-driven per `recoverStalledRuns` call, so one sweep is bounded. */
282
282
  const DEFAULT_STALLED_RUN_LIMIT = 100
283
283
 
284
+ /**
285
+ * How long a step may sit `pending` before the relay assumes its dispatch was
286
+ * lost. This is a bet that no healthy queue takes this long to move a job from
287
+ * `pending` to `running`; set it above the observed p99 of that latency.
288
+ */
289
+ const DEFAULT_UNDISPATCHED_STEP_MS = 30_000
290
+
291
+ /** Steps re-driven per `relayUndispatchedSteps` call, so one tick is bounded. */
292
+ const DEFAULT_UNDISPATCHED_STEP_LIMIT = 100
293
+
294
+ /** First wait before a step already re-dispatched once is re-dispatched again. */
295
+ const REDISPATCH_BACKOFF_MS = 30_000
296
+
297
+ /** Ceiling on the doubling backoff, so a permanently stuck step still gets swept. */
298
+ const REDISPATCH_BACKOFF_MAX_MS = 10 * 60_000
299
+
300
+ /** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
301
+ const REDISPATCH_BACKOFF_MAX_ENTRIES = 10_000
302
+
284
303
  const WORKFLOW_POLL_MIN_MS = 10
285
304
 
286
305
  const WORKFLOW_POLL_FACTOR = 1.6
@@ -975,6 +994,118 @@ export abstract class PikkuWorkflowService implements WorkflowService {
975
994
  return { resumed }
976
995
  }
977
996
 
997
+ /**
998
+ * Runs holding a step that has sat `pending` since before `before`, paired
999
+ * with the step that flagged them.
1000
+ *
1001
+ * Returns nothing by default so a store that cannot express the query keeps
1002
+ * working unchanged — and, because it does not opt in, gains no re-dispatches
1003
+ * either. A store must have an atomic `withStepLock` before overriding this,
1004
+ * or no concurrency for one to exclude: the relay makes duplicate dispatch
1005
+ * routine, and the claim in `executeWorkflowStepInner` is what keeps a
1006
+ * duplicate from becoming a second execution. `kysely-postgres` and
1007
+ * `kysely-mysql` qualify on the lock, `in-memory` on being inline and
1008
+ * single-process; `mongodb` and `kysely-sqlite` qualify on neither.
1009
+ */
1010
+ protected async findUndispatchedSteps(
1011
+ _before: Date,
1012
+ _limit: number
1013
+ ): Promise<Array<{ runId: string; stepId: string }>> {
1014
+ return []
1015
+ }
1016
+
1017
+ /**
1018
+ * Re-drive steps whose dispatch was lost, and report which runs were nudged.
1019
+ *
1020
+ * Arming a step is two writes to two systems: the step row lands `pending`,
1021
+ * then a queue or scheduler job is published. Nothing spans both, so a crash
1022
+ * in between leaves a durable row that nothing will ever pick up — the run
1023
+ * neither finishes nor fails. (Seen on a `workflow.sleep()`: a deploy restart
1024
+ * landed between the sleep step's insert and its timer.)
1025
+ *
1026
+ * The row is the outbox record and this is the relay. Age is the only signal
1027
+ * available — a step `pending` because its dispatch was lost is
1028
+ * indistinguishable from one whose job is merely still queued — so a step
1029
+ * past `undispatchedAfterMs` is re-dispatched regardless, and correctness
1030
+ * rests on the claim rather than on the guess being right. A redundant
1031
+ * dispatch costs one queue message: the loser reads `running` and returns
1032
+ * without invoking anything.
1033
+ *
1034
+ * Re-dispatches back off per step (doubling from 30s, capped at 10m) so a
1035
+ * genuine queue backlog is not amplified by a tick that keeps firing at the
1036
+ * steps the backlog is already delaying. The backoff is per process and
1037
+ * advisory — losing it on restart costs extra dispatches, never correctness.
1038
+ *
1039
+ * This is not self-starting. Call it from a scheduled task; ~30s suits a
1040
+ * queue whose `pending`→`running` latency is well under that.
1041
+ */
1042
+ public async relayUndispatchedSteps(options?: {
1043
+ undispatchedAfterMs?: number
1044
+ limit?: number
1045
+ }): Promise<{ redispatched: string[] }> {
1046
+ const before = new Date(
1047
+ Date.now() -
1048
+ (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS)
1049
+ )
1050
+ const steps = await this.findUndispatchedSteps(
1051
+ before,
1052
+ options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT
1053
+ )
1054
+
1055
+ // Backoff is keyed by run, not step, because the run is the unit of
1056
+ // re-drive: `resumeWorkflow` replays the whole run and re-dispatches every
1057
+ // step still owed a job. Holding off a single step while resuming its run
1058
+ // would not suppress anything.
1059
+ const now = Date.now()
1060
+ const runIds = new Set<string>()
1061
+ for (const { runId } of steps) {
1062
+ const eligibleAt = this.redispatchBackoff.get(runId)
1063
+ if (eligibleAt !== undefined && eligibleAt > now) {
1064
+ continue
1065
+ }
1066
+ this.noteRedispatch(runId, now)
1067
+ runIds.add(runId)
1068
+ }
1069
+
1070
+ const redispatched: string[] = []
1071
+ for (const runId of runIds) {
1072
+ try {
1073
+ await this.resumeWorkflow(runId)
1074
+ redispatched.push(runId)
1075
+ } catch (err) {
1076
+ // One unresumable run must not stop the tick from relaying the rest.
1077
+ getSingletonServices()?.logger?.error(
1078
+ `Failed to re-dispatch workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`
1079
+ )
1080
+ }
1081
+ }
1082
+ return { redispatched }
1083
+ }
1084
+
1085
+ /** Advisory, per-process record of when a run may next be re-dispatched. */
1086
+ private readonly redispatchBackoff = new Map<string, number>()
1087
+
1088
+ private readonly redispatchDelays = new Map<string, number>()
1089
+
1090
+ private noteRedispatch(runId: string, now: number): void {
1091
+ const previous = this.redispatchDelays.get(runId)
1092
+ const delay = Math.min(
1093
+ previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2,
1094
+ REDISPATCH_BACKOFF_MAX_MS
1095
+ )
1096
+ // A run that settles is never returned again, so entries are only evicted by
1097
+ // this bound — oldest first, which is also least recently re-dispatched.
1098
+ if (this.redispatchBackoff.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
1099
+ const oldest = this.redispatchBackoff.keys().next()
1100
+ if (!oldest.done) {
1101
+ this.redispatchBackoff.delete(oldest.value)
1102
+ this.redispatchDelays.delete(oldest.value)
1103
+ }
1104
+ }
1105
+ this.redispatchDelays.set(runId, delay)
1106
+ this.redispatchBackoff.set(runId, now + delay)
1107
+ }
1108
+
978
1109
  protected resolveStepJobOptions(
979
1110
  stepOptions?: WorkflowStepOptions
980
1111
  ): JobOptions {
@@ -0,0 +1,128 @@
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
+ /**
10
+ * `relayUndispatchedSteps` re-drives a run by putting it back on the
11
+ * orchestrator queue, so the queue is the observation point.
12
+ */
13
+ function trackResumes(): { runIds: string[] } {
14
+ const seen: string[] = []
15
+ pikkuState(null, 'package', 'singletonServices', {
16
+ queueService: {
17
+ add: async (_queue: string, data: { runId: string }) => {
18
+ seen.push(data.runId)
19
+ },
20
+ },
21
+ logger: silentLogger,
22
+ } as any)
23
+ return { runIds: seen }
24
+ }
25
+
26
+ /** Backdates a step's timestamp so it reads as undispatched. */
27
+ async function backdateSteps(
28
+ ws: InMemoryWorkflowService,
29
+ runId: string,
30
+ ms: number
31
+ ): Promise<void> {
32
+ const past = new Date(Date.now() - ms)
33
+ for (const step of await ws.getRunHistory(runId)) {
34
+ ;(step as any).updatedAt = past
35
+ }
36
+ }
37
+
38
+ describe('undispatched step relay', () => {
39
+ test('re-dispatches a step whose arming was lost', async () => {
40
+ const resumes = trackResumes()
41
+ const ws = new InMemoryWorkflowService()
42
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
43
+ type: 'test',
44
+ })
45
+ // The row committed; the process died before the timer was armed.
46
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
47
+ await backdateSteps(ws, runId, 60_000)
48
+
49
+ const { redispatched } = await ws.relayUndispatchedSteps()
50
+
51
+ assert.deepEqual(redispatched, [runId], 'the run is re-dispatched')
52
+ assert.deepEqual(resumes.runIds, [runId], 'it is put back on the queue')
53
+ })
54
+
55
+ test('leaves a freshly written step alone', async () => {
56
+ const resumes = trackResumes()
57
+ const ws = new InMemoryWorkflowService()
58
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
59
+ type: 'test',
60
+ })
61
+ // Written milliseconds ago — its dispatch is almost certainly in flight.
62
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
63
+
64
+ const { redispatched } = await ws.relayUndispatchedSteps()
65
+
66
+ assert.deepEqual(redispatched, [], 'nothing is re-dispatched')
67
+ assert.deepEqual(resumes.runIds, [], 'nothing is queued')
68
+ })
69
+
70
+ test('backs off rather than re-dispatching the same step every tick', async () => {
71
+ const resumes = trackResumes()
72
+ const ws = new InMemoryWorkflowService()
73
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
74
+ type: 'test',
75
+ })
76
+ await ws.insertStepState(runId, 'Charge card', 'charge', {})
77
+ await backdateSteps(ws, runId, 60_000)
78
+
79
+ const first = await ws.relayUndispatchedSteps()
80
+ // The step is still pending — a real queue backlog looks exactly like this.
81
+ const second = await ws.relayUndispatchedSteps()
82
+
83
+ assert.deepEqual(first.redispatched, [runId], 'the first tick relays it')
84
+ assert.deepEqual(
85
+ second.redispatched,
86
+ [],
87
+ 'the next tick is held off by the backoff'
88
+ )
89
+ assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
90
+ })
91
+
92
+ test('ignores steps belonging to a settled run', async () => {
93
+ const resumes = trackResumes()
94
+ const ws = new InMemoryWorkflowService()
95
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
96
+ type: 'test',
97
+ })
98
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
99
+ await backdateSteps(ws, runId, 60_000)
100
+ await ws.updateRunStatus(runId, 'completed', {})
101
+
102
+ const { redispatched } = await ws.relayUndispatchedSteps()
103
+
104
+ assert.deepEqual(redispatched, [], 'a finished run is not re-dispatched')
105
+ assert.deepEqual(resumes.runIds, [], 'nothing is queued')
106
+ })
107
+
108
+ test('re-dispatches each stuck run once per tick', async () => {
109
+ const resumes = trackResumes()
110
+ const ws = new InMemoryWorkflowService()
111
+ const runIds: string[] = []
112
+ for (const n of [1, 2]) {
113
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
114
+ type: 'test',
115
+ })
116
+ // Two orphaned steps in one run must still produce a single resume.
117
+ await ws.insertStepState(runId, `Wait ${n}a`, null, { duration: 1000 })
118
+ await ws.insertStepState(runId, `Wait ${n}b`, null, { duration: 1000 })
119
+ await backdateSteps(ws, runId, 60_000)
120
+ runIds.push(runId)
121
+ }
122
+
123
+ const { redispatched } = await ws.relayUndispatchedSteps()
124
+
125
+ assert.deepEqual(redispatched.sort(), runIds.sort(), 'both runs relayed')
126
+ assert.equal(resumes.runIds.length, 2, 'one queue message per run')
127
+ })
128
+ })