@pikku/kysely 0.13.16 → 0.13.19

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 (69) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/bin/pikku-kysely-pure.ts +0 -0
  3. package/dist/src/coercion-plugin.d.ts +41 -0
  4. package/dist/src/coercion-plugin.js +150 -0
  5. package/dist/src/create-audited-kysely.d.ts +1 -1
  6. package/dist/src/index.d.ts +4 -3
  7. package/dist/src/index.js +1 -0
  8. package/dist/src/kysely-agent-run-scores.d.ts +1 -1
  9. package/dist/src/kysely-agent-run-service.d.ts +1 -2
  10. package/dist/src/kysely-agent-run-state-service.d.ts +2 -2
  11. package/dist/src/kysely-agent-storage-service.d.ts +2 -2
  12. package/dist/src/kysely-audit-service.d.ts +3 -3
  13. package/dist/src/kysely-channel-store.d.ts +1 -1
  14. package/dist/src/kysely-credential-service.js +1 -1
  15. package/dist/src/kysely-deployment-service.d.ts +3 -2
  16. package/dist/src/kysely-deployment-service.js +2 -2
  17. package/dist/src/kysely-eventhub-store.d.ts +1 -1
  18. package/dist/src/kysely-eventhub-store.js +1 -1
  19. package/dist/src/kysely-scope-service.d.ts +3 -3
  20. package/dist/src/kysely-secret-service.d.ts +1 -1
  21. package/dist/src/kysely-secret-service.js +2 -2
  22. package/dist/src/kysely-session-store.d.ts +1 -1
  23. package/dist/src/kysely-tables.d.ts +1 -1
  24. package/dist/src/kysely-virtual-user-run-store.d.ts +1 -1
  25. package/dist/src/kysely-webhook-service.d.ts +1 -1
  26. package/dist/src/kysely-webhook-service.js +1 -1
  27. package/dist/src/kysely-workflow-mirror.d.ts +2 -2
  28. package/dist/src/kysely-workflow-run-service.d.ts +1 -1
  29. package/dist/src/kysely-workflow-service.d.ts +41 -10
  30. package/dist/src/kysely-workflow-service.js +75 -9
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +7 -7
  33. package/src/coercion-plugin.test.ts +160 -0
  34. package/src/coercion-plugin.ts +187 -0
  35. package/src/create-audited-kysely.test.ts +1 -1
  36. package/src/create-audited-kysely.ts +1 -1
  37. package/src/index.ts +9 -6
  38. package/src/kysely-agent-run-scores.ts +1 -1
  39. package/src/kysely-agent-run-service.ts +3 -2
  40. package/src/kysely-agent-run-state-service.ts +3 -3
  41. package/src/kysely-agent-storage-service.ts +7 -7
  42. package/src/kysely-audit-service.test.ts +1 -1
  43. package/src/kysely-audit-service.ts +4 -8
  44. package/src/kysely-channel-store.ts +1 -1
  45. package/src/kysely-credential-service.ts +5 -5
  46. package/src/kysely-deployment-service.ts +5 -4
  47. package/src/kysely-eventhub-store.ts +1 -1
  48. package/src/kysely-scope-service.ts +3 -3
  49. package/src/kysely-secret-service.ts +7 -6
  50. package/src/kysely-services.test.ts +1 -1
  51. package/src/kysely-session-store.ts +1 -1
  52. package/src/kysely-tables.ts +1 -1
  53. package/src/kysely-virtual-user-run-store.ts +1 -1
  54. package/src/kysely-webhook-service.test.ts +1 -1
  55. package/src/kysely-webhook-service.ts +2 -2
  56. package/src/kysely-workflow-mirror.ts +2 -2
  57. package/src/kysely-workflow-run-service.ts +1 -1
  58. package/src/kysely-workflow-service.test.ts +111 -0
  59. package/src/kysely-workflow-service.ts +108 -11
  60. package/dist/src/kysely-ai-agent-run-service.d.ts +0 -22
  61. package/dist/src/kysely-ai-agent-run-service.js +0 -190
  62. package/dist/src/kysely-ai-run-scores.d.ts +0 -10
  63. package/dist/src/kysely-ai-run-scores.js +0 -34
  64. package/dist/src/kysely-ai-run-state-service.d.ts +0 -24
  65. package/dist/src/kysely-ai-run-state-service.js +0 -169
  66. package/dist/src/kysely-ai-storage-service.d.ts +0 -40
  67. package/dist/src/kysely-ai-storage-service.js +0 -530
  68. package/dist/src/schema/ai.schema.d.ts +0 -12
  69. package/dist/src/schema/ai.schema.js +0 -102
