@pikku/kysely 0.13.12 → 0.13.15

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,81 @@
1
+ ## 0.13.15
2
+
3
+ ### Patch Changes
4
+
5
+ - fd9d834: Stop publishing internals that only their own package or file used. The declarations stay; only the entrypoint re-export is removed, so nothing that imported a name from where it is declared is affected.
6
+ - Updated dependencies [063f43a]
7
+ - Updated dependencies [ce66bf8]
8
+ - Updated dependencies [d0307a8]
9
+ - Updated dependencies [ce66bf8]
10
+ - Updated dependencies [3ad2131]
11
+ - Updated dependencies [b930dca]
12
+ - Updated dependencies [b95e77d]
13
+ - Updated dependencies [fd9d834]
14
+ - Updated dependencies [8978fbd]
15
+ - @pikku/core@0.12.82
16
+
17
+ ## 0.13.14
18
+
19
+ ### Patch Changes
20
+
21
+ - e110c55: Add runtime scoring for AI agents: `pikkuAIScorer` for heuristic grades and
22
+ `pikkuAIJudge` for LLM-judged ones, graded off the request path on two queue
23
+ lanes so a slow judge cannot starve the cheap checks. Grades are sampled
24
+ deterministically per `(run, scorer)` and persisted to `ai_run_score`.
25
+ - Updated dependencies [e110c55]
26
+ - Updated dependencies [e110c55]
27
+ - Updated dependencies [e110c55]
28
+ - Updated dependencies [acc8077]
29
+ - Updated dependencies [905f737]
30
+ - Updated dependencies [3cc6428]
31
+ - Updated dependencies [c524adf]
32
+ - Updated dependencies [e110c55]
33
+ - @pikku/core@0.12.81
34
+
35
+ ## 0.13.13
36
+
37
+ ### Patch Changes
38
+
39
+ - 6dada45: fix(workflow,ai-agent): make a run's owner, entry node and step function authoritative
40
+
41
+ A graph run may only start at a node the graph declared in `meta.entryNodeIds`, and
42
+ the generated `POST /workflow/:workflowName/graph/:nodeId` route that let an HTTP
43
+ caller pick the entry node is gone. `startNode` stays for `PikkuTriggerService`,
44
+ which names a declared entry node anyway.
45
+
46
+ `StepState` now records the `rpcName` the workflow dispatched a step with, and the
47
+ step claim rejects a queue message naming a different function with
48
+ `WorkflowStepFunctionMismatchError` before mutating any status — a step runs under
49
+ the run owner's identity and without the `expose` gate, so the message must not
50
+ choose what runs.
51
+
52
+ `approveStep` takes the caller's session, and the generated status routes and
53
+ streams assert the same `assertWorkflowRunOwner` check: a run started through a
54
+ session may only be read and approved by that session's user. A run with no
55
+ recorded owner (trigger, scheduler, unauthenticated route) has nobody to compare
56
+ against and is still gated by the entrypoint's own `auth`/`permissions`.
57
+
58
+ `AIRunStateService.resolveApproval` is now a compare-and-swap returning whether
59
+ _this_ caller made the claim, and both agent resume paths run a tool only for the
60
+ approvals they claimed — concurrent approvals of one tool call no longer all
61
+ execute it.
62
+
63
+ - Updated dependencies [41c1a95]
64
+ - Updated dependencies [ce96383]
65
+ - Updated dependencies [7e60867]
66
+ - Updated dependencies [f8f1244]
67
+ - Updated dependencies [dcf20cb]
68
+ - Updated dependencies [6512384]
69
+ - Updated dependencies [e3b4c14]
70
+ - Updated dependencies [efd0ed1]
71
+ - Updated dependencies [cba98fb]
72
+ - Updated dependencies [ce96383]
73
+ - Updated dependencies [f8f1244]
74
+ - Updated dependencies [f8f1244]
75
+ - Updated dependencies [6e93a35]
76
+ - Updated dependencies [6dada45]
77
+ - @pikku/core@0.12.80
78
+
1
79
  ## 0.13.12
