@pikku/core 0.12.36 → 0.12.38
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.
- package/CHANGELOG.md +67 -0
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +17 -1
- package/dist/services/meta-service.d.ts +4 -0
- package/dist/services/meta-service.js +9 -0
- package/dist/wirings/gateway/gateway.types.d.ts +4 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +117 -45
- package/dist/wirings/workflow/index.d.ts +2 -1
- package/dist/wirings/workflow/index.js +2 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +5 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/services/in-memory-workflow-service.ts +25 -1
- package/src/services/meta-service.ts +15 -0
- package/src/wirings/gateway/gateway.types.ts +6 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
- package/src/wirings/workflow/graph/graph-runner.ts +158 -56
- package/src/wirings/workflow/index.ts +3 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +5 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
PermissionMetadata,
|
|
27
27
|
} from '../types/core.types.js'
|
|
28
28
|
import type { AIAgentMeta } from '../wirings/ai-agent/ai-agent.types.js'
|
|
29
|
+
import type { GatewaysMeta } from '../wirings/gateway/gateway.types.js'
|
|
29
30
|
|
|
30
31
|
// Re-export core types for consumers
|
|
31
32
|
export type {
|
|
@@ -166,6 +167,7 @@ export interface MetaService {
|
|
|
166
167
|
getQueueMeta(): Promise<QueueWorkersMeta>
|
|
167
168
|
getCliMeta(): Promise<CLIMeta>
|
|
168
169
|
getMcpMeta(): Promise<MCPMeta>
|
|
170
|
+
getGatewayMeta(): Promise<GatewaysMeta>
|
|
169
171
|
getRpcMeta(): Promise<RPCMetaRecord>
|
|
170
172
|
getWorkflowMeta(): Promise<WorkflowsMeta>
|
|
171
173
|
getTriggerMeta(): Promise<TriggerMeta>
|
|
@@ -203,6 +205,7 @@ export class LocalMetaService implements MetaService {
|
|
|
203
205
|
private queueMetaCache: QueueWorkersMeta | null = null
|
|
204
206
|
private cliMetaCache: CLIMeta | null = null
|
|
205
207
|
private mcpMetaCache: MCPMeta | null = null
|
|
208
|
+
private gatewayMetaCache: GatewaysMeta | null = null
|
|
206
209
|
private rpcMetaCache: RPCMetaRecord | null = null
|
|
207
210
|
private workflowMetaCache: WorkflowsMeta | null = null
|
|
208
211
|
private triggerMetaCache: TriggerMeta | null = null
|
|
@@ -252,6 +255,7 @@ export class LocalMetaService implements MetaService {
|
|
|
252
255
|
this.queueMetaCache = null
|
|
253
256
|
this.cliMetaCache = null
|
|
254
257
|
this.mcpMetaCache = null
|
|
258
|
+
this.gatewayMetaCache = null
|
|
255
259
|
this.rpcMetaCache = null
|
|
256
260
|
this.workflowMetaCache = null
|
|
257
261
|
this.triggerMetaCache = null
|
|
@@ -357,6 +361,17 @@ export class LocalMetaService implements MetaService {
|
|
|
357
361
|
return this.mcpMetaCache!
|
|
358
362
|
}
|
|
359
363
|
|
|
364
|
+
async getGatewayMeta(): Promise<GatewaysMeta> {
|
|
365
|
+
if (this.gatewayMetaCache) return this.gatewayMetaCache
|
|
366
|
+
|
|
367
|
+
const content = await this.readMetaJson(
|
|
368
|
+
'gateway',
|
|
369
|
+
'pikku-gateway-wirings-meta'
|
|
370
|
+
)
|
|
371
|
+
this.gatewayMetaCache = content ? JSON.parse(content) : {}
|
|
372
|
+
return this.gatewayMetaCache!
|
|
373
|
+
}
|
|
374
|
+
|
|
360
375
|
async getRpcMeta(): Promise<RPCMetaRecord> {
|
|
361
376
|
if (this.rpcMetaCache) return this.rpcMetaCache
|
|
362
377
|
|
|
@@ -112,13 +112,16 @@ export type CoreGateway<
|
|
|
112
112
|
PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
|
|
113
113
|
PikkuPermission extends CorePikkuPermission = CorePikkuPermission,
|
|
114
114
|
PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware,
|
|
115
|
-
> =
|
|
115
|
+
> = Partial<
|
|
116
|
+
Pick<CommonWireMeta, 'title' | 'summary' | 'description' | 'errors'>
|
|
117
|
+
> & {
|
|
116
118
|
/** Unique name for this gateway */
|
|
117
119
|
name: string
|
|
118
120
|
/** Transport type */
|
|
119
121
|
type: GatewayTransportType
|
|
120
122
|
/** HTTP route for webhook/websocket types */
|
|
121
123
|
route?: string
|
|
124
|
+
platform?: string
|
|
122
125
|
/** The gateway adapter (parse inbound, send outbound) */
|
|
123
126
|
adapter: GatewayAdapter
|
|
124
127
|
/** The handler function that processes parsed messages */
|
|
@@ -140,6 +143,8 @@ export type GatewayMeta = CommonWireMeta & {
|
|
|
140
143
|
name: string
|
|
141
144
|
type: GatewayTransportType
|
|
142
145
|
route?: string
|
|
146
|
+
platform?: string
|
|
147
|
+
auth?: boolean
|
|
143
148
|
gateway: true
|
|
144
149
|
}
|
|
145
150
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, test, afterEach
|
|
1
|
+
import { describe, test, afterEach } from 'node:test'
|
|
2
|
+
|
|
3
|
+
const mockFn = <T extends (...args: any[]) => any>(fn: T): T => fn
|
|
2
4
|
import * as assert from 'node:assert/strict'
|
|
3
5
|
import { OAuth2Client } from './oauth2-client.js'
|
|
4
6
|
import type { OAuth2Token, OAuth2AppCredential } from './oauth2.types.js'
|
|
@@ -94,7 +96,7 @@ describe('OAuth2Client', () => {
|
|
|
94
96
|
APP_CREDS: defaultAppCredential,
|
|
95
97
|
})
|
|
96
98
|
|
|
97
|
-
globalThis.fetch =
|
|
99
|
+
globalThis.fetch = mockFn(async () =>
|
|
98
100
|
mockFetchResponse({
|
|
99
101
|
access_token: 'new-access-token',
|
|
100
102
|
refresh_token: 'new-refresh-token',
|
|
@@ -141,7 +143,7 @@ describe('OAuth2Client', () => {
|
|
|
141
143
|
APP_CREDS: defaultAppCredential,
|
|
142
144
|
})
|
|
143
145
|
|
|
144
|
-
globalThis.fetch =
|
|
146
|
+
globalThis.fetch = mockFn(async () =>
|
|
145
147
|
mockFetchResponse({
|
|
146
148
|
access_token: 'refreshed-token',
|
|
147
149
|
expires_in: 3600,
|
|
@@ -183,7 +185,7 @@ describe('OAuth2Client', () => {
|
|
|
183
185
|
APP_CREDS: defaultAppCredential,
|
|
184
186
|
})
|
|
185
187
|
|
|
186
|
-
globalThis.fetch =
|
|
188
|
+
globalThis.fetch = mockFn(async () =>
|
|
187
189
|
mockFetchResponse({
|
|
188
190
|
access_token: 'refreshed-due-to-buffer',
|
|
189
191
|
expires_in: 3600,
|
|
@@ -215,7 +217,7 @@ describe('OAuth2Client', () => {
|
|
|
215
217
|
|
|
216
218
|
let capturedRequest: { url: string; options: RequestInit } | null = null
|
|
217
219
|
|
|
218
|
-
globalThis.fetch =
|
|
220
|
+
globalThis.fetch = mockFn(
|
|
219
221
|
async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
220
222
|
capturedRequest = { url: url.toString(), options: options! }
|
|
221
223
|
return mockFetchResponse({
|
|
@@ -259,7 +261,7 @@ describe('OAuth2Client', () => {
|
|
|
259
261
|
})
|
|
260
262
|
|
|
261
263
|
let fetchCallCount = 0
|
|
262
|
-
globalThis.fetch =
|
|
264
|
+
globalThis.fetch = mockFn(async () => {
|
|
263
265
|
fetchCallCount++
|
|
264
266
|
return mockFetchResponse({
|
|
265
267
|
access_token: 'new-cached-token',
|
|
@@ -293,7 +295,7 @@ describe('OAuth2Client', () => {
|
|
|
293
295
|
APP_CREDS: defaultAppCredential,
|
|
294
296
|
})
|
|
295
297
|
|
|
296
|
-
globalThis.fetch =
|
|
298
|
+
globalThis.fetch = mockFn(async () =>
|
|
297
299
|
mockFetchResponse({ error: 'invalid_grant' }, 400, false)
|
|
298
300
|
)
|
|
299
301
|
|
|
@@ -339,7 +341,7 @@ describe('OAuth2Client', () => {
|
|
|
339
341
|
APP_CREDS: defaultAppCredential,
|
|
340
342
|
})
|
|
341
343
|
|
|
342
|
-
globalThis.fetch =
|
|
344
|
+
globalThis.fetch = mockFn(async () =>
|
|
343
345
|
mockFetchResponse({
|
|
344
346
|
access_token: 'new-persisted-token',
|
|
345
347
|
refresh_token: 'new-refresh-token',
|
|
@@ -374,7 +376,7 @@ describe('OAuth2Client', () => {
|
|
|
374
376
|
})
|
|
375
377
|
|
|
376
378
|
let fetchCallCount = 0
|
|
377
|
-
globalThis.fetch =
|
|
379
|
+
globalThis.fetch = mockFn(async () => {
|
|
378
380
|
fetchCallCount++
|
|
379
381
|
// Simulate network delay
|
|
380
382
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
@@ -417,7 +419,7 @@ describe('OAuth2Client', () => {
|
|
|
417
419
|
})
|
|
418
420
|
|
|
419
421
|
// Response doesn't include refresh_token
|
|
420
|
-
globalThis.fetch =
|
|
422
|
+
globalThis.fetch = mockFn(async () =>
|
|
421
423
|
mockFetchResponse({
|
|
422
424
|
access_token: 'new-token',
|
|
423
425
|
expires_in: 3600,
|
|
@@ -453,7 +455,7 @@ describe('OAuth2Client', () => {
|
|
|
453
455
|
|
|
454
456
|
let capturedHeaders: Record<string, string> | null = null
|
|
455
457
|
|
|
456
|
-
globalThis.fetch =
|
|
458
|
+
globalThis.fetch = mockFn(
|
|
457
459
|
async (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
458
460
|
capturedHeaders = options?.headers as Record<string, string>
|
|
459
461
|
return mockFetchResponse({ data: 'test' })
|
|
@@ -482,7 +484,7 @@ describe('OAuth2Client', () => {
|
|
|
482
484
|
})
|
|
483
485
|
|
|
484
486
|
let requestCount = 0
|
|
485
|
-
globalThis.fetch =
|
|
487
|
+
globalThis.fetch = mockFn(
|
|
486
488
|
async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
487
489
|
requestCount++
|
|
488
490
|
const urlStr = url.toString()
|
|
@@ -529,7 +531,7 @@ describe('OAuth2Client', () => {
|
|
|
529
531
|
})
|
|
530
532
|
|
|
531
533
|
let apiRequestCount = 0
|
|
532
|
-
globalThis.fetch =
|
|
534
|
+
globalThis.fetch = mockFn(async (url: RequestInfo | URL) => {
|
|
533
535
|
const urlStr = url.toString()
|
|
534
536
|
|
|
535
537
|
if (urlStr === 'https://example.com/oauth/token') {
|
|
@@ -565,7 +567,7 @@ describe('OAuth2Client', () => {
|
|
|
565
567
|
APP_CREDS: defaultAppCredential,
|
|
566
568
|
})
|
|
567
569
|
|
|
568
|
-
globalThis.fetch =
|
|
570
|
+
globalThis.fetch = mockFn(async () =>
|
|
569
571
|
mockFetchResponse({ error: 'forbidden' }, 403, false)
|
|
570
572
|
)
|
|
571
573
|
|
|
@@ -589,7 +591,7 @@ describe('OAuth2Client', () => {
|
|
|
589
591
|
|
|
590
592
|
let capturedHeaders: Record<string, string> | null = null
|
|
591
593
|
|
|
592
|
-
globalThis.fetch =
|
|
594
|
+
globalThis.fetch = mockFn(
|
|
593
595
|
async (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
594
596
|
capturedHeaders = options?.headers as Record<string, string>
|
|
595
597
|
return mockFetchResponse({ data: 'test' })
|
|
@@ -704,7 +706,7 @@ describe('OAuth2Client', () => {
|
|
|
704
706
|
|
|
705
707
|
let capturedRequest: { url: string; options: RequestInit } | null = null
|
|
706
708
|
|
|
707
|
-
globalThis.fetch =
|
|
709
|
+
globalThis.fetch = mockFn(
|
|
708
710
|
async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
709
711
|
capturedRequest = { url: url.toString(), options: options! }
|
|
710
712
|
return mockFetchResponse({
|
|
@@ -738,7 +740,7 @@ describe('OAuth2Client', () => {
|
|
|
738
740
|
})
|
|
739
741
|
|
|
740
742
|
let fetchCallCount = 0
|
|
741
|
-
globalThis.fetch =
|
|
743
|
+
globalThis.fetch = mockFn(async () => {
|
|
742
744
|
fetchCallCount++
|
|
743
745
|
return mockFetchResponse({
|
|
744
746
|
access_token: 'new-token',
|
|
@@ -767,7 +769,7 @@ describe('OAuth2Client', () => {
|
|
|
767
769
|
APP_CREDS: defaultAppCredential,
|
|
768
770
|
})
|
|
769
771
|
|
|
770
|
-
globalThis.fetch =
|
|
772
|
+
globalThis.fetch = mockFn(async () =>
|
|
771
773
|
mockFetchResponse({ error: 'invalid_code' }, 400, false)
|
|
772
774
|
)
|
|
773
775
|
|
|
@@ -785,7 +787,7 @@ describe('OAuth2Client', () => {
|
|
|
785
787
|
APP_CREDS: defaultAppCredential,
|
|
786
788
|
})
|
|
787
789
|
|
|
788
|
-
globalThis.fetch =
|
|
790
|
+
globalThis.fetch = mockFn(async () =>
|
|
789
791
|
mockFetchResponse({
|
|
790
792
|
access_token: 'token-only',
|
|
791
793
|
// No refresh_token, expires_in, or scope
|
|
@@ -813,7 +815,7 @@ describe('OAuth2Client', () => {
|
|
|
813
815
|
})
|
|
814
816
|
|
|
815
817
|
// Response without access_token
|
|
816
|
-
globalThis.fetch =
|
|
818
|
+
globalThis.fetch = mockFn(async () =>
|
|
817
819
|
mockFetchResponse({
|
|
818
820
|
refresh_token: 'refresh-only',
|
|
819
821
|
expires_in: 3600,
|
|
@@ -835,7 +837,7 @@ describe('OAuth2Client', () => {
|
|
|
835
837
|
})
|
|
836
838
|
|
|
837
839
|
// Response with non-string access_token
|
|
838
|
-
globalThis.fetch =
|
|
840
|
+
globalThis.fetch = mockFn(async () =>
|
|
839
841
|
mockFetchResponse({
|
|
840
842
|
access_token: 12345, // Number instead of string
|
|
841
843
|
expires_in: 3600,
|
|
@@ -865,7 +867,7 @@ describe('OAuth2Client', () => {
|
|
|
865
867
|
})
|
|
866
868
|
|
|
867
869
|
// Error response with sensitive info
|
|
868
|
-
globalThis.fetch =
|
|
870
|
+
globalThis.fetch = mockFn(async () =>
|
|
869
871
|
mockFetchResponse(
|
|
870
872
|
{
|
|
871
873
|
error: 'invalid_grant',
|
|
@@ -910,7 +912,7 @@ describe('OAuth2Client', () => {
|
|
|
910
912
|
// Instead, we verify that the signal is being passed
|
|
911
913
|
// by checking that an abort signal is present in the fetch call
|
|
912
914
|
let signalPassed = false
|
|
913
|
-
globalThis.fetch =
|
|
915
|
+
globalThis.fetch = mockFn(
|
|
914
916
|
async (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
915
917
|
signalPassed = options?.signal instanceof AbortSignal
|
|
916
918
|
return mockFetchResponse({
|
|
@@ -295,10 +295,28 @@ export type WorkflowStepMeta =
|
|
|
295
295
|
export interface WorkflowStepWire {
|
|
296
296
|
/** The workflow run ID */
|
|
297
297
|
runId: string
|
|
298
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* The step row ID. Whether it stays the same or is minted fresh per attempt is
|
|
300
|
+
* STORE-SPECIFIC (in-memory mints a new one each attempt; the SQL store reuses
|
|
301
|
+
* the row) — do NOT use it as a dedupe key. Use `invocationId`.
|
|
302
|
+
*/
|
|
299
303
|
stepId: string
|
|
304
|
+
/**
|
|
305
|
+
* Stable identity of this step invocation — the idempotency / dedupe key.
|
|
306
|
+
* Identical across every retry of the same call (derived from runId + step
|
|
307
|
+
* name) regardless of storage backend, so a step can `ON CONFLICT (invocationId)`
|
|
308
|
+
* or pass it as an external idempotency key and have retries collapse onto the
|
|
309
|
+
* first attempt.
|
|
310
|
+
*/
|
|
311
|
+
invocationId: string
|
|
300
312
|
/** Current attempt number (1-indexed, increments on retry) */
|
|
301
313
|
attemptCount: number
|
|
314
|
+
/**
|
|
315
|
+
* Invocation ID of the predecessor step this one was reached from (the walked
|
|
316
|
+
* transition/edge). Undefined for entry steps. Lets a step know its origin —
|
|
317
|
+
* e.g. in a cyclic graph `a → b → a → c`, the second `a` carries `b`'s id.
|
|
318
|
+
*/
|
|
319
|
+
fromInvocationId?: string
|
|
302
320
|
}
|
|
303
321
|
|
|
304
322
|
/**
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import type { WorkflowRuntimeMeta } from '../workflow.types.js'
|
|
11
11
|
import { pikkuState } from '../../../pikku-state.js'
|
|
12
12
|
import { RPCNotFoundError } from '../../rpc/rpc-runner.js'
|
|
13
|
+
import { DEFAULT_STEP_RETRIES } from '../pikku-workflow-service.js'
|
|
13
14
|
|
|
14
15
|
describe('graph-runner bugs', () => {
|
|
15
16
|
test('continueGraph should NOT mark workflow completed while nodes are still running', async () => {
|
|
@@ -240,6 +241,84 @@ describe('graph-runner bugs', () => {
|
|
|
240
241
|
assert.deepEqual(queuedNodes, ['c'])
|
|
241
242
|
})
|
|
242
243
|
|
|
244
|
+
test('continueGraph revisits a cyclic node and records the walked path via fromStepName', async () => {
|
|
245
|
+
const ws = new InMemoryWorkflowService()
|
|
246
|
+
const queued: Array<{ stepName: string; fromStepName?: string }> = []
|
|
247
|
+
|
|
248
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
249
|
+
queueService: {
|
|
250
|
+
add: async (_queueName: string, data: any) => {
|
|
251
|
+
if (data?.stepName) {
|
|
252
|
+
queued.push({
|
|
253
|
+
stepName: data.stepName,
|
|
254
|
+
fromStepName: data.fromStepName,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
} as any)
|
|
260
|
+
|
|
261
|
+
// start → a, where a loops back through b once, then exits to c:
|
|
262
|
+
// start → a --retry--> b → a --done--> c
|
|
263
|
+
const meta: WorkflowRuntimeMeta = {
|
|
264
|
+
name: 'testCyclicGraph',
|
|
265
|
+
pikkuFuncId: 'testCyclicGraph',
|
|
266
|
+
source: 'graph',
|
|
267
|
+
entryNodeIds: ['start'],
|
|
268
|
+
graphHash: 'cyclic-hash',
|
|
269
|
+
nodes: {
|
|
270
|
+
start: { nodeId: 'start', rpcName: 'doStart', next: 'a' },
|
|
271
|
+
a: { nodeId: 'a', rpcName: 'doA', next: { retry: 'b', done: 'c' } },
|
|
272
|
+
b: { nodeId: 'b', rpcName: 'doB', next: 'a' },
|
|
273
|
+
c: { nodeId: 'c', rpcName: 'doC' },
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const runId = await ws.createRun('testCyclicGraph', {}, false, 'cyclic-hash', {
|
|
278
|
+
type: 'test',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Succeed a queued step (taking an optional branch), then advance the graph.
|
|
282
|
+
const advance = async (stepName: string, branch?: string) => {
|
|
283
|
+
const step = await ws.getStepState(runId, stepName)
|
|
284
|
+
await ws.setStepRunning(step.stepId)
|
|
285
|
+
if (branch) await ws.setBranchTaken(step.stepId, branch)
|
|
286
|
+
await ws.setStepResult(step.stepId, { ok: true })
|
|
287
|
+
await continueGraph(ws, runId, 'testCyclicGraph', meta)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await continueGraph(ws, runId, 'testCyclicGraph', meta) // fire entry
|
|
291
|
+
await advance('start')
|
|
292
|
+
await advance('a', 'retry') // a → b (b reaches a, but first visit ⇒ bare 'b')
|
|
293
|
+
await advance('b') // b → a revisit ⇒ a#1
|
|
294
|
+
await advance('a#1', 'done') // a#1 → c (forward edge, terminal)
|
|
295
|
+
await advance('c')
|
|
296
|
+
|
|
297
|
+
// Each step records the predecessor it was reached from; the cyclic
|
|
298
|
+
// revisit is a fresh ordinal instance (a#1) with its own provenance.
|
|
299
|
+
assert.deepEqual(queued, [
|
|
300
|
+
{ stepName: 'start', fromStepName: undefined },
|
|
301
|
+
{ stepName: 'a', fromStepName: 'start' },
|
|
302
|
+
{ stepName: 'b', fromStepName: 'a' },
|
|
303
|
+
{ stepName: 'a#1', fromStepName: 'b' },
|
|
304
|
+
{ stepName: 'c', fromStepName: 'a#1' },
|
|
305
|
+
])
|
|
306
|
+
|
|
307
|
+
const run = await ws.getRun(runId)
|
|
308
|
+
assert.equal(run?.status, 'completed')
|
|
309
|
+
|
|
310
|
+
// Reconstruct the walked path purely from the fromStepName chain.
|
|
311
|
+
const instances = await ws.getStepInstances(runId)
|
|
312
|
+
const from = new Map(instances.map((i) => [i.stepName, i.fromStepName]))
|
|
313
|
+
const path: string[] = []
|
|
314
|
+
let cursor: string | undefined = 'c'
|
|
315
|
+
while (cursor) {
|
|
316
|
+
path.unshift(cursor)
|
|
317
|
+
cursor = from.get(cursor) ?? undefined
|
|
318
|
+
}
|
|
319
|
+
assert.deepEqual(path, ['start', 'a', 'b', 'a#1', 'c'])
|
|
320
|
+
})
|
|
321
|
+
|
|
243
322
|
test('inline graph should execute converging next node only once', async () => {
|
|
244
323
|
const ws = new InMemoryWorkflowService()
|
|
245
324
|
let cCalls = 0
|
|
@@ -618,9 +697,14 @@ describe('graph-runner bugs', () => {
|
|
|
618
697
|
const cJob = enqueued.find((e) => e.data?.stepName === 'c')
|
|
619
698
|
assert.ok(cJob, 'node c should have been enqueued')
|
|
620
699
|
assert.equal(
|
|
621
|
-
cJob!.options,
|
|
622
|
-
|
|
623
|
-
'no retries →
|
|
700
|
+
cJob!.options?.attempts,
|
|
701
|
+
DEFAULT_STEP_RETRIES + 1,
|
|
702
|
+
'no retries → workflow default (5) + 1 attempt; queue never decides retries'
|
|
703
|
+
)
|
|
704
|
+
assert.equal(
|
|
705
|
+
cJob!.options?.backoff,
|
|
706
|
+
'exponential',
|
|
707
|
+
'default retries get exponential backoff so they ride out transient outages'
|
|
624
708
|
)
|
|
625
709
|
})
|
|
626
710
|
})
|