@pikku/core 0.12.37 → 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 +60 -0
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +17 -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/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
|
@@ -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
|
})
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type PikkuWorkflowService,
|
|
3
3
|
WorkflowAsyncException,
|
|
4
4
|
WorkflowSuspendedException,
|
|
5
|
+
DEFAULT_STEP_RETRIES,
|
|
5
6
|
} from '../pikku-workflow-service.js'
|
|
6
7
|
import type { GraphWireState, PikkuGraphWire } from './workflow-graph.types.js'
|
|
7
8
|
import { pikkuState, getSingletonServices } from '../../../pikku-state.js'
|
|
@@ -28,6 +29,13 @@ function buildTemplateRegex(nodeId: string): RegExp | null {
|
|
|
28
29
|
return new RegExp(`^${escaped}$`)
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Strip a trailing revisit ordinal (`node#2` → `node`); leaves other names as-is. */
|
|
33
|
+
function stripInstanceOrdinal(name: string): string {
|
|
34
|
+
const hash = name.lastIndexOf('#')
|
|
35
|
+
if (hash <= 0) return name
|
|
36
|
+
return /^\d+$/.test(name.slice(hash + 1)) ? name.slice(0, hash) : name
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
function remapStepNamesToNodeIds(
|
|
32
40
|
stepNames: string[],
|
|
33
41
|
nodes: Record<string, any>,
|
|
@@ -38,12 +46,14 @@ function remapStepNamesToNodeIds(
|
|
|
38
46
|
const regex = buildTemplateRegex(nodeId)
|
|
39
47
|
if (regex) templatePatterns.set(nodeId, regex)
|
|
40
48
|
}
|
|
41
|
-
if (templatePatterns.size === 0) return stepNames
|
|
42
49
|
return stepNames.map((name) => {
|
|
43
50
|
if (nodes[name]) return name
|
|
51
|
+
// Revisit instance (`node#N`) maps to its logical node.
|
|
52
|
+
const base = stripInstanceOrdinal(name)
|
|
53
|
+
if (base !== name && nodes[base]) return base
|
|
44
54
|
const matches: string[] = []
|
|
45
55
|
for (const [nodeId, regex] of templatePatterns) {
|
|
46
|
-
if (regex.test(
|
|
56
|
+
if (regex.test(base)) matches.push(nodeId)
|
|
47
57
|
}
|
|
48
58
|
if (matches.length > 1) {
|
|
49
59
|
throw new Error(
|
|
@@ -57,6 +67,122 @@ function remapStepNamesToNodeIds(
|
|
|
57
67
|
})
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
const ENTRY_FROM = '__entry__'
|
|
71
|
+
|
|
72
|
+
/** Whether `target` can reach `source` over `next` edges — i.e. an edge
|
|
73
|
+
* source→target closes a cycle (a back-edge), vs a plain forward edge. */
|
|
74
|
+
function closesCycle(
|
|
75
|
+
source: string,
|
|
76
|
+
target: string,
|
|
77
|
+
nodes: Record<string, any>
|
|
78
|
+
): boolean {
|
|
79
|
+
const seen = new Set<string>()
|
|
80
|
+
const stack = [target]
|
|
81
|
+
while (stack.length) {
|
|
82
|
+
const cur = stack.pop()!
|
|
83
|
+
if (cur === source) return true
|
|
84
|
+
if (seen.has(cur)) continue
|
|
85
|
+
seen.add(cur)
|
|
86
|
+
for (const next of normalizeNodeTargets(nodes[cur]?.next)) stack.push(next)
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide which next steps to fire this tick. Two kinds of edge:
|
|
93
|
+
* - forward edge → node-once: fire the target only if it has no instance yet,
|
|
94
|
+
* so converging edges (joins) collapse to a single run (unchanged behavior).
|
|
95
|
+
* - back-edge (target can reach the source, closing a cycle) → revisit: fire a
|
|
96
|
+
* fresh ordinal instance (`target#1`, …), edge-once on `from → target` so it
|
|
97
|
+
* doesn't re-fire every tick. Cycles terminate when branch routing stops
|
|
98
|
+
* looping back; a node always records the predecessor it was reached from.
|
|
99
|
+
*/
|
|
100
|
+
function planGraphTransitions(
|
|
101
|
+
nodes: Record<string, any>,
|
|
102
|
+
instances: Array<{ stepName: string; status: string; fromStepName?: string }>,
|
|
103
|
+
branchByStep: Record<string, string>,
|
|
104
|
+
entryNodeIds: string[],
|
|
105
|
+
graphName: string
|
|
106
|
+
): {
|
|
107
|
+
toFire: Array<{ logical: string; instanceKey: string; fromStepName?: string }>
|
|
108
|
+
hasInFlight: boolean
|
|
109
|
+
blockedWaiting: boolean
|
|
110
|
+
} {
|
|
111
|
+
const toLogical = (name: string) =>
|
|
112
|
+
remapStepNamesToNodeIds([name], nodes, graphName)[0]!
|
|
113
|
+
|
|
114
|
+
const countByLogical: Record<string, number> = {}
|
|
115
|
+
const consumed = new Set<string>()
|
|
116
|
+
for (const inst of instances) {
|
|
117
|
+
const logical = toLogical(inst.stepName)
|
|
118
|
+
countByLogical[logical] = (countByLogical[logical] ?? 0) + 1
|
|
119
|
+
consumed.add(`${inst.fromStepName ?? ENTRY_FROM}->${logical}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const completed = instances.filter((i) => i.status === 'succeeded')
|
|
123
|
+
const completedLogical = new Set(completed.map((i) => toLogical(i.stepName)))
|
|
124
|
+
|
|
125
|
+
// Available edges: entry edges + each completed instance's resolved `next`.
|
|
126
|
+
const edges: Array<{
|
|
127
|
+
from?: string
|
|
128
|
+
fromKey: string
|
|
129
|
+
fromLogical?: string
|
|
130
|
+
target: string
|
|
131
|
+
}> = []
|
|
132
|
+
for (const entryId of entryNodeIds) {
|
|
133
|
+
edges.push({ fromKey: ENTRY_FROM, target: entryId })
|
|
134
|
+
}
|
|
135
|
+
for (const inst of completed) {
|
|
136
|
+
const fromLogical = toLogical(inst.stepName)
|
|
137
|
+
const node = nodes[fromLogical]
|
|
138
|
+
if (!node?.next) continue
|
|
139
|
+
for (const target of resolveNextFromConfig(
|
|
140
|
+
node.next,
|
|
141
|
+
branchByStep[inst.stepName]
|
|
142
|
+
)) {
|
|
143
|
+
edges.push({ from: inst.stepName, fromKey: inst.stepName, fromLogical, target })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const toFire: Array<{
|
|
148
|
+
logical: string
|
|
149
|
+
instanceKey: string
|
|
150
|
+
fromStepName?: string
|
|
151
|
+
}> = []
|
|
152
|
+
let blockedWaiting = false
|
|
153
|
+
for (const edge of edges) {
|
|
154
|
+
const target = edge.target
|
|
155
|
+
const edgeKey = `${edge.fromKey}->${target}`
|
|
156
|
+
if (consumed.has(edgeKey)) continue
|
|
157
|
+
const visits = countByLogical[target] ?? 0
|
|
158
|
+
const isBackEdge =
|
|
159
|
+
edge.fromLogical !== undefined &&
|
|
160
|
+
closesCycle(edge.fromLogical, target, nodes)
|
|
161
|
+
// Forward edge into an already-started node = a join; node-once.
|
|
162
|
+
if (!isBackEdge && visits > 0) {
|
|
163
|
+
consumed.add(edgeKey)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (!areDependenciesSatisfied(nodes[target] ?? {}, completedLogical)) {
|
|
167
|
+
blockedWaiting = true
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
toFire.push({
|
|
171
|
+
logical: target,
|
|
172
|
+
instanceKey: visits === 0 ? target : `${target}#${visits}`,
|
|
173
|
+
fromStepName: edge.from,
|
|
174
|
+
})
|
|
175
|
+
countByLogical[target] = visits + 1
|
|
176
|
+
consumed.add(edgeKey)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
toFire,
|
|
181
|
+
hasInFlight: instances.some((i) => i.status !== 'succeeded'),
|
|
182
|
+
blockedWaiting,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
60
186
|
function remapBranchKeys(
|
|
61
187
|
branchKeys: Record<string, string>,
|
|
62
188
|
nodes: Record<string, any>,
|
|
@@ -325,10 +451,13 @@ async function queueGraphNode(
|
|
|
325
451
|
nodeId: string,
|
|
326
452
|
rpcName: string,
|
|
327
453
|
input: any,
|
|
328
|
-
nodeConfig?: { retries?: number; retryDelay?: string | number }
|
|
454
|
+
nodeConfig?: { retries?: number; retryDelay?: string | number },
|
|
455
|
+
fromStepName?: string
|
|
329
456
|
): Promise<void> {
|
|
457
|
+
// Default to the workflow-wide retry policy when the node sets none, so the
|
|
458
|
+
// persisted step retries match the queue `attempts` (see resolveStepJobOptions).
|
|
330
459
|
const stepOptions = {
|
|
331
|
-
retries: nodeConfig?.retries ??
|
|
460
|
+
retries: nodeConfig?.retries ?? DEFAULT_STEP_RETRIES,
|
|
332
461
|
retryDelay: nodeConfig?.retryDelay,
|
|
333
462
|
}
|
|
334
463
|
await workflowService.insertStepState(
|
|
@@ -336,14 +465,16 @@ async function queueGraphNode(
|
|
|
336
465
|
nodeId,
|
|
337
466
|
rpcName,
|
|
338
467
|
input,
|
|
339
|
-
stepOptions
|
|
468
|
+
stepOptions,
|
|
469
|
+
fromStepName
|
|
340
470
|
)
|
|
341
471
|
await workflowService.queueStepWorker(
|
|
342
472
|
runId,
|
|
343
473
|
nodeId,
|
|
344
474
|
rpcName,
|
|
345
475
|
input,
|
|
346
|
-
stepOptions
|
|
476
|
+
stepOptions,
|
|
477
|
+
fromStepName
|
|
347
478
|
)
|
|
348
479
|
}
|
|
349
480
|
|
|
@@ -364,16 +495,13 @@ export async function continueGraph(
|
|
|
364
495
|
const {
|
|
365
496
|
completedNodeIds: rawCompleted,
|
|
366
497
|
failedNodeIds: rawFailed,
|
|
367
|
-
branchKeys:
|
|
498
|
+
branchKeys: branchByStep,
|
|
368
499
|
} = await workflowService.getCompletedGraphState(runId)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
)
|
|
374
|
-
const completedNodeIdSet = new Set(completedNodeIds)
|
|
500
|
+
// Validate step/branch names map to unambiguous nodes (planning keys
|
|
501
|
+
// physically; these calls only surface ambiguous template configs).
|
|
502
|
+
remapStepNamesToNodeIds(rawCompleted, nodes, graphName)
|
|
503
|
+
remapBranchKeys(branchByStep, nodes, graphName)
|
|
375
504
|
const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName)
|
|
376
|
-
const branchKeys = remapBranchKeys(rawBranch, nodes, graphName)
|
|
377
505
|
|
|
378
506
|
if (failedNodeIds.length > 0) {
|
|
379
507
|
const failedNode = failedNodeIds[0]!
|
|
@@ -390,44 +518,18 @@ export async function continueGraph(
|
|
|
390
518
|
return
|
|
391
519
|
}
|
|
392
520
|
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
candidateNodes.add(nextNode)
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
for (const entryId of meta.entryNodeIds ?? []) {
|
|
406
|
-
candidateNodes.add(entryId)
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (candidateNodes.size === 0 && completedNodeIds.length > 0) {
|
|
410
|
-
await workflowService.updateRunStatus(runId, 'completed')
|
|
411
|
-
return
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const unstartedNodes = await workflowService.getNodesWithoutSteps(runId, [
|
|
415
|
-
...candidateNodes,
|
|
416
|
-
])
|
|
417
|
-
|
|
418
|
-
const nodesToQueue = unstartedNodes.filter((nodeId) => {
|
|
419
|
-
const node = nodes[nodeId]
|
|
420
|
-
return node && areDependenciesSatisfied(node, completedNodeIdSet)
|
|
421
|
-
})
|
|
521
|
+
const instances = await workflowService.getStepInstances(runId)
|
|
522
|
+
const plan = planGraphTransitions(
|
|
523
|
+
nodes,
|
|
524
|
+
instances,
|
|
525
|
+
branchByStep,
|
|
526
|
+
meta.entryNodeIds ?? [],
|
|
527
|
+
graphName
|
|
528
|
+
)
|
|
422
529
|
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
.map(([id]) => id)
|
|
427
|
-
const allRpcCompleted = allRpcNodes.every((id) =>
|
|
428
|
-
completedNodeIdSet.has(id)
|
|
429
|
-
)
|
|
430
|
-
if (allRpcCompleted) {
|
|
530
|
+
if (plan.toFire.length === 0) {
|
|
531
|
+
// Nothing left to fire and nothing running/blocked → the run is done.
|
|
532
|
+
if (!plan.hasInFlight && !plan.blockedWaiting) {
|
|
431
533
|
await workflowService.updateRunStatus(runId, 'completed')
|
|
432
534
|
}
|
|
433
535
|
return
|
|
@@ -436,8 +538,8 @@ export async function continueGraph(
|
|
|
436
538
|
const run = await workflowService.getRun(runId)
|
|
437
539
|
const triggerInput = run?.input
|
|
438
540
|
|
|
439
|
-
for (const
|
|
440
|
-
const node = nodes[
|
|
541
|
+
for (const fire of plan.toFire) {
|
|
542
|
+
const node = nodes[fire.logical]
|
|
441
543
|
if (!node?.rpcName) continue
|
|
442
544
|
|
|
443
545
|
const referencedNodeIds = extractReferencedNodeIds(node.input).filter(
|
|
@@ -447,7 +549,6 @@ export async function continueGraph(
|
|
|
447
549
|
runId,
|
|
448
550
|
referencedNodeIds
|
|
449
551
|
)
|
|
450
|
-
|
|
451
552
|
const nodeResults = { trigger: triggerInput, ...fetchedResults }
|
|
452
553
|
const resolvedInput = resolveSerializedInput(node.input, nodeResults)
|
|
453
554
|
|
|
@@ -455,10 +556,11 @@ export async function continueGraph(
|
|
|
455
556
|
workflowService,
|
|
456
557
|
runId,
|
|
457
558
|
graphName,
|
|
458
|
-
|
|
559
|
+
fire.instanceKey,
|
|
459
560
|
node.rpcName,
|
|
460
561
|
resolvedInput,
|
|
461
|
-
node
|
|
562
|
+
node,
|
|
563
|
+
fire.fromStepName
|
|
462
564
|
)
|
|
463
565
|
}
|
|
464
566
|
}
|
|
@@ -5,9 +5,12 @@ export {
|
|
|
5
5
|
PikkuWorkflowService,
|
|
6
6
|
WorkflowCancelledException,
|
|
7
7
|
WorkflowSuspendedException,
|
|
8
|
+
WorkflowDispatchException,
|
|
8
9
|
WorkflowNotFoundError,
|
|
9
10
|
WorkflowRunNotFoundError,
|
|
11
|
+
DEFAULT_STEP_RETRIES,
|
|
10
12
|
} from './pikku-workflow-service.js'
|
|
13
|
+
export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js'
|
|
11
14
|
|
|
12
15
|
// Internal registration functions (used by generated code)
|
|
13
16
|
export { addWorkflow } from './dsl/workflow-runner.js'
|