@pikku/core 0.12.55 → 0.12.57

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.
@@ -1,20 +1,20 @@
1
1
  import { watch, type FSWatcher } from 'node:fs'
2
- import { stat, readFile } from 'node:fs/promises'
3
- import { join, resolve, relative } from 'node:path'
2
+ import { stat, readFile, copyFile, rm } from 'node:fs/promises'
3
+ import { basename, dirname, join, resolve, relative } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
 
6
6
  import { register } from 'tsx/esm/api'
7
7
 
8
8
  import { pikkuState } from '../pikku-state.js'
9
- import { addFunction } from '../function/function-runner.js'
10
9
  import { clearMiddlewareCache } from '../middleware-runner.js'
11
10
  import { clearPermissionsCache } from '../permissions.js'
12
11
  import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js'
13
12
  import { httpRouter } from '../wirings/http/routers/http-router.js'
14
- import { addSchema, compileAllSchemas } from '../schema.js'
15
13
  import type { Logger } from '../services/logger.js'
16
14
  import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
17
15
 
16
+ export * from './reload-meta.js'
17
+
18
18
  interface PikkuDevReloaderOptions {
19
19
  srcDirectories: string[]
20
20
  logger: Logger
@@ -65,16 +65,37 @@ const ensureTsxRegistered = () => {
65
65
  tsxRegistered = true
66
66
  }
67
67
 
68
+ let tempCounter = 0
69
+
68
70
  const reimportModule = async (
69
71
  filePath: string,
70
72
  useTsx = false
71
73
  ): Promise<Record<string, unknown> | null> => {
72
74
  try {
73
75
  if (useTsx) {
74
- ensureTsxRegistered()
75
- return await import(
76
- `${pathToFileURL(resolve(filePath)).href}?t=${Date.now()}`
76
+ // Import a uniquely-named sibling copy: a `?t=` query does NOT bust
77
+ // the cache on either runtime (Bun keys module identity on the bare
78
+ // path; tsx's transform cache keys on the file path too), so a fresh
79
+ // path is the only reliable re-import. Same directory → identical
80
+ // resolution for relative and package-`imports` (#…) specifiers. The
81
+ // dot-prefix keeps it out of the watcher.
82
+ if (!process.versions.bun) {
83
+ // Node needs tsx's loader to import raw .ts; Bun imports it natively.
84
+ ensureTsxRegistered()
85
+ }
86
+ const abs = resolve(filePath)
87
+ const tempPath = join(
88
+ dirname(abs),
89
+ `.pikku-hot-${++tempCounter}-${basename(abs)}`
77
90
  )
91
+ await copyFile(abs, tempPath)
92
+ try {
93
+ return await import(pathToFileURL(tempPath).href)
94
+ } finally {
95
+ await rm(tempPath, { force: true }).catch(() => {
96
+ // Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
97
+ })
98
+ }
78
99
  }
79
100
 
80
101
  const content = await readFile(resolve(filePath), 'utf-8')
@@ -91,13 +112,23 @@ const isWatchedTsFile = (filename: string): boolean => {
91
112
  filename.endsWith('.ts') &&
92
113
  !filename.endsWith('.test.ts') &&
93
114
  !filename.endsWith('.d.ts') &&
94
- !filename.endsWith('.gen.ts')
115
+ !filename.endsWith('.gen.ts') &&
116
+ // Hidden files: editor/sed atomic-write temps and our own hot-reload
117
+ // sibling copies must never trigger a reload of themselves.
118
+ !basename(filename).startsWith('.')
95
119
  )
96
120
  }
97
121
 
122
+ export interface PikkuDevReloaderHandle {
123
+ close: () => void
124
+ /** Re-import every file changed since the last drain (post-codegen, once
125
+ * fresh meta is in state). */
126
+ reimportPending: () => Promise<void>
127
+ }
128
+
98
129
  export async function pikkuDevReloader(
99
130
  options: PikkuDevReloaderOptions
100
- ): Promise<{ close: () => void }> {
131
+ ): Promise<PikkuDevReloaderHandle> {
101
132
  const { srcDirectories, logger, pikkuDir = '.pikku' } = options
102
133
  const absSrcDirs = srcDirectories.map((d) => resolve(d))
103
134
  const absPikkuDir = resolve(pikkuDir)
@@ -108,6 +139,7 @@ export async function pikkuDevReloader(
108
139
  const handleFileChange = async (changedTsFile: string) => {
109
140
  const start = Date.now()
110
141
  const reloadedNames: string[] = []
142
+ const addedNames: string[] = []
111
143
 
112
144
  const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d))
113
145
  if (!srcDir) return
@@ -134,54 +166,59 @@ export async function pikkuDevReloader(
134
166
  return
135
167
  }
136
168
 
137
- let schemasChanged = false
138
-
169
+ // Register every function-config export — replacing known functions AND
170
+ // adding new ones (a brand-new function becomes callable as soon as its
171
+ // meta lands via reloadGeneratedMeta after the next codegen pass).
172
+ // Write into the map captured at startup, NOT pikkuState's current one:
173
+ // a dev-server watcher may have temporarily swapped in a codegen-scoped
174
+ // map for the same file event (runAllWithCommandState), and a write to
175
+ // that map is silently discarded when it restores the original.
176
+ // Schemas are NOT touched here: `input`/`output` hold raw zod schemas,
177
+ // while the schema map carries codegen-generated JSON schemas — mixing the
178
+ // two crashed every reload. Fresh JSON schemas arrive via
179
+ // reloadGeneratedMeta once codegen has re-emitted them.
139
180
  for (const [exportName, exportValue] of Object.entries(mod)) {
140
- if (isFunctionConfig(exportValue) && functionsMap.has(exportName)) {
141
- addFunction(exportName, exportValue)
142
- reloadedNames.push(exportName)
143
-
144
- if (exportValue.input) {
145
- addSchema(exportName, exportValue.input)
146
- schemasChanged = true
147
- }
148
- if (exportValue.output) {
149
- addSchema(`${exportName}Output`, exportValue.output)
150
- schemasChanged = true
151
- }
152
- }
181
+ if (!isFunctionConfig(exportValue)) continue
182
+ const isNew = !functionsMap.has(exportName)
183
+ functionsMap.set(exportName, exportValue)
184
+ if (isNew) addedNames.push(exportName)
185
+ else reloadedNames.push(exportName)
153
186
  }
154
187
 
155
- if (reloadedNames.length > 0) {
156
- clearMiddlewareCache()
157
- clearPermissionsCache()
158
- clearChannelMiddlewareCache()
159
- httpRouter.reset()
160
-
161
- if (schemasChanged) {
162
- try {
163
- compileAllSchemas(logger)
164
- } catch (err) {
165
- const msg = err instanceof Error ? err.message : String(err)
166
- if (
167
- msg.includes('SchemaService') ||
168
- (msg.includes('schema') && msg.includes('not'))
169
- ) {
170
- logger.warn('Schema recompilation skipped (no SchemaService)')
171
- } else {
172
- logger.error(`Schema recompilation failed: ${msg}`)
173
- return
174
- }
175
- }
176
- }
188
+ // Re-importing the module re-ran its wire* side effects (wireHTTP et al
189
+ // are keyed map-sets, so re-registration replaces/adds) — reset the
190
+ // router and caches even when no function export changed, so a
191
+ // wiring-only file edit rebuilds the route matchers too.
192
+ clearMiddlewareCache()
193
+ clearPermissionsCache()
194
+ clearChannelMiddlewareCache()
195
+ httpRouter.reset()
177
196
 
197
+ if (reloadedNames.length > 0 || addedNames.length > 0) {
178
198
  const elapsed = Date.now() - start
179
- logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`)
199
+ const parts: string[] = []
200
+ if (reloadedNames.length > 0) parts.push(reloadedNames.join(', '))
201
+ if (addedNames.length > 0) parts.push(`new: ${addedNames.join(', ')}`)
202
+ logger.info(`Hot-reloaded: ${parts.join('; ')} (${elapsed}ms)`)
180
203
  }
181
204
  }
182
205
 
183
206
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
184
207
  const pendingChanges = new Set<string>()
208
+ // Files re-imported since the last reimportPending() drain. A dev-server
209
+ // watcher drains this after its codegen pass so wire* registrations that
210
+ // were skipped for missing meta (a NEW route) run again with fresh meta.
211
+ const postCodegenQueue = new Set<string>()
212
+
213
+ const safeHandleFileChange = async (file: string) => {
214
+ try {
215
+ await handleFileChange(file)
216
+ } catch (err) {
217
+ logger.error(
218
+ `Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
219
+ )
220
+ }
221
+ }
185
222
 
186
223
  const scheduleReload = (filePath: string) => {
187
224
  pendingChanges.add(filePath)
@@ -190,13 +227,8 @@ export async function pikkuDevReloader(
190
227
  const files = [...pendingChanges]
191
228
  pendingChanges.clear()
192
229
  for (const file of files) {
193
- try {
194
- await handleFileChange(file)
195
- } catch (err) {
196
- logger.error(
197
- `Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`
198
- )
199
- }
230
+ postCodegenQueue.add(file)
231
+ await safeHandleFileChange(file)
200
232
  }
201
233
  }, 50)
202
234
  }
@@ -229,5 +261,18 @@ export async function pikkuDevReloader(
229
261
  watcher.close()
230
262
  }
231
263
  },
264
+ // Drain the queue of recently re-imported files and import them again.
265
+ // A dev-server watcher calls this AFTER its codegen pass has refreshed
266
+ // the generated meta (reloadGeneratedMeta): wire* registrations skip
267
+ // routes whose meta doesn't exist yet, so a wiring file changed
268
+ // alongside a NEW function only registers its new route when
269
+ // re-imported after the fresh meta has landed.
270
+ reimportPending: async () => {
271
+ const files = [...postCodegenQueue]
272
+ postCodegenQueue.clear()
273
+ for (const file of files) {
274
+ await safeHandleFileChange(file)
275
+ }
276
+ },
232
277
  }