@@ -1,15 +1,15 @@
1
1
  import type {
2
- AgentStorageService,
3
- AgentRunStateService,
4
- CreateRunInput,
5
- SaveScoreInput,
6
- } from '@pikku/core/services'
7
- import type {
8
- AgentThread,
9
2
  AgentMessage,
10
3
  AgentRunState,
4
+ AgentThread,
11
5
  } from '@pikku/core/agent'
12
6
  import type { AgentRunScore } from '@pikku/core/agent-scorer'
7
+ import type {
8
+ AgentRunStateService,
9
+ AgentStorageService,
10
+ CreateRunInput,
11
+ SaveScoreInput,
12
+ } from '@pikku/core/services'
13
13
  import type { Kysely } from 'kysely'
14
14
  import type { KyselyPikkuDB } from './kysely-tables.js'
15
15
  import { parseJson } from './kysely-json.js'
@@ -2,7 +2,7 @@ import { describe, test, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import Database from 'better-sqlite3'
4
4
  import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
5
- import type { AuditEvent } from '../../../core/src/index.js'
5
+ import type { AuditEvent } from '@pikku/core/services'
6
6
 
7
7
  import { KyselyAuditService } from './kysely-audit-service.js'
8
8
 
@@ -1,13 +1,10 @@
1
- import type { AuditService } from '@pikku/core'
2
- import type {
3
- AuditEvent,
4
- AuditEventBatch,
5
- } from '@pikku/core/ecosystem/services'
1
+ import type { AuditService } from '@pikku/core/services'
2
+ import type { AuditEvent, AuditEventBatch } from '@pikku/core/services'
6
3
  import type {
7
4
  AuditFacets,
8
5
  AuditQuery,
9
6
  AuditQueryResult,
10
- } from '@pikku/core/ecosystem/types'
7
+ } from '@pikku/core/services'
11
8
  import type { Kysely } from 'kysely'
12
9
  import { ensurePikkuSchema } from './schema/index.js'
13
10
  import { auditSchema } from './schema/audit.schema.js'
