@pikku/core 0.12.38 → 0.12.40

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 (42) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/errors/error-handler.d.ts +8 -0
  3. package/dist/errors/error-handler.js +8 -0
  4. package/dist/function/functions.types.d.ts +6 -2
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/services/in-memory-queue-service.d.ts +9 -0
  8. package/dist/services/in-memory-queue-service.js +42 -11
  9. package/dist/services/in-memory-workflow-service.js +2 -0
  10. package/dist/services/workflow-service.d.ts +3 -0
  11. package/dist/testing/service-tests.js +35 -0
  12. package/dist/types/core.types.d.ts +7 -2
  13. package/dist/wirings/cli/cli-runner.js +12 -3
  14. package/dist/wirings/workflow/graph/graph-runner.js +51 -61
  15. package/dist/wirings/workflow/index.d.ts +2 -0
  16. package/dist/wirings/workflow/index.js +2 -0
  17. package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
  18. package/dist/wirings/workflow/pikku-workflow-service.js +142 -132
  19. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  20. package/dist/wirings/workflow/run-timeline.js +153 -0
  21. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  22. package/package.json +1 -1
  23. package/src/errors/error-handler.ts +10 -0
  24. package/src/function/functions.types.ts +6 -2
  25. package/src/index.ts +1 -0
  26. package/src/services/in-memory-queue-service.test.ts +97 -0
  27. package/src/services/in-memory-queue-service.ts +51 -16
  28. package/src/services/in-memory-workflow-service.ts +2 -0
  29. package/src/services/workflow-service.ts +9 -0
  30. package/src/testing/service-tests.ts +65 -0
  31. package/src/types/core.types.ts +10 -2
  32. package/src/wirings/cli/cli-runner.ts +11 -3
  33. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  34. package/src/wirings/workflow/graph/graph-runner.ts +95 -86
  35. package/src/wirings/workflow/index.ts +14 -0
  36. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -24
  37. package/src/wirings/workflow/pikku-workflow-service.ts +200 -151
  38. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  39. package/src/wirings/workflow/run-timeline.ts +241 -0
  40. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +3 -3
  41. package/src/wirings/workflow/workflow.types.ts +2 -0
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,97 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryQueueService } from './in-memory-queue-service.js'
5
+ import { wireQueueWorker } from '../wirings/queue/queue-runner.js'
6
+ import { resetPikkuState, pikkuState } from '../pikku-state.js'
7
+
8
+ beforeEach(() => {
9
+ resetPikkuState()
10
+ pikkuState(null, 'package', 'singletonServices', {
11
+ logger: { error() {}, info() {}, warn() {}, debug() {} },
12
+ } as any)
13
+ pikkuState(null, 'package', 'factories', {
14
+ createWireServices: async () => ({}),
15
+ } as any)
16
+ })
17
+
18
+ // Register a queue worker whose handler runs `behavior` (which may throw) and
19
+ // counts invocations. Returns the live call counter.
20
+ const registerWorker = (
21
+ queueName: string,
22
+ behavior: (count: number) => void
23
+ ) => {
24
+ const calls = { count: 0 }
25
+ const funcId = `queue_${queueName}`
26
+ pikkuState(null, 'queue', 'meta')[queueName] = { pikkuFuncId: funcId, name: queueName }
27
+ pikkuState(null, 'function', 'meta')[funcId] = {
28
+ pikkuFuncId: funcId,
29
+ inputSchemaName: null,
30
+ outputSchemaName: null,
31
+ sessionless: true,
32
+ } as any
33
+ wireQueueWorker({
34
+ name: queueName,
35
+ func: {
36
+ auth: false,
37
+ func: async () => {
38
+ calls.count++
39
+ behavior(calls.count)
40
+ },
41
+ },
42
+ } as any)
43
+ return calls
44
+ }
45
+
46
+ const waitUntil = async (
47
+ predicate: () => boolean,
48
+ timeoutMs = 2000
49
+ ): Promise<boolean> => {
50
+ const deadline = Date.now() + timeoutMs
51
+ while (Date.now() < deadline) {
52
+ if (predicate()) return true
53
+ await new Promise((r) => setTimeout(r, 10))
54
+ }
55
+ return predicate()
56
+ }
57
+
58
+ describe('InMemoryQueueService retry', () => {
59
+ test('redelivers a transiently-failing job until it succeeds', async () => {
60
+ const calls = registerWorker('flaky', (count) => {
61
+ if (count <= 2) throw new Error(`transient ${count}`)
62
+ })
63
+ const queue = new InMemoryQueueService()
64
+
65
+ await queue.add('flaky', {}, { attempts: 5, delay: 0 })
66
+
67
+ // Succeeds on the 3rd attempt; must not exceed the configured attempts.
68
+ assert.equal(await waitUntil(() => calls.count >= 3), true)
69
+ await new Promise((r) => setTimeout(r, 150))
70
+ assert.equal(calls.count, 3, 'should stop retrying once the job succeeds')
71
+ })
72
+
73
+ test('stops after `attempts` when a job always fails', async () => {
74
+ const calls = registerWorker('always-fails', () => {
75
+ throw new Error('boom')
76
+ })
77
+ const queue = new InMemoryQueueService()
78
+
79
+ await queue.add('always-fails', {}, { attempts: 3, delay: 0 })
80
+
81
+ assert.equal(await waitUntil(() => calls.count >= 3), true)
82
+ await new Promise((r) => setTimeout(r, 150))
83
+ assert.equal(calls.count, 3, 'must not redeliver beyond `attempts`')
84
+ })
85
+
86
+ test('runs once and does not retry when no attempts are configured', async () => {
87
+ const calls = registerWorker('no-retry', () => {
88
+ throw new Error('boom')
89
+ })
90
+ const queue = new InMemoryQueueService()
91
+
92
+ await queue.add('no-retry', {}, { delay: 0 })
93
+
94
+ await new Promise((r) => setTimeout(r, 150))
95
+ assert.equal(calls.count, 1, 'a job without attempts runs exactly once')
96
+ })
97
+ })
@@ -5,6 +5,13 @@ import type {
5
5
  } from '../wirings/queue/queue.types.js'
