@pikku/kysely 0.13.12 → 0.13.13

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,47 @@
1
+ ## 0.13.13
2
+
3
+ ### Patch Changes
4
+
5
+ - 6dada45: fix(workflow,ai-agent): make a run's owner, entry node and step function authoritative
6
+
7
+ A graph run may only start at a node the graph declared in `meta.entryNodeIds`, and
8
+ the generated `POST /workflow/:workflowName/graph/:nodeId` route that let an HTTP
9
+ caller pick the entry node is gone. `startNode` stays for `PikkuTriggerService`,
10
+ which names a declared entry node anyway.
11
+
12
+ `StepState` now records the `rpcName` the workflow dispatched a step with, and the
13
+ step claim rejects a queue message naming a different function with
14
+ `WorkflowStepFunctionMismatchError` before mutating any status — a step runs under
15
+ the run owner's identity and without the `expose` gate, so the message must not
16
+ choose what runs.
17
+
18
+ `approveStep` takes the caller's session, and the generated status routes and
19
+ streams assert the same `assertWorkflowRunOwner` check: a run started through a
20
+ session may only be read and approved by that session's user. A run with no
21
+ recorded owner (trigger, scheduler, unauthenticated route) has nobody to compare
22
+ against and is still gated by the entrypoint's own `auth`/`permissions`.
23
+
24
+ `AIRunStateService.resolveApproval` is now a compare-and-swap returning whether
25
+ _this_ caller made the claim, and both agent resume paths run a tool only for the
26
+ approvals they claimed — concurrent approvals of one tool call no longer all
27
+ execute it.
28
+
29
+ - Updated dependencies [41c1a95]
30
+ - Updated dependencies [ce96383]
31
+ - Updated dependencies [7e60867]
32
+ - Updated dependencies [f8f1244]
33
+ - Updated dependencies [dcf20cb]
34
+ - Updated dependencies [6512384]
35
+ - Updated dependencies [e3b4c14]
36
+ - Updated dependencies [efd0ed1]
37
+ - Updated dependencies [cba98fb]
38
+ - Updated dependencies [ce96383]
39
+ - Updated dependencies [f8f1244]
40
+ - Updated dependencies [f8f1244]
41
+ - Updated dependencies [6e93a35]
42
+ - Updated dependencies [6dada45]
43
+ - @pikku/core@0.12.80
44
+
1
45
  ## 0.13.12
2
46
 
3
47
  ### Patch Changes
@@ -11,7 +11,7 @@ export declare class KyselyAIRunStateService implements AIRunStateService {
11
11
  updateRun(runId: string, updates: Partial<AgentRunState>): Promise<void>;
12
12
  getRun(runId: string): Promise<AgentRunState | null>;
13
13
  getRunsByThread(threadId: string): Promise<AgentRunState[]>;
14
- resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<void>;
14
+ resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<boolean>;
15
15
  findRunByToolCallId(toolCallId: string): Promise<{
16
16
  run: AgentRunState;
17
17
  approval: PendingApproval;
@@ -100,14 +100,19 @@ export class KyselyAIRunStateService {
100
100
  if (filtered.length === 0) {
101
101
  updates.status = status;
102
102
  }
103
- await this.db
103
+ // Compare-and-swap on the list we read: a concurrent approver that got
104
+ // there first has already rewritten it, and this update matches no row.
105
+ const result = await this.db
104
106
  .updateTable('aiRun')
105
107
  .set(updates)
106
108
  .where('runId', '=', row.runId)
107
- .execute();
108
- return;
109
+ .where('status', '=', 'suspended')
110
+ .where('pendingApprovals', '=', row.pendingApprovals)
111
+ .executeTakeFirst();
112
+ return Number(result?.numUpdatedRows ?? 0) > 0;
109
113
  }
110
114
  }
115
+ return false;
111
116
  }
112
117
  async findRunByToolCallId(toolCallId) {
113
118
  const rows = await this.db
@@ -32,6 +32,6 @@ export declare class KyselyAIStorageService implements AIStorageService, AIRunSt
32
32
  run: AgentRunState;
33
33
  approval: NonNullable<AgentRunState['pendingApprovals']>[number];
34
34
  } | null>;
35
- resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<void>;
35
+ resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<boolean>;
36
36
  close(): Promise<void>;
37
37
  }
@@ -510,11 +510,14 @@ export class KyselyAIStorageService {
510
510
  return { run, approval };
511
511
  }
512
512
  async resolveApproval(toolCallId, status) {
513
- await this.db
513
+ // Only the caller that moves the tool call off 'pending' has claimed it.
514
+ const result = await this.db
514
515
  .updateTable('aiToolCall')
515
516
  .set({ approvalStatus: status })
516
517
  .where('id', '=', toolCallId)
517
- .execute();
518
+ .where('approvalStatus', '=', 'pending')
519
+ .executeTakeFirst();
520
+ return Number(result?.numUpdatedRows ?? 0) > 0;
518
521
  }
519
522
  async close() { }
520
523
  }