@@ -118,8 +115,7 @@ export class KyselyAuditService implements AuditService {
118
115
  if (!batch.length) return
119
116
  const rows = batch.map((e) => {
120
117
  const metadata = e.metadata as
121
- | { tables?: unknown; changedColumns?: unknown }
122
- | undefined
118
+ { tables?: unknown; changedColumns?: unknown } | undefined
123
119
  return {
124
120
  audit_id: e.eventId ?? e.queryId ?? fallbackId(),
125
121
  occurred_at: e.occurredAt ?? new Date().toISOString(),
@@ -1,5 +1,5 @@
1
- import type { Channel } from '@pikku/core/channel'
2
1
  import { ChannelStore } from '@pikku/core/channel'
2
+ import type { Channel } from '@pikku/core/channel'
3
3
  import type { Kysely } from 'kysely'
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js'
5
5
  import { parseJson } from './kysely-json.js'
@@ -1,13 +1,13 @@
1
- import type { CredentialService } from '@pikku/core/services'
2
- import type { Kysely } from 'kysely'
3
- import type { KyselyPikkuDB } from './kysely-tables.js'
4
1
  import {
5
2
  deriveKEK,
6
- generateKEKSalt,
7
- envelopeEncrypt,
8
3
  envelopeDecrypt,
4
+ envelopeEncrypt,
9
5
  envelopeRewrap,
6
+ generateKEKSalt,
10
7
  } from '@pikku/core/crypto-utils'
8
+ import type { CredentialService } from '@pikku/core/services'
9
+ import type { Kysely } from 'kysely'
10
+ import type { KyselyPikkuDB } from './kysely-tables.js'
11
11
  import { ensurePikkuSchema } from './schema/index.js'
12
12
  import { credentialSchema } from './schema/credential.schema.js'
13
13
 
@@ -1,11 +1,12 @@
1
- import { buildRemoteHeaders } from '@pikku/core/ecosystem/remote'
1
+ import { buildRemoteHeaders } from '@pikku/core/remote'
2
2
  import type {
3
3
  DeploymentService,
4
4
  DeploymentServiceConfig,
5
5
  DeploymentConfig,
6
- } from '@pikku/core/ecosystem/services'
7
- import type { JWTService, SecretService } from '@pikku/core/services'
8
- import { getAllFunctionNames } from '@pikku/core/ecosystem/function'
6
+ } from '@pikku/core/services'
7
+ import type { JWTService } from '@pikku/core/services'
8
+ import type { SecretService } from '@pikku/core/services'
9
+ import { getAllFunctionNames } from '@pikku/core/function'
9
10
  import type { Kysely } from 'kysely'
10
11
  import type { KyselyPikkuDB } from './kysely-tables.js'
11
12
  import { ensurePikkuSchema } from './schema/index.js'
@@ -1,4 +1,4 @@
1
- import { EventHubStore } from '@pikku/core/ecosystem/channel'
1
+ import { EventHubStore } from '@pikku/core/channel'
2
2
  import type { Kysely } from 'kysely'
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js'
4
4
 
@@ -1,7 +1,7 @@
1
1
  import type { ScopeService } from '@pikku/core/services'
2
- import type { Role } from '@pikku/core/ecosystem/services'
3
- import type { FlatScope } from '@pikku/core/ecosystem/scope'
4
- import type { SystemRole } from '@pikku/core/ecosystem/role'
2
+ import type { Role } from '@pikku/core/services'
3
+ import type { FlatScope } from '@pikku/core/scope'
4
+ import type { SystemRole } from '@pikku/core/role'
5
5
  import {
6
6
  SystemRoleImmutableError,
7
7
  SystemRoleShadowedError,
@@ -1,14 +1,15 @@
1
- import { createSecretValue, isSecretValue, type SecretValue } from '@pikku/core'
2
- import type { SecretService, SecretValues } from '@pikku/core/services'
3
- import type { Kysely } from 'kysely'
4
- import type { KyselyPikkuDB } from './kysely-tables.js'
5
1
  import {
6
2
  deriveKEK,
7
- generateKEKSalt,
8
- envelopeEncrypt,
9
3
  envelopeDecrypt,
4
+ envelopeEncrypt,
10
5
  envelopeRewrap,
6
+ generateKEKSalt,
11
7
  } from '@pikku/core/crypto-utils'
8
+ import { createSecretValue, isSecretValue } from '@pikku/core/classification'
9
+ import type { SecretService, SecretValues } from '@pikku/core/services'
10
+ import type { SecretValue } from '@pikku/core/classification'
11
+ import type { Kysely } from 'kysely'
12
+ import type { KyselyPikkuDB } from './kysely-tables.js'
12
13
  import { ensurePikkuSchema } from './schema/index.js'
13
14
  import { secretSchema } from './schema/secret.schema.js'
14
15
 
@@ -5,7 +5,7 @@ import { SerializePlugin } from './serialize-plugin.js'
5
5
  import Database from 'better-sqlite3'
6
6
  import { PostgresJSDialect } from 'kysely-postgres-js'
7
7
  import postgres from 'postgres'
8
- import { defineServiceTests } from '@pikku/core/ecosystem/testing'
8
+ import { defineServiceTests } from '@pikku/core/testing'
9
9
 
10
10
  import type { KyselyPikkuDB } from './kysely-tables.js'
11
11
  import { KyselyChannelStore } from './kysely-channel-store.js'
@@ -1,5 +1,5 @@
1
- import type { CoreUserSession } from '@pikku/core'
2
1
  import type { SessionStore } from '@pikku/core/services'
2
+ import type { CoreUserSession } from '@pikku/core/types'
3
3
  import type { Kysely } from 'kysely'
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js'
5
5
  import { parseJson } from './kysely-json.js'
@@ -3,7 +3,7 @@ import type {
3
3
  WorkflowStatus,
4
4
  StepStatus,
5
5
  WorkflowVersionStatus,
6
- } from '@pikku/core/ecosystem/workflow'
6
+ } from '@pikku/core/workflow'
7
7
 
8
8
  export interface ChannelsTable {
9
9
  channelId: string
@@ -6,7 +6,7 @@ import type {
6
6
  VirtualUserRunRecord,
7
7
  VirtualUserRunStart,
8
8
  VirtualUserRunStore,
9
- } from '@pikku/core/ecosystem/virtual-user'
9
+ } from '@pikku/core/virtual-user'
10
10
  import type { KyselyPikkuDB, VirtualUserRunTable } from './kysely-tables.js'
11
11
  import { parseJson } from './kysely-json.js'
12
12
  import { ensurePikkuSchema } from './schema/index.js'
@@ -2,7 +2,7 @@ import { describe, test, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
4
  import Database from 'better-sqlite3'
5
- import { setSingletonServices } from '@pikku/core/ecosystem'
5
+ import { setSingletonServices } from '@pikku/core/state'
6
6
 
7
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
8
8
  import { SerializePlugin } from './serialize-plugin.js'
@@ -1,4 +1,5 @@
1
1
  import { QueueWebhookService } from '@pikku/core/services'
2
+ import type { QueueService } from '@pikku/core/queue'
2
3
  import {
3
4
  PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
4
5
  type SendWebhookInput,
@@ -6,8 +7,7 @@ import {
6
7
  type WebhookAttemptResult,
7
8
  type WebhookDeliveryRecord,
8
9
  type WebhookDeliveryWithAttempts,
9
- } from '@pikku/core/ecosystem/services'
10
- import type { QueueService } from '@pikku/core/queue'
10
+ } from '@pikku/core/services'
11
11
  import type { Kysely } from 'kysely'
12
12
  import type { KyselyPikkuDB } from './kysely-tables.js'
13
13
  import { ensurePikkuSchema } from './schema/index.js'
@@ -1,4 +1,4 @@
1
- import type { SerializedError } from '@pikku/core/ecosystem/types'
1
+ import type { SerializedError } from '@pikku/core/errors'
2
2
  import type {
3
3
  StepState,
4
4
  WorkflowRunMirror,
@@ -6,7 +6,7 @@ import type {
6
6
  WorkflowStatus,
7
7
  WorkflowPlannedStep,
8
8
  WorkflowVersionStatus,
9
- } from '@pikku/core/ecosystem/workflow'
9
+ } from '@pikku/core/workflow'
10
10
  import type { Kysely } from 'kysely'
11
11
  import type { KyselyPikkuDB } from './kysely-tables.js'
12
12
  import { ensurePikkuSchema } from './schema/index.js'
@@ -3,7 +3,7 @@ import type {
3
3
  StepState,
4
4
  WorkflowStatus,
5
5
  WorkflowRunService,
6
- } from '@pikku/core/ecosystem/workflow'
6
+ } from '@pikku/core/workflow'
7
7
  import type { Kysely } from 'kysely'
8
8
  import type { KyselyPikkuDB } from './kysely-tables.js'
9
9
  import { parseJson } from './kysely-json.js'
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
4
4
  import Database from 'better-sqlite3'
5
5
 
6
+ import type { StepState } from '@pikku/core/ecosystem/workflow'
6
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
7
8
  import { SerializePlugin } from './serialize-plugin.js'
8
9
  import { KyselyWorkflowService } from './kysely-workflow-service.js'
@@ -429,3 +430,113 @@ describe('KyselyWorkflowService — a transition that addresses no history row',
429
430
  })
430
431
  })
