@pikku/core 0.12.77 → 0.12.79

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.
@@ -142,6 +142,24 @@ const WORKFLOW_TERMINAL_STATES = new Set([
142
142
  'failed',
143
143
  'cancelled',
144
144
  ]);
145
+ /** Idle window before a `running` run with nothing in flight is treated as stalled. */
146
+ const DEFAULT_STALLED_RUN_MS = 5 * 60_000;
147
+ /** Runs re-driven per `recoverStalledRuns` call, so one sweep is bounded. */
148
+ const DEFAULT_STALLED_RUN_LIMIT = 100;
149
+ /**
150
+ * How long a step may sit `pending` before the relay assumes its dispatch was
151
+ * lost. This is a bet that no healthy queue takes this long to move a job from
152
+ * `pending` to `running`; set it above the observed p99 of that latency.
153
+ */
154
+ const DEFAULT_UNDISPATCHED_STEP_MS = 30_000;
155
+ /** Steps re-driven per `relayUndispatchedSteps` call, so one tick is bounded. */
156
+ const DEFAULT_UNDISPATCHED_STEP_LIMIT = 100;
157
+ /** First wait before a step already re-dispatched once is re-dispatched again. */
158
+ const REDISPATCH_BACKOFF_MS = 30_000;
159
+ /** Ceiling on the doubling backoff, so a permanently stuck step still gets swept. */
160
+ const REDISPATCH_BACKOFF_MAX_MS = 10 * 60_000;
161
+ /** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
162
+ const REDISPATCH_BACKOFF_MAX_ENTRIES = 10_000;
145
163
  const WORKFLOW_POLL_MIN_MS = 10;
146
164
  const WORKFLOW_POLL_FACTOR = 1.6;
147
165
  const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -435,6 +453,143 @@ export class PikkuWorkflowService {
435
453
  group: this.getJobGroup(workflowName),
436
454
  });
437
455
  }
456
+ /**
457
+ * Ids of runs that are stalled: still `running`, with no step in a state that
458
+ * something is expected to complete (`running`, `scheduled`, `suspended`),
459
+ * and no step activity since `before`.
460
+ *
461
+ * Returns nothing by default so a store that cannot express the query keeps
462
+ * working unchanged; a store that overrides it gains crash recovery through
463
+ * `recoverStalledRuns`.
464
+ */
465
+ async findStalledRunIds(_before, _limit) {
466
+ return [];
467
+ }
468
+ /**
469
+ * Re-drive runs whose next move was lost, and report which were resumed.
470
+ *
471
+ * Arming a step is two writes to two systems — the step row, then the queue
472
+ * or scheduler job — so a process that dies between them leaves a run that is
473
+ * `running` with nothing in flight. Nothing notices: the run parks on a step
474
+ * that will never complete and never error, so it neither finishes nor fails.
475
+ * (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
476
+ * step's insert and its timer, parking the run permanently.)
477
+ *
478
+ * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted
479
+ * step state, and every settled step is memoized, so resuming a run that was
480
+ * not actually stuck costs an orchestration pass and changes nothing. That
481
+ * idempotence is what makes an idle-time heuristic safe here; a run that is
482
+ * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
483
+ *
484
+ * This is not self-starting. Call it from a scheduled task at whatever
485
+ * interval suits the workload.
486
+ */
487
+ async recoverStalledRuns(options) {
488
+ const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
489
+ const runIds = await this.findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
490
+ const resumed = [];
491
+ for (const runId of runIds) {
492
+ try {
493
+ await this.resumeWorkflow(runId);
494
+ resumed.push(runId);
495
+ }
496
+ catch (err) {
497
+ // One unresumable run must not stop the sweep from recovering the rest.
498
+ getSingletonServices()?.logger?.error(`Failed to resume stalled workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`);
499
+ }
500
+ }
501
+ return { resumed };
502
+ }
503
+ /**
504
+ * Runs holding a step that has sat `pending` since before `before`, paired
505
+ * with the step that flagged them.
506
+ *
507
+ * Returns nothing by default so a store that cannot express the query keeps
508
+ * working unchanged — and, because it does not opt in, gains no re-dispatches
509
+ * either. A store must have an atomic `withStepLock` before overriding this,
510
+ * or no concurrency for one to exclude: the relay makes duplicate dispatch
511
+ * routine, and the claim in `executeWorkflowStepInner` is what keeps a
512
+ * duplicate from becoming a second execution. `kysely-postgres` and
513
+ * `kysely-mysql` qualify on the lock, `in-memory` on being inline and
514
+ * single-process; `mongodb` and `kysely-sqlite` qualify on neither.
515
+ */
516
+ async findUndispatchedSteps(_before, _limit) {
517
+ return [];
518
+ }
519
+ /**
520
+ * Re-drive steps whose dispatch was lost, and report which runs were nudged.
521
+ *
522
+ * Arming a step is two writes to two systems: the step row lands `pending`,
523
+ * then a queue or scheduler job is published. Nothing spans both, so a crash
524
+ * in between leaves a durable row that nothing will ever pick up — the run
525
+ * neither finishes nor fails. (Seen on a `workflow.sleep()`: a deploy restart
526
+ * landed between the sleep step's insert and its timer.)
527
+ *
528
+ * The row is the outbox record and this is the relay. Age is the only signal
529
+ * available — a step `pending` because its dispatch was lost is
530
+ * indistinguishable from one whose job is merely still queued — so a step
531
+ * past `undispatchedAfterMs` is re-dispatched regardless, and correctness
532
+ * rests on the claim rather than on the guess being right. A redundant
533
+ * dispatch costs one queue message: the loser reads `running` and returns
534
+ * without invoking anything.
535
+ *
536
+ * Re-dispatches back off per step (doubling from 30s, capped at 10m) so a
537
+ * genuine queue backlog is not amplified by a tick that keeps firing at the
538
+ * steps the backlog is already delaying. The backoff is per process and
539
+ * advisory — losing it on restart costs extra dispatches, never correctness.
540
+ *
541
+ * This is not self-starting. Call it from a scheduled task; ~30s suits a
542
+ * queue whose `pending`→`running` latency is well under that.
543
+ */
544
+ async relayUndispatchedSteps(options) {
545
+ const before = new Date(Date.now() -
546
+ (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS));
547
+ const steps = await this.findUndispatchedSteps(before, options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT);
548
+ // Backoff is keyed by run, not step, because the run is the unit of
549
+ // re-drive: `resumeWorkflow` replays the whole run and re-dispatches every
550
+ // step still owed a job. Holding off a single step while resuming its run
551
+ // would not suppress anything.
552
+ const now = Date.now();
553
+ const runIds = new Set();
554
+ for (const { runId } of steps) {
555
+ const eligibleAt = this.redispatchBackoff.get(runId);
556
+ if (eligibleAt !== undefined && eligibleAt > now) {
557
+ continue;
558
+ }
559
+ this.noteRedispatch(runId, now);
560
+ runIds.add(runId);
561
+ }
562
+ const redispatched = [];
563
+ for (const runId of runIds) {
564
+ try {
565
+ await this.resumeWorkflow(runId);
566
+ redispatched.push(runId);
567
+ }
568
+ catch (err) {
569
+ // One unresumable run must not stop the tick from relaying the rest.
570
+ getSingletonServices()?.logger?.error(`Failed to re-dispatch workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`);
571
+ }
572
+ }
573
+ return { redispatched };
574
+ }
575
+ /** Advisory, per-process record of when a run may next be re-dispatched. */
576
+ redispatchBackoff = new Map();
577
+ redispatchDelays = new Map();
578
+ noteRedispatch(runId, now) {
579
+ const previous = this.redispatchDelays.get(runId);
580
+ const delay = Math.min(previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2, REDISPATCH_BACKOFF_MAX_MS);
581
+ // A run that settles is never returned again, so entries are only evicted by
582
+ // this bound — oldest first, which is also least recently re-dispatched.
583
+ if (this.redispatchBackoff.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
584
+ const oldest = this.redispatchBackoff.keys().next();
585
+ if (!oldest.done) {
586
+ this.redispatchBackoff.delete(oldest.value);
587
+ this.redispatchDelays.delete(oldest.value);
588
+ }
589
+ }
590
+ this.redispatchDelays.set(runId, delay);
591
+ this.redispatchBackoff.set(runId, now + delay);
592
+ }
438
593
  resolveStepJobOptions(stepOptions) {
439
594
  const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
440
595
  const retryDelay = stepOptions?.retryDelay;
@@ -17,7 +17,16 @@ runner's gate is the only thing that evaluates a function's declared `auth`,
17
17
  `scopes` and `permissions`; a direct call runs the handler with none of them
18
18
  checked.
19
19
 
20
- The handler is registered with `sessionless: true`. Gateway inbound traffic is
20
+ The synthetic registration **inherits the wired function's own metadata**
21
+ `sessionless`, input and output schema names, `scopes`, tag middleware — read
22
+ back from `pikkuState(null, 'gateway', 'meta')[name].pikkuFuncId`, which the
23
+ inspector already records. The synthetic id exists so the gate runs; it is not a
24
+ licence to run the handler under metadata its author never wrote. A handler
25
+ declared with `pikkuFunc` says session-required in meta rather than through an
26
+ `auth` property, and that declaration is honoured.
27
+
28
+ When nothing was declared — no inspector entry, as when a gateway is wired by
29
+ hand — the handler falls back to `sessionless: true`. Gateway inbound traffic is
21
30
  authenticated by the platform adapter (webhook signature verification, platform
22
31
  tokens), not by a user session, so defaulting to session-required would reject
23
32
  every legitimate webhook. `CoreGateway.auth` and the handler's own `auth: true`
@@ -26,6 +35,7 @@ whenever declared, session or not.
26
35
 
27
36
  **What this rules out:** invoking `config.func` directly from any gateway
28
37
  transport as an optimisation, and "simplifying" the synthetic function
29
- registration away. It also rules out flipping the sessionless default to
30
- session-required as a hardening measure that breaks every webhook rather than
38
+ registration away. It also rules out fabricating the handler's metadata rather
39
+ than inheriting it, and flipping the _fallback_ to session-required as a
40
+ hardening measure — that breaks every webhook that declared nothing, rather than
31
41
  securing it; require auth per gateway via `CoreGateway.auth` instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.77",
3
+ "version": "0.12.79",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "./trigger": "./dist/wirings/trigger/index.js",
38
38
  "./rpc": "./dist/wirings/rpc/index.js",
39
39
  "./safe-fetch": "./dist/utils/safe-fetch.js",
40
+ "./node-host-resolver": "./dist/utils/node-host-resolver.js",
40
41
  "./mcp": "./dist/wirings/mcp/index.js",
41
42
  "./ai-agent": "./dist/wirings/ai-agent/index.js",
42
43
  "./gateway": "./dist/wirings/gateway/index.js",
@@ -301,6 +301,58 @@ export class InMemoryWorkflowService
301
301
  return newStep
302
302
  }