2
80
 
3
81
  ### Patch Changes
@@ -8,18 +8,15 @@ export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
8
8
  export { KyselyAgentRunService } from './kysely-ai-agent-run-service.js';
9
9
  export { KyselyAIRunStateService } from './kysely-ai-run-state-service.js';
10
10
  export { KyselySecretService } from './kysely-secret-service.js';
11
- export type { KyselySecretServiceConfig } from './kysely-secret-service.js';
12
11
  export { KyselyCredentialService } from './kysely-credential-service.js';
13
- export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js';
14
12
  export { KyselySessionStore } from './kysely-session-store.js';
15
13
  export { KyselyScopeService } from './kysely-scope-service.js';
16
14
  export { KyselyWebhookService } from './kysely-webhook-service.js';
17
15
  export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
18
16
  export { KyselyAuditService } from './kysely-audit-service.js';
19
17
  export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
20
- export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, type Serializer, type Deserializer, type SerializePluginOptions, } from './serialize-plugin.js';
21
- export { virtualUserSchema } from './schema/virtual-user.schema.js';
22
- export { auditSchema, pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, type EnsureOutcome, resolveRequirements, unsatisfiedRequirements, rawStatement, requiredType, type PikkuSchema, type RequiredTypes, type SchemaRequirement, type UnmetRequirement, type SchemaStatement, type SchemaStatementFactory, } from './schema/index.js';
18
+ export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, type Serializer, type Deserializer, } from './serialize-plugin.js';
19
+ export { auditSchema, pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, type EnsureOutcome, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
23
20
  export type { KyselyPikkuDB } from './kysely-tables.js';
24
21
  export type { WorkflowRunService } from '@pikku/core/workflow';
25
22
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent';
package/dist/src/index.js CHANGED
@@ -15,6 +15,5 @@ export { KyselyWebhookService } from './kysely-webhook-service.js';
15
15
  export { createAuditedKysely, } from './create-audited-kysely.js';
16
16
  export { KyselyAuditService } from './kysely-audit-service.js';
17
17
  export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
18
- export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, } from './serialize-plugin.js';
19
- export { virtualUserSchema } from './schema/virtual-user.schema.js';
20
- export { auditSchema, pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, resolveRequirements, unsatisfiedRequirements, rawStatement, requiredType, } from './schema/index.js';
18
+ export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
19
+ export { auditSchema, pikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, ensurePikkuSchema, resolveRequirements, } from './schema/index.js';
@@ -0,0 +1,10 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { KyselyPikkuDB } from './kysely-tables.js';
3
+ import type { SaveScoreInput } from '@pikku/core/services';
4
+ import type { AIRunScore } from '@pikku/core/ai-scorer';
5
+ /**
6
+ * Shared by the two Kysely services that implement `AIRunStateService` — they
7
+ * differ in how they store approvals, not in how they store grades.
8
+ */
9
+ export declare const saveRunScore: (db: Kysely<KyselyPikkuDB>, score: SaveScoreInput) => Promise<void>;
10
+ export declare const getRunScores: (db: Kysely<KyselyPikkuDB>, runId: string) => Promise<AIRunScore[]>;
@@ -0,0 +1,34 @@
1
+ import { parseJson } from './kysely-json.js';
2
+ /**
3
+ * Shared by the two Kysely services that implement `AIRunStateService` — they
4
+ * differ in how they store approvals, not in how they store grades.
5
+ */
6
+ export const saveRunScore = async (db, score) => {
7
+ await db
8
+ .insertInto('aiRunScore')
9
+ .values({
10
+ id: crypto.randomUUID(),
11
+ runId: score.runId,
12
+ scorerName: score.scorerName,
13
+ score: score.score,
14
+ reason: score.reason ?? null,
15
+ metadata: score.metadata ? JSON.stringify(score.metadata) : null,
16
+ })
17
+ .execute();
18
+ };
19
+ export const getRunScores = async (db, runId) => {
20
+ const rows = await db
21
+ .selectFrom('aiRunScore')
22
+ .selectAll()
23
+ .where('runId', '=', runId)
24
+ .orderBy('createdAt', 'asc')
25
+ .execute();
26
+ return rows.map((row) => ({
27
+ runId: row.runId,
28
+ scorerName: row.scorerName,
29
+ score: Number(row.score),
30
+ reason: row.reason ?? undefined,
31
+ metadata: parseJson(row.metadata),
32
+ createdAt: new Date(row.createdAt),
33
+ }));
34
+ };
@@ -1,7 +1,8 @@
1
1
  import type { Kysely } from 'kysely';