431
432
 
433
+ /**
434
+ * Dispatch is at-least-once — the relay re-dispatches a step it believes was
435
+ * dropped, and a queue can redeliver a job it already handed out — so the claim
436
+ * is the only thing standing between a duplicate dispatch and a second
437
+ * execution of a side-effecting step.
438
+ */
439
+ describe('KyselyWorkflowService — claiming a step for execution', () => {
440
+ const claim = (runId: string, stepName: string, rpcName = 'rpc.fn') =>
441
+ (service as any).claimStepForExecution(
442
+ runId,
443
+ stepName,
444
+ rpcName
445
+ ) as Promise<StepState | null>
446
+
447
+ test('two dispatches racing for the same pending step: exactly one wins', async () => {
448
+ const runId = await seedRun()
449
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
450
+
451
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
452
+ const winners = claims.filter((c) => c !== null)
453
+
454
+ assert.equal(
455
+ winners.length,
456
+ 1,
457
+ `both dispatches claimed the step, so a side-effecting step would run twice: ${JSON.stringify(claims)}`
458
+ )
459
+ assert.equal((await service.getStepState(runId, 's1')).status, 'running')
460
+ })
461
+
462
+ test('two dispatches racing to retry the same failed step: exactly one wins', async () => {
463
+ const runId = await seedRun()
464
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
465
+ await service.setStepRunning(step.stepId)
466
+ await service.setStepError(step.stepId, new Error('boom'))
467
+
468
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
469
+ const winners = claims.filter((c) => c !== null)
470
+
471
+ assert.equal(
472
+ winners.length,
473
+ 1,
474
+ `both dispatches started a retry, so the failed step would be retried twice concurrently: ${JSON.stringify(claims)}`
475
+ )
476
+ assert.equal(
477
+ (await service.getStepState(runId, 's1')).attemptCount,
478
+ 2,
479
+ 'the retry raced itself and burned more than one attempt'
480
+ )
481
+ })
482
+
483
+ test('a step already claimed is not claimable again', async () => {
484
+ const runId = await seedRun()
485
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
486
+
487
+ assert.notEqual(await claim(runId, 's1'), null)
488
+ assert.equal(
489
+ await claim(runId, 's1'),
490
+ null,
491
+ 'a redelivered job re-claimed a step that is already running'
492
+ )
493
+ })
494
+
495
+ test('a succeeded step is not claimable', async () => {
496
+ const runId = await seedRun()
497
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
498
+ await service.setStepRunning(step.stepId)
499
+ await service.setStepResult(step.stepId, { ok: true })
500
+
501
+ assert.equal(await claim(runId, 's1'), null)
502
+ })
503
+
504
+ test('a claim that fails releases the step, so it is retryable', async () => {
505
+ const runId = await seedRun()
506
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
507
+
508
+ const first = await claim(runId, 's1')
509
+ assert.notEqual(first, null)
510
+ await service.setStepError(step.stepId, new Error('boom'))
511
+
512
+ const retry = await claim(runId, 's1')
513
+ assert.notEqual(
514
+ retry,
515
+ null,
516
+ 'the failed step stayed claimed, so it can never be retried'
517
+ )
518
+ assert.equal(retry?.status, 'running')
519
+ assert.equal(retry?.attemptCount, 2)
520
+ })
521
+
522
+ test('a claim for a different function than the step was dispatched with is refused', async () => {
523
+ const runId = await seedRun()
524
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
525
+
526
+ await assert.rejects(() => claim(runId, 's1', 'rpc.other'))
527
+ assert.equal(
528
+ (await service.getStepState(runId, 's1')).status,
529
+ 'pending',
530
+ 'the mismatched claim moved the step anyway'
531
+ )
532
+ })
533
+
534
+ test('a scheduled step is claimable, and only once', async () => {
535
+ const runId = await seedRun()
536
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
537
+ await service.setStepScheduled(step.stepId)
538
+
539
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
540
+ assert.equal(claims.filter((c) => c !== null).length, 1)
541
+ })
542
+ })
@@ -1,6 +1,18 @@
1
- import type { SerializedError } from '@pikku/core/ecosystem/types'
2
- import { PikkuWorkflowService } from '@pikku/core/workflow'
3
- import type { WorkflowPlannedStep, WorkflowQueueOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/ecosystem/workflow'
1
+ import type { SerializedError } from '@pikku/core/errors'
2
+ import {
3
+ PikkuWorkflowService,
4
+ WorkflowStepFunctionMismatchError,
5
+ } from '@pikku/core/workflow'
6
+ import type {
7
+ WorkflowPlannedStep,
8
+ WorkflowQueueOptions,
9
+ WorkflowRun,
10
+ WorkflowRunWire,
11
+ StepState,
12
+ StepStatus,
13
+ WorkflowStatus,
14
+ WorkflowVersionStatus,
15
+ } from '@pikku/core/workflow'
4
16
  import { sql, type Kysely } from 'kysely'
5
17
  import type { KyselyPikkuDB } from './kysely-tables.js'
6
18
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
@@ -451,6 +463,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
451
463
  return fn()
452
464
  }