303
303
 
304
+ /**
305
+ * Safe to opt in despite the pass-through `withStepLock`: this service wires
306
+ * no queues and runs every step inline in one process, so the relay's
307
+ * redundant dispatch has no second holder to race with. See
308
+ * knowledge: decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md
309
+ */
310
+ protected async findUndispatchedSteps(
311
+ before: Date,
312
+ limit: number
313
+ ): Promise<Array<{ runId: string; stepId: string }>> {
314
+ const undispatched: Array<{ runId: string; stepId: string }> = []
315
+ for (const [runId, run] of this.runs) {
316
+ if (run.status !== 'running') continue
317
+ for (const step of this.stepHistory.get(runId) ?? []) {
318
+ if (step.status !== 'pending') continue
319
+ if (step.updatedAt >= before) continue
320
+ undispatched.push({ runId, stepId: step.stepId })
321
+ if (undispatched.length >= limit) return undispatched
322
+ }
323
+ }
324
+ return undispatched
325
+ }
326
+
327
+ protected async findStalledRunIds(
328
+ before: Date,
329
+ limit: number
330
+ ): Promise<string[]> {
331
+ const stalled: string[] = []
332
+ for (const [runId, run] of this.runs) {
333
+ if (run.status !== 'running') continue
334
+ const steps = this.stepHistory.get(runId) ?? []
335
+ if (
336
+ steps.some(
337
+ (step) =>
338
+ step.status === 'running' ||
339
+ step.status === 'scheduled' ||
340
+ step.status === 'suspended'
341
+ )
342
+ ) {
343
+ continue
344
+ }
345
+ const lastActivity = steps.reduce(
346
+ (latest, step) => (step.updatedAt > latest ? step.updatedAt : latest),
347
+ run.updatedAt
348
+ )
349
+ if (lastActivity >= before) continue
350
+ stalled.push(runId)
351
+ if (stalled.length >= limit) break
352
+ }
353
+ return stalled
354
+ }
355
+
304
356
  async listRuns(options?: {
305
357
  workflowName?: string
306
358
  status?: string
@@ -0,0 +1,20 @@
1
+ import { lookup } from 'node:dns/promises'
2
+
3
+ import { setDefaultHostResolver, type HostResolver } from './safe-fetch.js'
4
+
5
+ /**
6
+ * Resolves a hostname through the platform resolver, returning every address it
7
+ * points at so `safeFetch` can reject a public name aimed at an internal one.
8
+ *
9
+ * This module is Node-only and is never imported by core itself — Workers has no
10
+ * DNS API, and a static `node:dns` import in the shared path would break that
11
+ * build.
12
+ */
13
+ export const nodeHostResolver: HostResolver = async (hostname) => {
14
+ const results = await lookup(hostname, { all: true, verbatim: true })
15
+ return results.map(({ address }) => address)
16
+ }
17
+
18
+ /** Installs {@link nodeHostResolver} as the default for every `safeFetch`. */
19
+ export const installNodeHostResolver = () =>
20
+ setDefaultHostResolver(nodeHostResolver)
@@ -41,15 +41,54 @@ describe('isPrivateHost', () => {
41
41
  }
42
42
  })
43
43
 
44
+ test('flags reserved IPv4 blocks that are not routable public space', () => {
45
+ for (const host of [
46
+ '100.64.0.1', // carrier-grade NAT
47
+ '100.100.100.200', // Alibaba Cloud metadata endpoint
48
+ '100.127.255.255', // top of 100.64.0.0/10
49
+ '192.0.0.192', // IETF protocol assignments
50
+ '198.18.0.1', // benchmarking
51
+ '198.19.255.255', // top of 198.18.0.0/15
52
+ '192.88.99.1', // 6to4 anycast
53
+ '192.0.2.1', // TEST-NET-1
54
+ '198.51.100.1', // TEST-NET-2
55
+ '203.0.113.1', // TEST-NET-3
56
+ '224.0.0.1', // multicast
57
+ '240.0.0.1', // reserved
58
+ '255.255.255.255', // broadcast
59
+ ]) {
60
+ assert.equal(isPrivateHost(host), true, `${host} should be private`)
61
+ }
62
+ })
63
+
64
+ test('flags IPv6 forms that tunnel to an internal IPv4 address', () => {
65
+ for (const host of [
66
+ '64:ff9b::a9fe:a9fe', // NAT64 wrapping 169.254.169.254
67
+ '[64:ff9b::169.254.169.254]', // same, dotted-quad tail
68
+ '2002:a9fe:a9fe::', // 6to4 wrapping 169.254.169.254
69
+ '2002:6464:64c8::', // 6to4 wrapping 100.100.100.200
70
+ 'fec0::1', // deprecated site-local
71
+ 'ff02::1', // multicast
72
+ '100::1', // discard-only
73
+ ]) {
74
+ assert.equal(isPrivateHost(host), true, `${host} should be private`)
75
+ }
76
+ })
77
+
44
78
  test('allows public hosts', () => {
45
79
  for (const host of [
46
80
  'example.com',
47
81
  '8.8.8.8',
48
82
  '172.32.0.1',
49
83
  '11.0.0.1',
84
+ '100.63.255.255', // just below the CGNAT block
85
+ '100.128.0.0', // just above the CGNAT block
86
+ '198.17.255.255', // just below the benchmarking block
87
+ '198.20.0.0', // just above the benchmarking block
50
88
  '134744072', // decimal-encoded 8.8.8.8 — public
51
89
  '2001:db8::1', // documentation range — public
52
- 'fec0::1', // deprecated site-local, outside fe80::/10 — treated public
90
+ '2002:808:808::', // 6to4 wrapping public 8.8.8.8
91
+ '64:ff9b::808:808', // NAT64 wrapping public 8.8.8.8
53
92
  ]) {
54
93
  assert.equal(isPrivateHost(host), false, `${host} should be public`)
55
94
  }
@@ -138,6 +177,109 @@ describe('safeFetch', () => {
138
177
  )
139
178
  })
140
179
 
180
+ test('refuses a public hostname that resolves to a private address', async () => {
181
+ await withStubbedFetch(
182
+ () => new Response('ok', { status: 200 }),
183
+ async (calls) => {
184
+ // The shape the literal-only check misses: a name that parses as public
185
+ // but whose A record points at the cloud metadata endpoint.
186
+ await assert.rejects(
187
+ safeFetch(
188
+ 'https://169-254-169-254.nip.io/latest/meta-data/',
189
+ {},
190
+ {
191
+ resolveHost: async () => ['169.254.169.254'],
192
+ }
193
+ ),
194
+ /resolves to 169\.254\.169\.254/
195
+ )
196
+ assert.equal(calls.length, 0)
197
+ }
198
+ )
199
+ })
200
+
201
+ test('refuses when any one resolved address is private', async () => {
202
+ await withStubbedFetch(
203
+ () => new Response('ok', { status: 200 }),
204
+ async (calls) => {
205
+ await assert.rejects(
206
+ safeFetch(
207
+ 'https://split-horizon.example.com/',
208
+ {},
209
+ {
210
+ resolveHost: async () => ['93.184.216.34', '10.0.0.5'],
211
+ }
212
+ ),
213
+ /resolves to 10\.0\.0\.5/
214
+ )
215
+ assert.equal(calls.length, 0)
216
+ }
217
+ )
218
+ })
219
+
220
+ test('re-resolves the host of a redirect hop', async () => {
221
+ await withStubbedFetch(
222
+ (url) =>
223
+ url.includes('start.com')
224
+ ? new Response(null, {
225
+ status: 302,
226
+ headers: { location: 'https://rebound.example.com/next' },
227
+ })
228
+ : new Response('ok', { status: 200 }),
229
+ async (calls) => {
230
+ await assert.rejects(
231
+ safeFetch(
232
+ 'https://start.com',
233
+ {},
234
+ {
235
+ resolveHost: async (hostname) =>
236
+ hostname === 'start.com' ? ['93.184.216.34'] : ['127.0.0.1'],
237
+ }
238
+ ),
239
+ /resolves to 127\.0\.0\.1/
240
+ )
241
+ assert.equal(calls.length, 1)
242
+ }
243
+ )
244
+ })
245
+
246
+ test('allows a public hostname that resolves to public addresses', async () => {
247
+ await withStubbedFetch(
248
+ () => new Response('ok', { status: 200 }),
249
+ async (calls) => {
250
+ const res = await safeFetch(
251
+ 'https://example.com/',
252
+ {},
253
+ {
254
+ resolveHost: async () => ['93.184.216.34'],
255
+ }
256
+ )
257
+ assert.equal(res.status, 200)
258
+ assert.equal(calls.length, 1)
259
+ }
260
+ )
261
+ })
262
+
263
+ test('does not resolve an IP literal or an allowlisted host', async () => {
264
+ await withStubbedFetch(
265
+ () => new Response('ok', { status: 200 }),
266
+ async () => {
267
+ let resolverCalls = 0
268
+ const resolveHost = async () => {
269
+ resolverCalls++
270
+ return ['10.0.0.1']
271
+ }
272
+ await safeFetch('https://93.184.216.34/', {}, { resolveHost })
273
+ await safeFetch(
274
+ 'https://internal.example.com/',
275
+ {},
276
+ { resolveHost, allowedHosts: ['internal.example.com'] }
277
+ )
278
+ assert.equal(resolverCalls, 0)
279
+ }
280
+ )
281
+ })
282
+
141
283
  test('follows a redirect to another public host', async () => {
142
284
  await withStubbedFetch(
143
285
  (url) =>