@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/services/in-memory-workflow-service.d.ts +7 -2
  3. package/dist/services/in-memory-workflow-service.js +17 -1
  4. package/dist/services/meta-service.d.ts +4 -0
  5. package/dist/services/meta-service.js +9 -0
  6. package/dist/wirings/gateway/gateway.types.d.ts +4 -1
  7. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  8. package/dist/wirings/workflow/graph/graph-runner.js +117 -45
  9. package/dist/wirings/workflow/index.d.ts +2 -1
  10. package/dist/wirings/workflow/index.js +2 -1
  11. package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
  12. package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
  13. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  14. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  15. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  16. package/dist/wirings/workflow/workflow.types.d.ts +5 -0
  17. package/package.json +2 -1
  18. package/src/dev/hot-reload.test.ts +5 -0
  19. package/src/services/in-memory-workflow-service.ts +25 -1
  20. package/src/services/meta-service.ts +15 -0
  21. package/src/wirings/gateway/gateway.types.ts +6 -1
  22. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  23. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  24. package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
  25. package/src/wirings/workflow/graph/graph-runner.ts +158 -56
  26. package/src/wirings/workflow/index.ts +3 -0
  27. package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
  28. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  29. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  30. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  31. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  32. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  33. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  34. package/src/wirings/workflow/workflow.types.ts +5 -0
  35. 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, mock } from 'node:test'
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(
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 = mock.fn(async () => {
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(async () => {
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 = mock.fn(async () =>
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 = mock.fn(
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 = mock.fn(
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 = mock.fn(async (url: RequestInfo | URL) => {
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 = mock.fn(async () =>
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 = mock.fn(
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 = mock.fn(
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 = mock.fn(async () => {
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(async () =>
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 = mock.fn(
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
- /** The unique step ID */
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
- undefined,
623
- 'no retries → no queue options (preserves prior queue defaults)'
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
  })