453
465
 
466
+ /**
467
+ * A pass-through, and deliberately so: the one decision that must exclude —
468
+ * claiming a step to execute it — is made by `claimStepForExecution` below as
469
+ * a single conditional write, which needs no lock to be exclusive.
470
+ *
471
+ * What is left are the suspend and approval sections in the engine, where the
472
+ * lock is genuine mutual exclusion rather than a claim. A dialect with a real
473
+ * primitive overrides this to cover them — `kysely-postgres` with
474
+ * `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
475
+ */
454
476
  async withStepLock<T>(
455
477
  _runId: string,
456
478
  _stepName: string,
@@ -459,6 +481,79 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
459
481
  return fn()
460
482
  }
461
483
 
484
+ /**
485
+ * Claim the step with a status-guarded `UPDATE` and read the affected-row
486
+ * count: the database decides the winner in one statement, so two dispatches
487
+ * racing for the same step cannot both proceed.
488
+ *
489
+ * This replaces the read-then-write the base engine does under `withStepLock`,
490
+ * which is only as exclusive as that lock — and every dialect but Postgres and
491
+ * MySQL inherits a pass-through. A conditional update needs no advisory-lock
492
+ * primitive, so every SQL dialect gets the same guarantee.
493
+ *
494
+ * The winner then goes through the ordinary transition methods, so history
495
+ * rows and the mirror see exactly what they saw before.
496
+ */
497
+ protected override async claimStepForExecution(
498
+ runId: string,
499
+ stepName: string,
500
+ rpcName: string
501
+ ): Promise<StepState | null> {
502
+ const stepState = await this.getStepState(runId, stepName)
503
+
504
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
505
+ if (
506
+ stepState.rpcName !== undefined &&
507
+ stepState.rpcName !== (rpcName ?? null)
508
+ ) {
509
+ throw new WorkflowStepFunctionMismatchError(runId, stepName)
510
+ }
511
+
512
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
513
+ return null
514
+ }
515
+
516
+ if (stepState.status === 'failed') {
517
+ if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
518
+ return null
519
+ }
520
+ return this.createRetryAttempt(stepState.stepId, 'running')
521
+ }
522
+
523
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
524
+ if (
525
+ !(await this.claimStepStatus(stepState.stepId, [
526
+ 'pending',
527
+ 'scheduled',
528
+ ]))
529
+ ) {
530
+ return null
531
+ }
532
+ await this.setStepRunning(stepState.stepId)
533
+ return { ...stepState, status: 'running' }
534
+ }
535
+
536
+ return stepState
537
+ }
538
+
539
+ /**
540
+ * Move a step to `running` only if it is still in one of `from`, reporting
541
+ * whether this caller is the one that moved it.
542
+ */
543
+ private async claimStepStatus(
544
+ stepId: string,
545
+ from: StepStatus[]
546
+ ): Promise<boolean> {
547
+ const claimed = await this.db
548
+ .updateTable('workflowStep')
549
+ .set({ status: 'running', updatedAt: new Date() })
550
+ .where('workflowStepId', '=', stepId)
551
+ .where('status', 'in', from)
552
+ .executeTakeFirst()
553
+
554
+ return Number(claimed?.numUpdatedRows ?? 0n) > 0
555
+ }
556
+
462
557
  async getCompletedGraphState(runId: string): Promise<{
463
558
  completedNodeIds: string[]
464
559
  failedNodeIds: string[]
@@ -597,16 +692,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
597
692
  }
