@pikku/kysely 0.12.2 → 0.12.3

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.
@@ -140,6 +140,7 @@ export class KyselyAgentRunService implements AgentRunService {
140
140
  'thread_id',
141
141
  'resource_id',
142
142
  'status',
143
+ 'error_message',
143
144
  'suspend_reason',
144
145
  'missing_rpcs',
145
146
  'usage_input_tokens',
@@ -193,6 +194,7 @@ export class KyselyAgentRunService implements AgentRunService {
193
194
  threadId: row.thread_id as string,
194
195
  resourceId: row.resource_id as string,
195
196
  status: row.status as string,
197
+ errorMessage: (row.error_message as string) ?? undefined,
196
198
  suspendReason: (row.suspend_reason as string) ?? undefined,
197
199
  missingRpcs: parseJson(row.missing_rpcs),
198
200
  usageInputTokens: Number(row.usage_input_tokens),
@@ -22,8 +22,11 @@ export class KyselyAIStorageService
22
22
  try {
23
23
  await builder.execute()
24
24
  } catch (e: any) {
25
- // Ignore "index already exists" errors (MySQL doesn't support IF NOT EXISTS for indexes)
25
+ // Ignore "index already exists" errors across databases
26
+ // MySQL: ER_DUP_KEYNAME, Postgres: 42P07, SQLite: "already exists"
26
27
  if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061) return
28
+ if (e?.code === '42P07') return
29
+ if (e?.message?.includes('already exists')) return
27
30
  throw e
28
31
  }
29
32
  }
@@ -138,6 +141,7 @@ export class KyselyAIStorageService
138
141
  .addColumn('status', 'varchar(50)', (col) =>
139
142
  col.notNull().defaultTo('running')
140
143
  )
144
+ .addColumn('error_message', 'text')
141
145
  .addColumn('suspend_reason', 'text')
142
146
  .addColumn('missing_rpcs', 'text')
143
147
  .addColumn('usage_input_tokens', 'integer', (col) =>
@@ -463,6 +467,7 @@ export class KyselyAIStorageService
463
467
  thread_id: run.threadId,
464
468
  resource_id: run.resourceId,
465
469
  status: run.status,
470
+ error_message: run.errorMessage ?? null,
466
471
  suspend_reason: run.suspendReason ?? null,
467
472
  missing_rpcs: run.missingRpcs ? JSON.stringify(run.missingRpcs) : null,
468
473
  usage_input_tokens: run.usage.inputTokens,
@@ -489,6 +494,9 @@ export class KyselyAIStorageService
489
494
  if (updates.status !== undefined) {
490
495
  setValues.status = updates.status
491
496
  }
497
+ if (updates.errorMessage !== undefined) {
498
+ setValues.error_message = updates.errorMessage
499
+ }
492
500
  if (updates.suspendReason !== undefined) {
493
501
  setValues.suspend_reason = updates.suspendReason
494
502
  }
@@ -537,6 +545,7 @@ export class KyselyAIStorageService
537
545
  'thread_id',
538
546
  'resource_id',
539
547
  'status',
548
+ 'error_message',
540
549
  'suspend_reason',
541
550
  'missing_rpcs',
542
551
  'usage_input_tokens',
@@ -577,6 +586,7 @@ export class KyselyAIStorageService
577
586
  'thread_id',
578
587
  'resource_id',
579
588
  'status',
589
+ 'error_message',
580
590
  'suspend_reason',
581
591
  'missing_rpcs',
582
592
  'usage_input_tokens',
@@ -670,6 +680,7 @@ export class KyselyAIStorageService
670
680
  threadId: row.thread_id as string,
671
681
  resourceId: row.resource_id as string,
672
682
  status: row.status as AgentRunState['status'],
683
+ errorMessage: row.error_message ?? undefined,
673
684
  suspendReason: row.suspend_reason as AgentRunState['suspendReason'],
674
685
  missingRpcs: parseJson(row.missing_rpcs),
675
686
  pendingApprovals,
@@ -18,7 +18,7 @@ export class KyselyDeploymentService implements DeploymentService {
18
18
 
19
19
  constructor(
20
20
  config: DeploymentServiceConfig,
21
- private db: Kysely<KyselyPikkuDB>
21
+ protected db: Kysely<KyselyPikkuDB>
22
22
  ) {
23
23
  this.heartbeatInterval = config.heartbeatInterval ?? 10000
24
24
  this.heartbeatTtl = config.heartbeatTtl ?? 30000
@@ -58,19 +58,21 @@ export class KyselyDeploymentService implements DeploymentService {
58
58
  ])
59
59
  .execute()
60
60
 
61
- await this.db.schema
62
- .createIndex('idx_pikku_deployments_heartbeat')
63
- .ifNotExists()
64
- .on('pikku_deployments')
65
- .column('last_heartbeat')
66
- .execute()
61
+ await this.createIndexSafe(
62
+ this.db.schema
63
+ .createIndex('idx_pikku_deployments_heartbeat')
64
+ .ifNotExists()
65
+ .on('pikku_deployments')
66
+ .column('last_heartbeat')
67
+ )
67
68
 
68
- await this.db.schema
69
- .createIndex('idx_pikku_deployment_functions_name')
70
- .ifNotExists()
71
- .on('pikku_deployment_functions')
72
- .column('function_name')
73
- .execute()
69
+ await this.createIndexSafe(
70
+ this.db.schema
71
+ .createIndex('idx_pikku_deployment_functions_name')
72
+ .ifNotExists()
73
+ .on('pikku_deployment_functions')
74
+ .column('function_name')
75
+ )
74
76
 
75
77
  this.initialized = true
76
78
  }
@@ -156,6 +158,19 @@ export class KyselyDeploymentService implements DeploymentService {
156
158
  }))