6
6
  import { runQueueJob } from '../wirings/queue/queue-runner.js'
7
7
 
8
+ /**
9
+ * In-process queue for local/dev runs. Schedules jobs on the macrotask queue
10
+ * (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
11
+ * a real queue — and redelivers a failed job up to `options.attempts` times with
12
+ * backoff, so a transiently-failing workflow step recovers exactly as it would
13
+ * on pg-boss/bullmq instead of being silently dropped on its first error.
14
+ */
8
15
  export class InMemoryQueueService implements QueueService {
9
16
  readonly supportsResults = false
10
17
  private jobCounter = 0
@@ -15,31 +22,59 @@ export class InMemoryQueueService implements QueueService {
15
22
  options?: JobOptions
16
23
  ): Promise<string> {
17
24
  const jobId = `inmem-${++this.jobCounter}`
25
+ const maxAttempts = Math.max(1, options?.attempts ?? 1)
26
+ let attemptsMade = 0
27
+ const createdAt = new Date()
18
28
 
19
- const job: QueueJob<T> = {
20
- id: jobId,
21
- queueName,
22
- data,
23
- status: () => 'active',
24
- pikkuUserId: options?.pikkuUserId,
25
- }
26
-
27
- const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201)
28
-
29
- setTimeout(async () => {
29
+ const runAttempt = async () => {
30
+ attemptsMade++
31
+ const job: QueueJob<T> = {
32
+ id: jobId,
33
+ queueName,
34
+ data,
35
+ status: () => 'active',
36
+ metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
37
+ pikkuUserId: options?.pikkuUserId,
38
+ }
30
39
  try {
31
40
  await runQueueJob({ job })
32
41
  } catch (e: any) {
33
- console.error(
34
- `[InMemoryQueue] Job ${jobId} on ${queueName} failed:`,
35
- e.message
36
- )
42
+ if (attemptsMade < maxAttempts) {
43
+ // Transient failure redeliver with backoff, mirroring a real queue.
44
+ setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade))
45
+ } else {
46
+ console.error(
47
+ `[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`,
48
+ e.message
49
+ )
50
+ }
37
51
  }
38
- }, delay)
52
+ }
53
+
54
+ const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201)
55
+ setTimeout(runAttempt, delay)
39
56
 