@@ -78,6 +78,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
78
78
  return {
79
79
  stepId,
80
80
  status: 'pending',
81
+ rpcName,
81
82
  result: undefined,
82
83
  error: undefined,
83
84
  attemptCount: 1,
@@ -94,6 +95,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
94
95
  .select([
95
96
  'workflowStepId',
96
97
  'status',
98
+ 'rpcName',
97
99
  'result',
98
100
  'error',
99
101
  'retries',
@@ -115,6 +117,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
115
117
  return {
116
118
  stepId: row.workflowStepId,
117
119
  status: row.status,
120
+ rpcName: row.rpcName ?? null,
118
121
  result: parseJson(row.result),
119
122
  error: parseJson(row.error),
120
123
  attemptCount: Number(row.currentAttempt ?? 1),
@@ -331,6 +334,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
331
334
  .select([
332
335
  'workflowStepId',
333
336
  'status',
337
+ 'rpcName',
334
338
  'result',
335
339
  'error',
336
340
  'retries',
@@ -345,6 +349,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
345
349
  return {
346
350
  stepId: row.workflowStepId,
347
351
  status: row.status,
352
+ rpcName: row.rpcName ?? null,
348
353
  result: parseJson(row.result),
349
354
  error: parseJson(row.error),
350
355
  attemptCount: Number(row.currentAttempt ?? 1),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.12",
3
+ "version": "0.13.13",
4
4
  "description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "prepublishOnly": "yarn build"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pikku/core": "^0.12.79"
24
+ "@pikku/core": "^0.12.80"
25
25
  },
26
26
  "dependencies": {
27
27
  "kysely": "^0.29.0"
@@ -89,7 +89,7 @@ export class KyselyAIRunStateService implements AIRunStateService {
89
89
  async resolveApproval(
90
90
  toolCallId: string,
91
91
  status: 'approved' | 'denied'
92
- ): Promise<void> {
92
+ ): Promise<boolean> {
93
93
  const rows = await this.db
94
94
  .selectFrom('aiRun')
95
95
  .select(['runId', 'pendingApprovals'])
@@ -117,14 +117,19 @@ export class KyselyAIRunStateService implements AIRunStateService {
117
117
  if (filtered.length === 0) {
118
118
  updates.status = status
119
119
  }
120
- await this.db
120
+ // Compare-and-swap on the list we read: a concurrent approver that got
121
+ // there first has already rewritten it, and this update matches no row.
122
+ const result = await this.db
121
123
  .updateTable('aiRun')
122
124
  .set(updates as any)
123
125
  .where('runId', '=', row.runId)
124
- .execute()
125
- return
126
+ .where('status', '=', 'suspended')
127
+ .where('pendingApprovals', '=', row.pendingApprovals)
128
+ .executeTakeFirst()
129
+ return Number(result?.numUpdatedRows ?? 0) > 0
126
130
  }
127
131
  }
132
+ return false
128
133
  }
129
134
 
130
135
  async findRunByToolCallId(
@@ -596,12 +596,15 @@ export class KyselyAIStorageService
596
596
  async resolveApproval(
597
597
  toolCallId: string,
598
598
  status: 'approved' | 'denied'
599
- ): Promise<void> {
600
- await this.db
599
+ ): Promise<boolean> {
600
+ // Only the caller that moves the tool call off 'pending' has claimed it.
601
+ const result = await this.db
601
602
  .updateTable('aiToolCall')
602
603
  .set({ approvalStatus: status })
603
604
  .where('id', '=', toolCallId)
604
- .execute()
605
+ .where('approvalStatus', '=', 'pending')
606
+ .executeTakeFirst()
607
+ return Number(result?.numUpdatedRows ?? 0) > 0
605
608
  }
606
609
 
607
610
  public async close(): Promise<void> {}
@@ -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'
5
+ import { setSingletonServices } from '@pikku/core/ecosystem'
6
6
 
7
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
8
8
  import { SerializePlugin } from './serialize-plugin.js'
@@ -123,6 +123,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
123
123
  return {
124
124
  stepId,
125
125
  status: 'pending',
126
+ rpcName,
126
127
  result: undefined,
127
128
  error: undefined,
128
129
  attemptCount: 1,
@@ -140,6 +141,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
140
141
  .select([
141
142
  'workflowStepId',
142
143
  'status',
144
+ 'rpcName',
143
145
  'result',
144
146
  'error',
145
147
  'retries',
@@ -165,6 +167,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
165
167
  return {
166
168
  stepId: row.workflowStepId,
167
169
  status: row.status as StepState['status'],
170
+ rpcName: row.rpcName ?? null,
168
171
  result: parseJson(row.result),
169
172
  error: parseJson(row.error),
170
173
  attemptCount: Number(row.currentAttempt ?? 1),
@@ -425,6 +428,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
425
428
  .select([
426
429
  'workflowStepId',
427
430
  'status',
431
+ 'rpcName',
428
432
  'result',
429
433
  'error',
430
434
  'retries',
@@ -440,6 +444,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
440
444
  return {
441
445
  stepId: row.workflowStepId,
442
446
  status: row.status as StepState['status'],
447
+ rpcName: row.rpcName ?? null,
443
448
  result: parseJson(row.result),
444
449
  error: parseJson(row.error),
445
450
  attemptCount: Number(row.currentAttempt ?? 1),