@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.
- package/CHANGELOG.md +16 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -1
- package/dist/src/kysely-agent-run-service.js +2 -0
- package/dist/src/kysely-ai-storage-service.js +14 -1
- package/dist/src/kysely-deployment-service.d.ts +2 -1
- package/dist/src/kysely-deployment-service.js +18 -6
- package/dist/src/kysely-secret-service.d.ts +29 -0
- package/dist/src/kysely-secret-service.js +142 -0
- package/dist/src/kysely-tables.d.ts +27 -8
- package/dist/src/kysely-workflow-run-service.d.ts +5 -0
- package/dist/src/kysely-workflow-run-service.js +16 -0
- package/dist/src/kysely-workflow-service.d.ts +11 -5
- package/dist/src/kysely-workflow-service.js +18 -22
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -5
- package/src/index.ts +2 -2
- package/src/kysely-agent-run-service.ts +2 -0
- package/src/kysely-ai-storage-service.ts +12 -1
- package/src/kysely-deployment-service.ts +28 -13
- package/src/kysely-secret-service.ts +197 -0
- package/src/kysely-services.test.ts +89 -698
- package/src/kysely-tables.ts +33 -8
- package/src/kysely-workflow-run-service.ts +20 -1
- package/src/kysely-workflow-service.ts +30 -24
- package/dist/src/pikku-kysely.d.ts +0 -13
- package/dist/src/pikku-kysely.js +0 -58
- package/src/pikku-kysely.ts +0 -67
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
62
|
-
.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|