2
2
  import type { KyselyPikkuDB } from './kysely-tables.js';
3
- import type { AIRunStateService, CreateRunInput } from '@pikku/core/services';
3
+ import type { AIRunStateService, CreateRunInput, SaveScoreInput } from '@pikku/core/services';
4
4
  import type { AgentRunState, PendingApproval } from '@pikku/core/ai-agent';
5
+ import type { AIRunScore } from '@pikku/core/ai-scorer';
5
6
  export declare class KyselyAIRunStateService implements AIRunStateService {
6
7
  private db;
7
8
  private initialized;
@@ -11,10 +12,12 @@ export declare class KyselyAIRunStateService implements AIRunStateService {
11
12
  updateRun(runId: string, updates: Partial<AgentRunState>): Promise<void>;
12
13
  getRun(runId: string): Promise<AgentRunState | null>;
13
14
  getRunsByThread(threadId: string): Promise<AgentRunState[]>;
14
- resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<void>;
15
+ resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<boolean>;
15
16
  findRunByToolCallId(toolCallId: string): Promise<{
16
17
  run: AgentRunState;
17
18
  approval: PendingApproval;
18
19
  } | null>;
20
+ saveScore(score: SaveScoreInput): Promise<void>;
21
+ getScores(runId: string): Promise<AIRunScore[]>;
19
22
  private toRunState;
20
23
  }
@@ -1,6 +1,7 @@
1
1
  import { sql } from 'kysely';
2
2
  import { ensurePikkuSchema } from './schema/index.js';
3
3
  import { aiSchema } from './schema/ai.schema.js';
4
+ import { getRunScores, saveRunScore } from './kysely-ai-run-scores.js';
4
5
  export class KyselyAIRunStateService {
5
6
  db;
6
7
  initialized = false;
@@ -100,14 +101,19 @@ export class KyselyAIRunStateService {
100
101
  if (filtered.length === 0) {
101
102
  updates.status = status;
102
103
  }
103
- await this.db
104
+ // Compare-and-swap on the list we read: a concurrent approver that got
105
+ // there first has already rewritten it, and this update matches no row.
106
+ const result = await this.db
104
107
  .updateTable('aiRun')
105
108
  .set(updates)
106
109
  .where('runId', '=', row.runId)
107
- .execute();
108
- return;
110
+ .where('status', '=', 'suspended')
111
+ .where('pendingApprovals', '=', row.pendingApprovals)
112
+ .executeTakeFirst();
113
+ return Number(result?.numUpdatedRows ?? 0) > 0;
109
114
  }
110
115
  }
116
+ return false;
111
117
  }
112
118
  async findRunByToolCallId(toolCallId) {
113
119
  const rows = await this.db
@@ -132,6 +138,12 @@ export class KyselyAIRunStateService {
132
138
  }
133
139
  return null;
134
140
  }