157
159
  }
158
160
 
161
+ private async createIndexSafe(builder: {
162
+ execute(): Promise<void>
163
+ }): Promise<void> {
164
+ try {
165
+ await builder.execute()
166
+ } catch (e: any) {
167
+ if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061) return
168
+ if (e?.code === '42P07') return
169
+ if (e?.message?.includes('already exists')) return
170
+ throw e
171
+ }
172
+ }
173
+
159
174
  private async sendHeartbeat(): Promise<void> {
160
175
  if (!this.deploymentConfig) return
161
176
 
@@ -0,0 +1,197 @@
1
+ import type { SecretService } from '@pikku/core/services'
2
+ import type { Kysely } from 'kysely'
3
+ import { sql } from 'kysely'
4
+ import type { KyselyPikkuDB } from './kysely-tables.js'
5
+ import {
6
+ envelopeEncrypt,
7
+ envelopeDecrypt,
8
+ envelopeRewrap,
9
+ } from '@pikku/core/crypto-utils'
10
+
11
+ export interface KyselySecretServiceConfig {
12
+ key: string
13
+ keyVersion?: number
14
+ previousKey?: string
15
+ audit?: boolean
16
+ auditReads?: boolean
17
+ }
18
+
19
+ export class KyselySecretService implements SecretService {
20
+ private initialized = false
21
+ private key: string
22
+ private keyVersion: number
23
+ private previousKey?: string
24
+ private audit: boolean
25
+ private auditReads: boolean
26
+
27
+ constructor(
28
+ private db: Kysely<KyselyPikkuDB>,
29
+ config: KyselySecretServiceConfig
30
+ ) {
31
+ this.key = config.key
32
+ this.keyVersion = config.keyVersion ?? 1
33
+ this.previousKey = config.previousKey
34
+ this.audit = config.audit ?? false
35
+ this.auditReads = config.auditReads ?? false
36
+ }
37
+
38
+ public async init(): Promise<void> {
39
+ if (this.initialized) return
40
+
41
+ await this.db.schema
42
+ .createTable('secrets')
43
+ .ifNotExists()
44
+ .addColumn('key', 'varchar(255)', (col) => col.primaryKey())
45
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
46
+ .addColumn('wrapped_dek', 'text', (col) => col.notNull())
47
+ .addColumn('key_version', 'integer', (col) => col.notNull())
48
+ .addColumn('created_at', 'timestamp', (col) =>
49
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
50
+ )
51
+ .addColumn('updated_at', 'timestamp', (col) =>
52
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
53
+ )
54
+ .execute()
55
+
56
+ if (this.audit) {
57
+ await this.db.schema
58
+ .createTable('secrets_audit')
59
+ .ifNotExists()
60
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
61
+ .addColumn('secret_key', 'varchar(255)', (col) => col.notNull())
62
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
63
+ .addColumn('performed_at', 'timestamp', (col) =>
64
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
65
+ )
66
+ .execute()
67
+ }
68
+
69
+ this.initialized = true
70
+ }
71
+
72
+ private async logAudit(
73
+ secretKey: string,
74
+ action: 'read' | 'write' | 'delete' | 'rotate'
75
+ ): Promise<void> {
76
+ if (!this.audit) return
77
+ if (action === 'read' && !this.auditReads) return
78
+
79
+ await this.db
80
+ .insertInto('secrets_audit')
81
+ .values({
82
+ id: crypto.randomUUID(),
83
+ secret_key: secretKey,
84
+ action,
85
+ performed_at: new Date().toISOString() as any,
86
+ })
87
+ .execute()
88
+ }
89
+
90
+ private getKEK(version: number): string {
91
+ if (version === this.keyVersion) return this.key
92
+ if (this.previousKey) return this.previousKey
93
+ throw new Error(`No KEK available for key_version ${version}`)
94
+ }
95
+
96
+ async getSecret(key: string): Promise<string> {
97
+ const row = await this.db
98
+ .selectFrom('secrets')
99
+ .select(['ciphertext', 'wrapped_dek', 'key_version'])
100
+ .where('key', '=', key)
101
+ .executeTakeFirst()
102
+
103
+ if (!row) throw new Error(`Secret not found: ${key}`)
104
+
105
+ const kek = this.getKEK(row.key_version)
106
+ const result = await envelopeDecrypt<string>(
107
+ kek,
108
+ row.ciphertext,
109
+ row.wrapped_dek
110
+ )
111
+ await this.logAudit(key, 'read')
112
+ return result
113
+ }
114
+
115
+ async getSecretJSON<R = {}>(key: string): Promise<R> {
116
+ const raw = await this.getSecret(key)
117
+ return JSON.parse(raw) as R
118
+ }
119
+
120
+ async hasSecret(key: string): Promise<boolean> {
121
+ const row = await this.db
122
+ .selectFrom('secrets')
123
+ .select('key')
124
+ .where('key', '=', key)
125
+ .executeTakeFirst()
126
+ return !!row
127
+ }
128
+
129
+ async setSecretJSON(key: string, value: unknown): Promise<void> {
130
+ const plaintext = JSON.stringify(value)
131
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
132
+ this.key,
133
+ plaintext
134
+ )
135
+ const now = new Date().toISOString()
136
+
137
+ await this.db
138
+ .insertInto('secrets')
139
+ .values({
140
+ key,
141
+ ciphertext,
142
+ wrapped_dek: wrappedDEK,
143
+ key_version: this.keyVersion,
144
+ created_at: now as any,
145
+ updated_at: now as any,
146
+ })
147
+ .onConflict((oc) =>
148
+ oc.column('key').doUpdateSet({
149
+ ciphertext,
150
+ wrapped_dek: wrappedDEK,
151
+ key_version: this.keyVersion,
152
+ updated_at: now as any,
153
+ })
154
+ )
155
+ .execute()
156
+
157
+ await this.logAudit(key, 'write')
158
+ }
159
+
160
+ async deleteSecret(key: string): Promise<void> {
161
+ await this.db.deleteFrom('secrets').where('key', '=', key).execute()
162
+ await this.logAudit(key, 'delete')
163
+ }
164
+
165
+ async rotateKEK(): Promise<number> {
166
+ if (!this.previousKey) {
167
+ throw new Error('No previousKey configured — nothing to rotate from')
168
+ }
169
+
170
+ const rows = await this.db
171
+ .selectFrom('secrets')
172
+ .select(['key', 'wrapped_dek'])
173
+ .where('key_version', '<', this.keyVersion)
174
+ .execute()
175
+
176
+ for (const row of rows) {
177
+ const newWrappedDEK = await envelopeRewrap(
178
+ this.previousKey,
179
+ this.key,
180
+ row.wrapped_dek
181
+ )
182
+ await this.db
183
+ .updateTable('secrets')
184
+ .set({
185
+ wrapped_dek: newWrappedDEK,
186
+ key_version: this.keyVersion,
187
+ updated_at: new Date().toISOString() as any,
188
+ })
189
+ .where('key', '=', row.key)
190
+ .execute()
191
+
192
+ await this.logAudit(row.key, 'rotate')
193
+ }
194
+
195
+ return rows.length
196
+ }
197
+ }