598
693
 
599
694
  /**
600
- * The undispatched-step query, shared by the dialects that can safely relay.
695
+ * The undispatched-step query, shared by every dialect.
601
696
  *
602
- * Deliberately NOT an override of `findUndispatchedSteps`: this class's
603
- * `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
604
- * has no atomic claim, and the relay's redundant dispatches would become
605
- * double executions. A dialect opts in by overriding `findUndispatchedSteps`
606
- * to call this — which `kysely-postgres` and `kysely-mysql` do, having real
607
- * locks.
697
+ * It used to be deliberately NOT an override, because this class's
698
+ * `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
699
+ * had no atomic claim, so the relay's redundant dispatches would have become
700
+ * double executions, and only `kysely-postgres` and `kysely-mysql` opted in
701
+ * on the strength of their real locks. `claimStepForExecution` no longer
702
+ * needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
703
+ * dialect — so the relay is safe here for all of them and the override lives
704
+ * in the base.
608
705
  */
609
- protected async queryUndispatchedSteps(
706
+ protected override async findUndispatchedSteps(
610
707
  before: Date,
611
708
  limit: number
612
709
  ): Promise<Array<{ runId: string; stepId: string }>> {
@@ -1,22 +0,0 @@
1
- import type { AIThread, AIMessage } from '@pikku/core/ai-agent';
2
- import type { AgentRunRow, AgentRunService } from '@pikku/core/ecosystem/ai-agent';
3
- import type { Kysely } from 'kysely';
4
- import type { KyselyPikkuDB } from './kysely-tables.js';
5
- export declare class KyselyAgentRunService implements AgentRunService {
6
- private db;
7
- constructor(db: Kysely<KyselyPikkuDB>);
8
- listThreads(options?: {
9
- agentName?: string;
10
- resourceId?: string;
11
- owners?: string[];
12
- limit?: number;
13
- offset?: number;
14
- }): Promise<AIThread[]>;
15
- getThread(threadId: string): Promise<AIThread | null>;
16
- getThreadMessages(threadId: string): Promise<AIMessage[]>;
17
- getThreadRuns(threadId: string): Promise<AgentRunRow[]>;
18
- deleteThread(threadId: string): Promise<boolean>;
19
- getDistinctAgentNames(): Promise<string[]>;
20
- private mapThreadRow;
21
- private mapRunRow;
22
- }