141
+ async saveScore(score) {
142
+ await saveRunScore(this.db, score);
143
+ }
144
+ async getScores(runId) {
145
+ return getRunScores(this.db, runId);
146
+ }
135
147
  toRunState(row) {
136
148
  return {
137
149
  runId: row.runId,
@@ -1,5 +1,6 @@
1
- import type { AIStorageService, AIRunStateService, CreateRunInput } from '@pikku/core/services';
1
+ import type { AIStorageService, AIRunStateService, CreateRunInput, SaveScoreInput } from '@pikku/core/services';
2
2
  import type { AIThread, AIMessage, AgentRunState } from '@pikku/core/ai-agent';
3
+ import type { AIRunScore } from '@pikku/core/ai-scorer';
3
4
  import type { Kysely } from 'kysely';
4
5
  import type { KyselyPikkuDB } from './kysely-tables.js';
5
6
  export declare class KyselyAIStorageService implements AIStorageService, AIRunStateService {
@@ -32,6 +33,8 @@ export declare class KyselyAIStorageService implements AIStorageService, AIRunSt
32
33
  run: AgentRunState;
33
34
  approval: NonNullable<AgentRunState['pendingApprovals']>[number];
34
35
  } | null>;
35
- resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<void>;
36
+ resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<boolean>;
37
+ saveScore(score: SaveScoreInput): Promise<void>;
38
+ getScores(runId: string): Promise<AIRunScore[]>;
36
39
  close(): Promise<void>;
37
40
  }
@@ -1,4 +1,5 @@
1
1
  import { parseJson } from './kysely-json.js';
2
+ import { getRunScores, saveRunScore } from './kysely-ai-run-scores.js';
2
3
  import { ensurePikkuSchema } from './schema/index.js';
3
4
  import { aiSchema } from './schema/ai.schema.js';
4
5
  export class KyselyAIStorageService {
@@ -510,11 +511,20 @@ export class KyselyAIStorageService {
510
511
  return { run, approval };
511
512
  }
512
513
  async resolveApproval(toolCallId, status) {
513
- await this.db
514
+ // Only the caller that moves the tool call off 'pending' has claimed it.
515
+ const result = await this.db
514
516
  .updateTable('aiToolCall')
515
517
  .set({ approvalStatus: status })
516
518
  .where('id', '=', toolCallId)
517
- .execute();
519
+ .where('approvalStatus', '=', 'pending')
520
+ .executeTakeFirst();
521
+ return Number(result?.numUpdatedRows ?? 0) > 0;
522
+ }
523
+ async saveScore(score) {
524
+ await saveRunScore(this.db, score);
525
+ }
526
+ async getScores(runId) {
527
+ return getRunScores(this.db, runId);
518
528
  }
519
529
  async close() { }
520
530
  }
@@ -133,6 +133,17 @@ export interface AIRunTable {
133
133
  createdAt: Generated<Date>;
134
134
  updatedAt: Generated<Date>;
135
135
  }
136
+ export interface AIRunScoreTable {
137
+ id: string;
138
+ runId: string;
139
+ scorerName: string;
140
+ /** 0..1, so grades are comparable across scorers. */
141
+ score: number;
142
+ reason: string | null;
143
+ /** JSON-serialized structured detail the scorer returned alongside the score. */
144
+ metadata: string | null;
145
+ createdAt: Generated<Date>;
146
+ }
136
147
  export interface PikkuDeploymentsTable {
137
148
  deploymentId: string;
138
149
  endpoint: string;
@@ -295,6 +306,7 @@ export interface KyselyPikkuDB {
295
306
  aiToolCall: AIToolCallTable;
296
307
  aiWorkingMemory: AIWorkingMemoryTable;
297
308
  aiRun: AIRunTable;
309
+ aiRunScore: AIRunScoreTable;
298
310
  pikkuDeployments: PikkuDeploymentsTable;
299
311
  pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable;
300
312
  secrets: SecretsTable;
@@ -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),
@@ -85,5 +85,18 @@ export const aiSchema = {
85
85
  .createIndex('idx_ai_run_thread')
86
86
  .on('aiRun')
87
87
  .columns(['threadId', 'createdAt']),
88
+ (db) => db.schema
89
+ .createTable('aiRunScore')
90
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
91
+ .addColumn('runId', 'varchar(36)', (col) => col.notNull().references('aiRun.runId').onDelete('cascade'))
92
+ .addColumn('scorerName', 'varchar(255)', (col) => col.notNull())
93
+ .addColumn('score', 'real', (col) => col.notNull())
94
+ .addColumn('reason', 'text')
95
+ .addColumn('metadata', 'text')
96
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
97
+ (db) => db.schema
98
+ .createIndex('idx_ai_run_score_run')
99
+ .on('aiRunScore')
100
+ .columns(['runId', 'createdAt']),
88
101
  ],
89
102
  };
@@ -1 +1 @@
1
- {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-ai-agent-run-service.ts","../src/kysely-ai-run-state-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/ai.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
1
+ {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-ai-agent-run-service.ts","../src/kysely-ai-run-scores.ts","../src/kysely-ai-run-state-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/ai.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.12",
3
+ "version": "0.13.15",
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.82"
25
25
  },
26
26
  "dependencies": {
27
27
  "kysely": "^0.29.0"
package/src/index.ts CHANGED
@@ -8,9 +8,7 @@ export { KyselyAIStorageService } from './kysely-ai-storage-service.js'
8
8
  export { KyselyAgentRunService } from './kysely-ai-agent-run-service.js'
9
9
  export { KyselyAIRunStateService } from './kysely-ai-run-state-service.js'
10
10
  export { KyselySecretService } from './kysely-secret-service.js'
11
- export type { KyselySecretServiceConfig } from './kysely-secret-service.js'
12
11
  export { KyselyCredentialService } from './kysely-credential-service.js'
13
- export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js'
14
12
  export { KyselySessionStore } from './kysely-session-store.js'
15
13
  export { KyselyScopeService } from './kysely-scope-service.js'
16
14
  export { KyselyWebhookService } from './kysely-webhook-service.js'
@@ -25,17 +23,9 @@ export {
25
23
  SerializePlugin,
26
24
  SqliteSerializePlugin,
27
25
  BaseSerializePlugin,
28
- defaultSerializer,
29
- defaultDeserializer,
30
- maybeJson,
31
- skipTransform,
32
- dateRegex,
33
26
  type Serializer,
34
27
  type Deserializer,
35
- type SerializePluginOptions,
36
28
  } from './serialize-plugin.js'
37
-
38
- export { virtualUserSchema } from './schema/virtual-user.schema.js'
39
29
  export {
40
30
  auditSchema,
41
31
  pikkuSchemas,
@@ -44,14 +34,9 @@ export {
44
34
  ensurePikkuSchema,
45
35
  type EnsureOutcome,
46
36
  resolveRequirements,
47
- unsatisfiedRequirements,
48
- rawStatement,
49
- requiredType,
50
37
  type PikkuSchema,
51
38
  type RequiredTypes,
52
- type SchemaRequirement,
53
39
  type UnmetRequirement,
54
- type SchemaStatement,
55
40
  type SchemaStatementFactory,
56
41
  } from './schema/index.js'
57
42
 
@@ -0,0 +1,47 @@
1
+ import type { Kysely } from 'kysely'
2
+ import type { KyselyPikkuDB } from './kysely-tables.js'
3
+ import type { SaveScoreInput } from '@pikku/core/services'
4
+ import type { AIRunScore } from '@pikku/core/ai-scorer'
5
+ import { parseJson } from './kysely-json.js'
6
+
7
+ /**
8
+ * Shared by the two Kysely services that implement `AIRunStateService` — they
9
+ * differ in how they store approvals, not in how they store grades.
10
+ */
11
+ export const saveRunScore = async (
12
+ db: Kysely<KyselyPikkuDB>,
13
+ score: SaveScoreInput
14
+ ): Promise<void> => {
15
+ await db
16
+ .insertInto('aiRunScore')
17
+ .values({
18
+ id: crypto.randomUUID(),
19
+ runId: score.runId,
20
+ scorerName: score.scorerName,
21
+ score: score.score,
22
+ reason: score.reason ?? null,
23
+ metadata: score.metadata ? JSON.stringify(score.metadata) : null,
24
+ })
25
+ .execute()
26
+ }
27
+
28
+ export const getRunScores = async (
29
+ db: Kysely<KyselyPikkuDB>,
30
+ runId: string
31
+ ): Promise<AIRunScore[]> => {
32
+ const rows = await db
33
+ .selectFrom('aiRunScore')
34
+ .selectAll()
35
+ .where('runId', '=', runId)
36
+ .orderBy('createdAt', 'asc')
37
+ .execute()
38
+
39
+ return rows.map((row) => ({
40
+ runId: row.runId,
41
+ scorerName: row.scorerName,
42
+ score: Number(row.score),
43
+ reason: row.reason ?? undefined,
44
+ metadata: parseJson(row.metadata),
45
+ createdAt: new Date(row.createdAt),
46
+ }))
47
+ }
@@ -1,10 +1,16 @@
1
1
  import { sql } from 'kysely'
2
2
  import type { Kysely } from 'kysely'
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js'
4
- import type { AIRunStateService, CreateRunInput } from '@pikku/core/services'
4
+ import type {
5
+ AIRunStateService,
6
+ CreateRunInput,
7
+ SaveScoreInput,
8
+ } from '@pikku/core/services'
5
9
  import type { AgentRunState, PendingApproval } from '@pikku/core/ai-agent'
10
+ import type { AIRunScore } from '@pikku/core/ai-scorer'
6
11
  import { ensurePikkuSchema } from './schema/index.js'
7
12
  import { aiSchema } from './schema/ai.schema.js'
13
+ import { getRunScores, saveRunScore } from './kysely-ai-run-scores.js'
8
14
 
9
15
  export class KyselyAIRunStateService implements AIRunStateService {
10
16
  private initialized = false
@@ -89,7 +95,7 @@ export class KyselyAIRunStateService implements AIRunStateService {
89
95
  async resolveApproval(
90
96
  toolCallId: string,
91
97
  status: 'approved' | 'denied'
92
- ): Promise<void> {
98
+ ): Promise<boolean> {
93
99
  const rows = await this.db
94
100
  .selectFrom('aiRun')
95
101
  .select(['runId', 'pendingApprovals'])
@@ -117,14 +123,19 @@ export class KyselyAIRunStateService implements AIRunStateService {
117
123
  if (filtered.length === 0) {
118
124
  updates.status = status
119
125
  }
120
- await this.db
126
+ // Compare-and-swap on the list we read: a concurrent approver that got
127
+ // there first has already rewritten it, and this update matches no row.
128
+ const result = await this.db
121
129
  .updateTable('aiRun')
122
130
  .set(updates as any)
123
131
  .where('runId', '=', row.runId)
124
- .execute()
125
- return
132
+ .where('status', '=', 'suspended')
133
+ .where('pendingApprovals', '=', row.pendingApprovals)
134
+ .executeTakeFirst()
135
+ return Number(result?.numUpdatedRows ?? 0) > 0
126
136
  }
127
137
  }
138
+ return false
128
139
  }
129
140
 
130
141
  async findRunByToolCallId(
@@ -155,6 +166,14 @@ export class KyselyAIRunStateService implements AIRunStateService {
155
166
  return null
156
167
  }
157
168
 
169
+ async saveScore(score: SaveScoreInput): Promise<void> {
170
+ await saveRunScore(this.db, score)
171
+ }
172
+
173
+ async getScores(runId: string): Promise<AIRunScore[]> {
174
+ return getRunScores(this.db, runId)
175
+ }
176
+
158
177
  private toRunState(row: any): AgentRunState {
159
178
  return {
160
179
  runId: row.runId,
@@ -2,11 +2,14 @@ import type {
2
2
  AIStorageService,
3
3
  AIRunStateService,
4
4
  CreateRunInput,
5
+ SaveScoreInput,
5
6
  } from '@pikku/core/services'
6
7
  import type { AIThread, AIMessage, AgentRunState } from '@pikku/core/ai-agent'
8
+ import type { AIRunScore } from '@pikku/core/ai-scorer'
7
9
  import type { Kysely } from 'kysely'
8
10
  import type { KyselyPikkuDB } from './kysely-tables.js'
9
11
  import { parseJson } from './kysely-json.js'
12
+ import { getRunScores, saveRunScore } from './kysely-ai-run-scores.js'
10
13
  import { ensurePikkuSchema } from './schema/index.js'
11
14
  import { aiSchema } from './schema/ai.schema.js'
12
15
 
@@ -596,12 +599,23 @@ export class KyselyAIStorageService
596
599
  async resolveApproval(
597
600
  toolCallId: string,
598
601
  status: 'approved' | 'denied'
599
- ): Promise<void> {
600
- await this.db
602
+ ): Promise<boolean> {
603
+ // Only the caller that moves the tool call off 'pending' has claimed it.
604
+ const result = await this.db
601
605
  .updateTable('aiToolCall')
602
606
  .set({ approvalStatus: status })
603
607
  .where('id', '=', toolCallId)
604
- .execute()
608
+ .where('approvalStatus', '=', 'pending')
609
+ .executeTakeFirst()
610
+ return Number(result?.numUpdatedRows ?? 0) > 0
611
+ }
612
+
613
+ async saveScore(score: SaveScoreInput): Promise<void> {
614
+ await saveRunScore(this.db, score)
615
+ }
616
+
617
+ async getScores(runId: string): Promise<AIRunScore[]> {
618
+ return getRunScores(this.db, runId)
605
619
  }
606
620
 
607
621
  public async close(): Promise<void> {}
@@ -44,6 +44,7 @@ async function dropAllTables(db: Kysely<KyselyPikkuDB>): Promise<void> {
44
44
  'pikku_deployments',
45
45
  'ai_tool_call',
46
46
  'ai_message',
47
+ 'ai_run_score',
47
48
  'ai_run',
48
49
  'ai_working_memory',
49
50
  'ai_threads',
@@ -151,6 +151,18 @@ export interface AIRunTable {
151
151
  updatedAt: Generated<Date>
152
152
  }
153
153
 
154
+ export interface AIRunScoreTable {
155
+ id: string
156
+ runId: string
157
+ scorerName: string
158
+ /** 0..1, so grades are comparable across scorers. */
159
+ score: number
160
+ reason: string | null
161
+ /** JSON-serialized structured detail the scorer returned alongside the score. */
162
+ metadata: string | null
163
+ createdAt: Generated<Date>
164
+ }
165
+
154
166
  export interface PikkuDeploymentsTable {
155
167
  deploymentId: string
156
168
  endpoint: string
@@ -329,6 +341,7 @@ export interface KyselyPikkuDB {
329
341
  aiToolCall: AIToolCallTable
330
342
  aiWorkingMemory: AIWorkingMemoryTable
331
343
  aiRun: AIRunTable
344
+ aiRunScore: AIRunScoreTable
332
345
  pikkuDeployments: PikkuDeploymentsTable
333
346
  pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable
334
347
  secrets: SecretsTable
@@ -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),
@@ -136,5 +136,26 @@ export const aiSchema: PikkuSchema = {
136
136
  .createIndex('idx_ai_run_thread')
137
137
  .on('aiRun')
138
138
  .columns(['threadId', 'createdAt']),
139
+
140
+ (db) =>
141
+ db.schema
142
+ .createTable('aiRunScore')
143
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
144
+ .addColumn('runId', 'varchar(36)', (col) =>
145
+ col.notNull().references('aiRun.runId').onDelete('cascade')
146
+ )
147
+ .addColumn('scorerName', 'varchar(255)', (col) => col.notNull())
148
+ .addColumn('score', 'real', (col) => col.notNull())
149
+ .addColumn('reason', 'text')
150
+ .addColumn('metadata', 'text')
151
+ .addColumn('createdAt', 'timestamp', (col) =>
152
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
153
+ ),
154
+
155
+ (db) =>
156
+ db.schema
157
+ .createIndex('idx_ai_run_score_run')
158
+ .on('aiRunScore')
159
+ .columns(['runId', 'createdAt']),
139
160
  ],
140
161
  }