40
57
  return jobId
41
58
  }
42
59
 
60
+ /** Delay before the next redelivery, honoring the job's backoff policy. */
61
+ private backoffDelay(
62
+ backoff: JobOptions['backoff'],
63
+ attemptsMade: number
64
+ ): number {
65
+ const baseDelay = typeof backoff === 'object' ? (backoff.delay ?? 50) : 50
66
+ if (
67
+ backoff === 'exponential' ||
68
+ (typeof backoff === 'object' && backoff?.type === 'exponential')
69
+ ) {
70
+ return Math.min(2 ** (attemptsMade - 1) * baseDelay, 2000)
71
+ }
72
+ if (typeof backoff === 'object' && backoff?.type === 'fixed') {
73
+ return backoff.delay ?? 50
74
+ }
75
+ return 50
76
+ }
77
+
43
78
  async getJob(): Promise<null> {
44
79
  return null
45
80
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'crypto'
2
2
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js'
3
+ import { isExpectedError } from '../errors/error-handler.js'
3
4
  import type { SerializedError } from '../types/core.types.js'
4
5
  import type {
5
6
  WorkflowPlannedStep,
@@ -230,6 +231,7 @@ export class InMemoryWorkflowService
230
231
  name: error.name,
231
232
  message: error.message,
232
233
  stack: error.stack,
234
+ expected: isExpectedError(error),
233
235
  }
234
236
  step.failedAt = new Date()
235
237
  step.updatedAt = new Date()
@@ -8,6 +8,10 @@ import type {
8
8
  WorkflowStatus,
9
9
  WorkflowVersionStatus,
10
10
  } from '../wirings/workflow/workflow.types.js'
11
+ import type {
12
+ RunTimeline,
13
+ ReconstructedRunState,
14
+ } from '../wirings/workflow/run-timeline.js'
11
15
 
12
16
  /**
13
17
  * Interface for workflow orchestration
@@ -29,6 +33,11 @@ export interface WorkflowService {
29
33
  getRun(id: string): Promise<WorkflowRun | null>
30
34
  getRunStatus(id: string): Promise<WorkflowRunStatus | null>
31
35
  getRunHistory(runId: string): Promise<Array<StepState & { stepName: string }>>
36
+ getRunTimeline(id: string): Promise<RunTimeline | null>
37
+ reconstructRunStateAt(
38
+ id: string,
39
+ at?: number | Date
40
+ ): Promise<ReconstructedRunState | null>
32
41
  updateRunStatus(
33
42
  id: string,
34
43
  status: WorkflowStatus,
@@ -320,6 +320,71 @@ export function defineServiceTests(config: ServiceTestConfig): void {
320
320
  assert.equal(retried.result, undefined)
321
321
  })
322
322
 
323
+ test('getRunStatus reports per-step duration and attempts', async () => {
324
+ const runId = await service.createRun(
325
+ 'status-timing-workflow',
326
+ {},
327
+ false,
328
+ 'hash-timing',
329
+ wire
330
+ )
331
+ const step = await service.insertStepState(
332
+ runId,
333
+ 'timed-step',
334
+ 'myRpc',
335
+ {}
336
+ )
337
+ await service.setStepRunning(step.stepId)
338
+ await service.setStepResult(step.stepId, { ok: true })
339
+
340
+ const status = await service.getRunStatus(runId)
341
+ assert.ok(status)
342
+ const timed = status.steps.find((s) => s.name === 'timed-step')
343
+ assert.ok(timed, 'step appears in run status')
344
+ // running→succeeded must stamp runningAt/succeededAt so a duration is
345
+ // computable. Regression guard: the kysely store previously updated
346
+ // only the status on transitions, leaving the timestamps null and
347
+ // duration permanently undefined.
348
+ assert.ok(
349
+ timed.duration !== undefined,
350
+ 'duration is computed from stamped transition timestamps'
351
+ )
352
+ assert.ok(timed.duration >= 0, 'duration is non-negative')
353
+ assert.equal(timed.attempts, 1, 'single attempt')
354
+ })
355
+
356
+ test('getRunStatus surfaces retried attempt count', async () => {
357
+ const runId = await service.createRun(
358
+ 'status-retry-workflow',
359
+ {},
360
+ false,
361
+ 'hash-retry-status',
362
+ wire
363
+ )
364
+ const step = await service.insertStepState(
365
+ runId,
366
+ 'flaky-step',
367
+ 'myRpc',
368
+ {},
369
+ { retries: 2 }
370
+ )
371
+ await service.setStepRunning(step.stepId)
372
+ await service.setStepError(step.stepId, new Error('first try'))
373
+ // Guarantee the retry's history row sorts after the failed attempt so
374
+ // getRunStatus picks the latest attempt (it tie-breaks on timestamps).
375
+ await new Promise((resolve) => setTimeout(resolve, 5))
376
+ const retry = await service.createRetryAttempt(step.stepId, 'pending')
377
+ await service.setStepRunning(retry.stepId)
378
+ await service.setStepResult(retry.stepId, { ok: true })
379
+
380
+ const status = await service.getRunStatus(runId)
381
+ assert.ok(status)
382
+ const flaky = status.steps.find((s) => s.name === 'flaky-step')
383
+ assert.ok(flaky, 'retried step collapses to a single status entry')
384
+ assert.equal(flaky.status, 'succeeded', 'latest attempt wins')
385
+ assert.equal(flaky.attempts, 2, 'attempt count surfaces in status')
386
+ })
387
+
323
388
  test('getNodesWithoutSteps', async () => {
324
389
  const runId = await service.createRun(
325
390
  'graph-workflow',
@@ -119,8 +119,12 @@ export type FunctionRuntimeMeta = {
119
119
  readonly?: boolean
120
120
  deploy?: 'serverless' | 'server' | 'auto'
121
121
  sessionless?: boolean
122
- /** When false, workflow steps calling this function are dispatched via the queue. Defaults to true (inline). */
123
- inline?: boolean
122
+ /** When true, workflow steps calling this function are dispatched via the queue. No queue service configured is a hard error. */
123
+ workflowQueued?: boolean
124
+ /** Retry count when this function is used as a workflow step. */
125
+ workflowRetries?: number
126
+ /** Timeout when this function is used as a workflow step (e.g. '30s', '5m'). */
127
+ workflowTimeout?: string
124
128
  version?: number
125
129
  approvalRequired?: boolean
126
130
  approvalDescription?: string
@@ -558,5 +562,9 @@ export interface SerializedError {
558
562
  message: string
559
563
  stack?: string
560
564
  code?: string
565
+ // True when the original error was a deliberate, expected failure (a
566
+ // PikkuError). Survives the step-boundary rehydration so the workflow runner
567
+ // can log the message alone instead of a stack trace. See isExpectedError.
568
+ expected?: boolean
561
569
  [key: string]: any
562
570
  }
@@ -1,4 +1,5 @@
1
1
  import { NotFoundError } from '../../errors/errors.js'
2
+ import { isExpectedError } from '../../errors/error-handler.js'
2
3
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js'
3
4
  import { pikkuState } from '../../pikku-state.js'
4
5
  import type {
@@ -543,10 +544,17 @@ export async function executeCLI({
543
544
  throw error
544
545
  }
545
546
 
546
- // Wrap other errors in CLIError
547
- console.error('Error:', error)
547
+ // An expected failure (a deliberate PikkuError, e.g. a build gate
548
+ // tripping) — its message says everything, so print that alone (no
549
+ // `Error:` prefix). Anything else is an uncaught error: log the object so
550
+ // its stack shows.
551
+ if (isExpectedError(error)) {
552
+ console.error(error.message)
553
+ } else {
554
+ console.error('Error:', error)
555
+ }
548
556
 
549
- // Show stack trace in verbose mode
557
+ // Show stack trace in verbose mode (even for expected errors).
550
558
  if (args.includes('--verbose') || args.includes('-v')) {
551
559
  console.error('Stack trace:', error.stack)
552
560
  }
@@ -416,6 +416,78 @@ describe('graph-runner bugs', () => {
416
416
  delete metaState['testInlineFailure']
417
417
  })
418
418
 
419
+ test('inline graph revisits a cyclic node and records the walked path via fromStepName', async () => {
420
+ const ws = new InMemoryWorkflowService()
421
+
422
+ // attempt loops back to itself via `again` until it converges to `done`.
423
+ const mockRpcService = {
424
+ rpcWithWire: async (rpcName: string, _data: any, wire: any) => {
425
+ if (rpcName === 'cyclicBegin') return { attempts: 3 }
426
+ if (rpcName === 'cyclicAttempt') {
427
+ const state = await wire.graph.getState()
428
+ const count = ((state.count as number) ?? 0) + 1
429
+ await wire.graph.setState('count', count)
430
+ wire.graph.branch(count >= 3 ? 'done' : 'again')
431
+ return { count }
432
+ }
433
+ if (rpcName === 'cyclicFinish') return { ok: true }
434
+ return {}
435
+ },
436
+ }
437
+
438
+ const metaState = pikkuState(null, 'workflows', 'meta')
439
+ metaState['testInlineCyclic'] = {
440
+ name: 'testInlineCyclic',
441
+ pikkuFuncId: 'testInlineCyclic',
442
+ source: 'graph',
443
+ entryNodeIds: ['begin'],
444
+ graphHash: 'inline-cyclic-hash',
445
+ nodes: {
446
+ begin: { nodeId: 'begin', rpcName: 'cyclicBegin', next: 'attempt' },
447
+ attempt: {
448
+ nodeId: 'attempt',
449
+ rpcName: 'cyclicAttempt',
450
+ next: { again: 'attempt', done: 'finish' },
451
+ },
452
+ finish: { nodeId: 'finish', rpcName: 'cyclicFinish' },
453
+ },
454
+ }
455
+
456
+ const { runId } = await runWorkflowGraph(
457
+ ws,
458
+ 'testInlineCyclic',
459
+ {},
460
+ mockRpcService,
461
+ true
462
+ )
463
+
464
+ const run = await ws.getRun(runId)
465
+ assert.equal(run?.status, 'completed')
466
+
467
+ // The self-cycle produced three ordinal instances of `attempt`.
468
+ const instances = await ws.getStepInstances(runId)
469
+ const names = instances.map((i) => i.stepName).sort()
470
+ assert.ok(
471
+ names.includes('attempt') &&
472
+ names.includes('attempt#1') &&
473
+ names.includes('attempt#2'),
474
+ `expected attempt/attempt#1/attempt#2, got ${JSON.stringify(names)}`
475
+ )
476
+
477
+ // Walk the fromStepName chain back from the terminal node — inline now
478
+ // records provenance identical to the queued path.
479
+ const from = new Map(instances.map((i) => [i.stepName, i.fromStepName]))
480
+ const path: string[] = []
481
+ let cursor: string | undefined = 'finish'
482
+ while (cursor) {
483
+ path.unshift(cursor)
484
+ cursor = from.get(cursor) ?? undefined
485
+ }
486
+ assert.deepEqual(path, ['begin', 'attempt', 'attempt#1', 'attempt#2', 'finish'])
487
+
488
+ delete metaState['testInlineCyclic']
489
+ })
490
+
419
491
  test('executeGraphStep should throw after queueing onError nodes', async () => {
420
492
  const ws = new InMemoryWorkflowService()
421
493