233
278
  }
@@ -0,0 +1,154 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+
7
+ import { pikkuState, resetPikkuState } from '../pikku-state.js'
8
+ import { getSchema } from '../schema.js'
9
+ import { reloadGeneratedMeta } from './reload-meta.js'
10
+
11
+ const createMockLogger = () => {
12
+ const logs: Array<{ level: string; message: string }> = []
13
+ return {
14
+ info: (msg: string) => logs.push({ level: 'info', message: String(msg) }),
15
+ warn: (msg: string) => logs.push({ level: 'warn', message: String(msg) }),
16
+ error: (msg: string | Error) =>
17
+ logs.push({
18
+ level: 'error',
19
+ message: msg instanceof Error ? msg.message : String(msg),
20
+ }),
21
+ debug: (msg: string) => logs.push({ level: 'debug', message: String(msg) }),
22
+ getLogs: () => logs,
23
+ setLevel: () => {},
24
+ }
25
+ }
26
+
27
+ const compiled: string[] = []
28
+ const mockSchemaService = {
29
+ compileSchema: (name: string, _value: any) => {
30
+ compiled.push(name)
31
+ },
32
+ validateSchema: () => {},
33
+ getSchemaNames: () => new Set(compiled),
34
+ getSchemaKeys: () => [],
35
+ } as any
36
+
37
+ describe('reloadGeneratedMeta', { concurrency: false }, () => {
38
+ let tmpDir: string
39
+ let mockLogger: ReturnType<typeof createMockLogger>
40
+
41
+ beforeEach(async () => {
42
+ resetPikkuState()
43
+ compiled.length = 0
44
+ tmpDir = await mkdtemp(join(tmpdir(), 'pikku-reload-meta-test-'))
45
+ mockLogger = createMockLogger()
46
+ })
47
+
48
+ afterEach(async () => {
49
+ await rm(tmpDir, { recursive: true, force: true })
50
+ })
51
+
52
+ test('reads generated meta json into pikku state', async () => {
53
+ await mkdir(join(tmpDir, 'function'), { recursive: true })
54
+ await mkdir(join(tmpDir, 'rpc'), { recursive: true })
55
+ await writeFile(
56
+ join(tmpDir, 'function/pikku-functions-meta.gen.json'),
57
+ JSON.stringify({
58
+ newFunc: { pikkuFuncId: 'newFunc', inputSchemaName: 'NewFuncInput' },
59
+ })
60
+ )
61
+ await writeFile(
62
+ join(tmpDir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json'),
63
+ JSON.stringify({ newFunc: 'newFunc' })
64
+ )
65
+
66
+ await reloadGeneratedMeta({
67
+ pikkuDir: tmpDir,
68
+ logger: mockLogger,
69
+ schemaService: mockSchemaService,
70
+ })
71
+
72
+ const functionsMeta = pikkuState(null, 'function', 'meta') as any
73
+ assert.equal(functionsMeta.newFunc.pikkuFuncId, 'newFunc')
74
+ const rpcMeta = pikkuState(null, 'rpc', 'meta') as any
75
+ assert.equal(rpcMeta.newFunc, 'newFunc')
76
+ })
77
+
78
+ test('preserves runtime-registered internals (workflow orchestrator) across reload', async () => {
79
+ // The workflow service registers these at init; they are never in the
80
+ // generated JSON, so the reload must merge — not replace — the meta maps.
81
+ pikkuState(null, 'function', 'meta', {
82
+ pikkuWorkflowOrchestrator: { pikkuFuncId: 'pikkuWorkflowOrchestrator' },
83
+ })
84
+ pikkuState(null, 'queue', 'meta', {
85
+ 'pikku-workflow-orchestrator': {
86
+ pikkuFuncId: 'pikkuWorkflowOrchestrator',
87
+ name: 'pikku-workflow-orchestrator',
88
+ },
89
+ })
90
+
91
+ await mkdir(join(tmpDir, 'function'), { recursive: true })
92
+ await mkdir(join(tmpDir, 'queue'), { recursive: true })
93
+ await writeFile(
94
+ join(tmpDir, 'function/pikku-functions-meta.gen.json'),
95
+ JSON.stringify({ userFunc: { pikkuFuncId: 'userFunc' } })
96
+ )
97
+ await writeFile(
98
+ join(tmpDir, 'queue/pikku-queue-workers-wirings-meta.gen.json'),
99
+ JSON.stringify({ userQueue: { pikkuFuncId: 'userFunc', name: 'userQueue' } })
100
+ )
101
+
102
+ await reloadGeneratedMeta({
103
+ pikkuDir: tmpDir,
104
+ logger: mockLogger,
105
+ schemaService: mockSchemaService,
106
+ })
107
+
108
+ const functionsMeta = pikkuState(null, 'function', 'meta') as any
109
+ assert.equal(
110
+ functionsMeta.pikkuWorkflowOrchestrator?.pikkuFuncId,
111
+ 'pikkuWorkflowOrchestrator',
112
+ 'runtime-registered internal must survive the reload'
113
+ )
114
+ assert.equal(functionsMeta.userFunc?.pikkuFuncId, 'userFunc')
115
+ const queueMeta = pikkuState(null, 'queue', 'meta') as any
116
+ assert.equal(
117
+ queueMeta['pikku-workflow-orchestrator']?.pikkuFuncId,
118
+ 'pikkuWorkflowOrchestrator'
119
+ )
120
+ assert.equal(queueMeta.userQueue?.name, 'userQueue')
121
+ })
122
+
123
+ test('re-adds generated json schemas and recompiles them', async () => {
124
+ const schemasDir = join(tmpDir, 'schemas', 'schemas')
125
+ await mkdir(schemasDir, { recursive: true })
126
+ await writeFile(
127
+ join(schemasDir, 'NewFuncInput.schema.json'),
128
+ JSON.stringify({ type: 'object', properties: { id: { type: 'string' } } })
129
+ )
130
+
131
+ await reloadGeneratedMeta({
132
+ pikkuDir: tmpDir,
133
+ logger: mockLogger,
134
+ schemaService: mockSchemaService,
135
+ })
136
+
137
+ const schema = getSchema('NewFuncInput') as any
138
+ assert.equal(schema.type, 'object')
139
+ assert.ok(
140
+ compiled.includes('NewFuncInput'),
141
+ 'Schema should be recompiled after reload'
142
+ )
143
+ })
144
+
145
+ test('missing meta files and schemas dir are not an error', async () => {
146
+ await reloadGeneratedMeta({
147
+ pikkuDir: tmpDir,
148
+ logger: mockLogger,
149
+ schemaService: mockSchemaService,
150
+ })
151
+ const errors = mockLogger.getLogs().filter((l) => l.level === 'error')
152
+ assert.deepEqual(errors, [])
153
+ })
154
+ })
@@ -0,0 +1,138 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { pikkuState } from '../pikku-state.js'
5
+ import { addSchema, compileAllSchemas } from '../schema.js'
6
+ import { clearMiddlewareCache } from '../middleware-runner.js'
7
+ import { clearPermissionsCache } from '../permissions.js'
8
+ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js'
9
+ import { httpRouter } from '../wirings/http/routers/http-router.js'
10
+ import type { Logger } from '../services/logger.js'
11
+ import type { SchemaService } from '../services/schema-service.js'
12
+
13
+ export interface ReloadGeneratedMetaOptions {
14
+ /** The project's generated output directory (the CLI's resolved outDir). */
15
+ pikkuDir: string
16
+ logger: Logger
17
+ /** Used to recompile validators for changed schemas; falls back to the
18
+ * schema service on the registered singleton services. */
19
+ schemaService?: SchemaService
20
+ }
21
+
22
+ const readJson = async (
23
+ logger: Logger,
24
+ file: string
25
+ ): Promise<any | undefined> => {
26
+ let raw: string
27
+ try {
28
+ raw = await readFile(file, 'utf-8')
29
+ } catch {
30
+ // The project doesn't use this wiring type — nothing generated.
31
+ return undefined
32
+ }
33
+ try {
34
+ return JSON.parse(raw)
35
+ } catch (err) {
36
+ logger.error(
37
+ `Hot-reload could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`
38
+ )
39
+ return undefined
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Re-reads the codegen output (wiring meta + JSON schemas) into the running
45
+ * process so new and changed functions become callable without a server
46
+ * restart. The generated `*-meta.gen.ts` files are plain
47
+ * `pikkuState(area, key, <json>)` side effects, but they cannot be
48
+ * re-imported after a dev-time codegen — the ESM cache pins both the wrapper
49
+ * and its JSON import — so this reads the JSON sources directly and applies
50
+ * the same state.
51
+ *
52
+ * Meant to be called by a dev-server watcher after each codegen pass. Routes
53
+ * registered by NEW `wireHTTP` files are not picked up (their modules were
54
+ * never imported); those still need a restart.
55
+ */
56
+ export async function reloadGeneratedMeta(
57
+ options: ReloadGeneratedMetaOptions
58
+ ): Promise<void> {
59
+ const { pikkuDir, logger, schemaService } = options
60
+ const dir = resolve(pikkuDir)
61
+
62
+ const functionsMeta = await readJson(
63
+ logger,
64
+ join(dir, 'function/pikku-functions-meta.gen.json')
65
+ )
66
+ // Merge over the existing map, don't replace it: framework internals like
67
+ // pikkuWorkflowOrchestrator / the per-workflow queue workers are registered
68
+ // at service-init (pikku-workflow-service.ts), never in the generated JSON —
69
+ // a wholesale replace drops them and workflow jobs then fail with
70
+ // "Function meta not found: pikkuWorkflowOrchestrator".
71
+ if (functionsMeta) {
72
+ const existing = pikkuState(null, 'function', 'meta') ?? {}
73
+ pikkuState(null, 'function', 'meta', { ...existing, ...functionsMeta })
74
+ }
75
+
76
+ const httpMeta = await readJson(
77
+ logger,
78
+ join(dir, 'http/pikku-http-wirings-meta.gen.json')
79
+ )
80
+ if (httpMeta) pikkuState(null, 'http', 'meta', httpMeta)
81
+
82
+ const rpcMeta = await readJson(
83
+ logger,
84
+ join(dir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json')
85
+ )
86
+ if (rpcMeta) pikkuState(null, 'rpc', 'meta', rpcMeta)
87
+
88
+ const queueMeta = await readJson(
89
+ logger,
90
+ join(dir, 'queue/pikku-queue-workers-wirings-meta.gen.json')
91
+ )
92
+ // Same reason as function meta: the workflow service adds its orchestrator /
93
+ // step queues (and wf-orchestrator-* / wf-step-* per-workflow queues) here at
94
+ // init, absent from the generated JSON — merge so they survive the reload.
95
+ if (queueMeta) {
96
+ const existing = pikkuState(null, 'queue', 'meta') ?? {}
97
+ pikkuState(null, 'queue', 'meta', { ...existing, ...queueMeta })
98
+ }
99
+
100
+ const agentMeta = await readJson(
101
+ logger,
102
+ join(dir, 'agent/pikku-agent-wirings-meta.gen.json')
103
+ )
104
+ if (agentMeta?.agentsMeta) {
105
+ pikkuState(null, 'agent', 'agentsMeta', agentMeta.agentsMeta)
106
+ }
107
+
108
+ // Generated JSON schemas: <outDir>/schemas/schemas/<Name>.schema.json.
109
+ // Re-adding replaces the map entry; the schema service recompiles any
110
+ // validator whose stored schema value no longer matches.
111
+ const schemasDir = join(dir, 'schemas', 'schemas')
112
+ let schemaFiles: string[] = []
113
+ try {
114
+ schemaFiles = await readdir(schemasDir)
115
+ } catch {
116
+ // No generated schemas — a schema-less project.
117
+ }
118
+ for (const file of schemaFiles) {
119
+ if (!file.endsWith('.schema.json')) continue
120
+ const schema = await readJson(logger, join(schemasDir, file))
121
+ if (schema) {
122
+ addSchema(file.slice(0, -'.schema.json'.length), schema)
123
+ }
124
+ }
125
+
126
+ clearMiddlewareCache()
127
+ clearPermissionsCache()
128
+ clearChannelMiddlewareCache()
129
+ httpRouter.reset()
130
+
131
+ try {
132
+ compileAllSchemas(logger, schemaService)
133
+ } catch (err) {
134
+ logger.error(
135
+ `Hot-reload schema recompilation failed: ${err instanceof Error ? err.message : String(err)}`
136
+ )
137
+ }
138
+ }
@@ -921,14 +921,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
921
921
  ): JobOptions {
922
922
  const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
923
923
  const retryDelay = stepOptions?.retryDelay
924
+ // A concrete retryDelay (15000, '15s') is a fixed backoff; only the literal
925
+ // 'exponential' — or no delay at all — selects exponential.
924
926
  const backoff =
925
- typeof retryDelay === 'number'
926
- ? { type: 'fixed', delay: retryDelay }
927
- : retryDelay === 'exponential'
927
+ retryDelay !== undefined && retryDelay !== 'exponential'
928
+ ? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
929
+ : retries > 0 || retryDelay === 'exponential'
928
930
  ? 'exponential'
929
- : retries > 0
930
- ? 'exponential'
931
- : undefined
931
+ : undefined
932
932
  return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
933
933
  }
934
934
 
@@ -48,6 +48,13 @@ describe('resolveStepJobOptions — workflow owns retry policy', () => {
48
48
  })
49
49
  })
50
50
 
51
+ test("duration-string retryDelay ('15s') → fixed backoff in ms", () => {
52
+ assert.deepEqual(ws.resolve({ retries: 3, retryDelay: '15s' }), {
53
+ attempts: 4,
54
+ backoff: { type: 'fixed', delay: 15000 },
55
+ })
56
+ })
57
+
51
58
  test("retryDelay: 'exponential' → exponential backoff", () => {
52
59
  assert.deepEqual(ws.resolve({ retries: 2, retryDelay: 'exponential' }), {
53
60
  attempts: 3,