@pikku/kysely 0.12.1 → 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 CHANGED
@@ -1,5 +1,31 @@
1
1
  ## 0.12.0
2
2
 
3
+ ## 0.12.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 32ed003: Add envelope encryption utilities and database-backed secret services with KEK rotation support
8
+ - 387b2ee: Add error_message column to agent run storage and queries
9
+ - b2b0af9: Migrate all consumers from @pikku/pg to @pikku/kysely and remove the @pikku/pg package
10
+ - c7ff141: Add WorkflowVersionStatus type with draft→active lifecycle for AI-generated workflows, type all DB status fields with proper unions instead of plain strings
11
+ - Updated dependencies [387b2ee]
12
+ - Updated dependencies [32ed003]
13
+ - Updated dependencies [7d369f3]
14
+ - Updated dependencies [508a796]
15
+ - Updated dependencies [ffe83af]
16
+ - Updated dependencies [c7ff141]
17
+ - @pikku/core@0.12.3
18
+
19
+ ## 0.12.2
20
+
21
+ ### Patch Changes
22
+
23
+ - ce961b5: fix: improve MySQL compatibility in AI storage service by using varchar columns with explicit lengths instead of text for primary keys, foreign keys, and indexed columns, and handle duplicate index errors gracefully
24
+ - 3e04565: chore: update dependencies to latest minor/patch versions
25
+ - Updated dependencies [cc4c9e9]
26
+ - Updated dependencies [3e04565]
27
+ - @pikku/core@0.12.2
28
+
3
29
  ## 0.12.1
4
30
 
5
31
  ### Patch Changes
@@ -1,4 +1,3 @@
1
- export { PikkuKysely } from './pikku-kysely.js';
2
1
  export { KyselyChannelStore } from './kysely-channel-store.js';
3
2
  export { KyselyEventHubStore } from './kysely-eventhub-store.js';
4
3
  export { KyselyWorkflowService } from './kysely-workflow-service.js';
@@ -6,6 +5,8 @@ export { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
6
5
  export { KyselyDeploymentService } from './kysely-deployment-service.js';
7
6
  export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
8
7
  export { KyselyAgentRunService } from './kysely-agent-run-service.js';
8
+ export { KyselySecretService } from './kysely-secret-service.js';
9
+ export type { KyselySecretServiceConfig } from './kysely-secret-service.js';
9
10
  export type { KyselyPikkuDB } from './kysely-tables.js';
10
11
  export type { WorkflowRunService } from '@pikku/core/workflow';
11
12
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent';
package/dist/src/index.js CHANGED
@@ -1,4 +1,3 @@
1
- export { PikkuKysely } from './pikku-kysely.js';
2
1
  export { KyselyChannelStore } from './kysely-channel-store.js';
3
2
  export { KyselyEventHubStore } from './kysely-eventhub-store.js';
4
3
  export { KyselyWorkflowService } from './kysely-workflow-service.js';
@@ -6,3 +5,4 @@ export { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
6
5
  export { KyselyDeploymentService } from './kysely-deployment-service.js';
7
6
  export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
8
7
  export { KyselyAgentRunService } from './kysely-agent-run-service.js';
8
+ export { KyselySecretService } from './kysely-secret-service.js';
@@ -113,6 +113,7 @@ export class KyselyAgentRunService {
113
113
  'thread_id',
114
114
  'resource_id',
115
115
  'status',
116
+ 'error_message',
116
117
  'suspend_reason',
117
118
  'missing_rpcs',
118
119
  'usage_input_tokens',
@@ -159,6 +160,7 @@ export class KyselyAgentRunService {
159
160
  threadId: row.thread_id,
160
161
  resourceId: row.resource_id,
161
162
  status: row.status,
163
+ errorMessage: row.error_message ?? undefined,
162
164
  suspendReason: row.suspend_reason ?? undefined,
163
165
  missingRpcs: parseJson(row.missing_rpcs),
164
166
  usageInputTokens: Number(row.usage_input_tokens),
@@ -6,6 +6,7 @@ export declare class KyselyAIStorageService implements AIStorageService, AIRunSt
6
6
  private db;
7
7
  private initialized;
8
8
  constructor(db: Kysely<KyselyPikkuDB>);
9
+ private createIndexSafe;
9
10
  init(): Promise<void>;
10
11
  createThread(resourceId: string, options?: {
11
12
  threadId?: string;
@@ -6,6 +6,22 @@ export class KyselyAIStorageService {
6
6
  constructor(db) {
7
7
  this.db = db;
8
8
  }
9
+ async createIndexSafe(builder) {
10
+ try {
11
+ await builder.execute();
12
+ }
13
+ catch (e) {
14
+ // Ignore "index already exists" errors across databases
15
+ // MySQL: ER_DUP_KEYNAME, Postgres: 42P07, SQLite: "already exists"
16
+ if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061)
17
+ return;
18
+ if (e?.code === '42P07')
19
+ return;
20
+ if (e?.message?.includes('already exists'))
21
+ return;
22
+ throw e;
23
+ }
24
+ }
9
25
  async init() {
10
26
  if (this.initialized) {
11
27
  return;
@@ -13,68 +29,60 @@ export class KyselyAIStorageService {
13
29
  await this.db.schema
14
30
  .createTable('ai_threads')
15
31
  .ifNotExists()
16
- .addColumn('id', 'text', (col) => col.primaryKey())
17
- .addColumn('resource_id', 'text', (col) => col.notNull())
32
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
33
+ .addColumn('resource_id', 'varchar(255)', (col) => col.notNull())
18
34
  .addColumn('title', 'text')
19
35
  .addColumn('metadata', 'text')
20
36
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
21
37
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
22
38
  .execute();
23
- await this.db.schema
39
+ await this.createIndexSafe(this.db.schema
24
40
  .createIndex('idx_ai_threads_resource')
25
- .ifNotExists()
26
41
  .on('ai_threads')
27
- .column('resource_id')
28
- .execute();
42
+ .column('resource_id'));
29
43
  await this.db.schema
30
44
  .createTable('ai_message')
31
45
  .ifNotExists()
32
- .addColumn('id', 'text', (col) => col.primaryKey())
33
- .addColumn('thread_id', 'text', (col) => col.notNull().references('ai_threads.id').onDelete('cascade'))
34
- .addColumn('role', 'text', (col) => col.notNull())
46
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
47
+ .addColumn('thread_id', 'varchar(36)', (col) => col.notNull().references('ai_threads.id').onDelete('cascade'))
48
+ .addColumn('role', 'varchar(50)', (col) => col.notNull())
35
49
  .addColumn('content', 'text')
36
50
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
37
51
  .execute();
38
- await this.db.schema
52
+ await this.createIndexSafe(this.db.schema
39
53
  .createIndex('idx_ai_message_thread')
40
- .ifNotExists()
41
54
  .on('ai_message')
42
- .columns(['thread_id', 'created_at'])
43
- .execute();
55
+ .columns(['thread_id', 'created_at']));
44
56
  await this.db.schema
45
57
  .createTable('ai_tool_call')
46
58
  .ifNotExists()
47
- .addColumn('id', 'text', (col) => col.primaryKey())
48
- .addColumn('thread_id', 'text', (col) => col.notNull().references('ai_threads.id').onDelete('cascade'))
49
- .addColumn('message_id', 'text', (col) => col.notNull().references('ai_message.id').onDelete('cascade'))
50
- .addColumn('run_id', 'text')
51
- .addColumn('tool_name', 'text', (col) => col.notNull())
52
- .addColumn('args', 'text', (col) => col.notNull().defaultTo('{}'))
59
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
60
+ .addColumn('thread_id', 'varchar(36)', (col) => col.notNull().references('ai_threads.id').onDelete('cascade'))
61
+ .addColumn('message_id', 'varchar(36)', (col) => col.notNull().references('ai_message.id').onDelete('cascade'))
62
+ .addColumn('run_id', 'varchar(36)')
63
+ .addColumn('tool_name', 'varchar(255)', (col) => col.notNull())
64
+ .addColumn('args', 'text', (col) => col.notNull())
53
65
  .addColumn('result', 'text')
54
- .addColumn('approval_status', 'text')
55
- .addColumn('approval_type', 'text')
56
- .addColumn('agent_run_id', 'text')
57
- .addColumn('display_tool_name', 'text')
66
+ .addColumn('approval_status', 'varchar(50)')
67
+ .addColumn('approval_type', 'varchar(50)')
68
+ .addColumn('agent_run_id', 'varchar(36)')
69
+ .addColumn('display_tool_name', 'varchar(255)')
58
70
  .addColumn('display_args', 'text')
59
71
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
60
72
  .execute();
61
- await this.db.schema
73
+ await this.createIndexSafe(this.db.schema
62
74
  .createIndex('idx_ai_tool_call_thread')
63
- .ifNotExists()
64
75
  .on('ai_tool_call')
65
- .column('thread_id')
66
- .execute();
67
- await this.db.schema
76
+ .column('thread_id'));
77
+ await this.createIndexSafe(this.db.schema
68
78
  .createIndex('idx_ai_tool_call_message')
69
- .ifNotExists()
70
79
  .on('ai_tool_call')
71
- .column('message_id')
72
- .execute();
80
+ .column('message_id'));
73
81
  await this.db.schema
74
82
  .createTable('ai_working_memory')
75
83
  .ifNotExists()
76
- .addColumn('id', 'text', (col) => col.notNull())
77
- .addColumn('scope', 'text', (col) => col.notNull())
84
+ .addColumn('id', 'varchar(255)', (col) => col.notNull())
85
+ .addColumn('scope', 'varchar(50)', (col) => col.notNull())
78
86
  .addColumn('data', 'text', (col) => col.notNull())
79
87
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
80
88
  .addPrimaryKeyConstraint('ai_working_memory_pk', ['id', 'scope'])
@@ -82,25 +90,24 @@ export class KyselyAIStorageService {
82
90
  await this.db.schema
83
91
  .createTable('ai_run')
84
92
  .ifNotExists()
85
- .addColumn('run_id', 'text', (col) => col.primaryKey())
86
- .addColumn('agent_name', 'text', (col) => col.notNull())
87
- .addColumn('thread_id', 'text', (col) => col.notNull().references('ai_threads.id').onDelete('cascade'))
88
- .addColumn('resource_id', 'text', (col) => col.notNull())
89
- .addColumn('status', 'text', (col) => col.notNull().defaultTo('running'))
93
+ .addColumn('run_id', 'varchar(36)', (col) => col.primaryKey())
94
+ .addColumn('agent_name', 'varchar(255)', (col) => col.notNull())
95
+ .addColumn('thread_id', 'varchar(36)', (col) => col.notNull().references('ai_threads.id').onDelete('cascade'))
96
+ .addColumn('resource_id', 'varchar(255)', (col) => col.notNull())
97
+ .addColumn('status', 'varchar(50)', (col) => col.notNull().defaultTo('running'))
98
+ .addColumn('error_message', 'text')
90
99
  .addColumn('suspend_reason', 'text')
91
100
  .addColumn('missing_rpcs', 'text')
92
101
  .addColumn('usage_input_tokens', 'integer', (col) => col.notNull().defaultTo(0))
93
102
  .addColumn('usage_output_tokens', 'integer', (col) => col.notNull().defaultTo(0))
94
- .addColumn('usage_model', 'text', (col) => col.notNull().defaultTo(''))
103
+ .addColumn('usage_model', 'varchar(255)', (col) => col.notNull().defaultTo(''))
95
104
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
96
105
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
97
106
  .execute();
98
- await this.db.schema
107
+ await this.createIndexSafe(this.db.schema
99
108
  .createIndex('idx_ai_run_thread')
100
- .ifNotExists()
101
109
  .on('ai_run')
102
- .columns(['thread_id', 'created_at'])
103
- .execute();
110
+ .columns(['thread_id', 'created_at']));
104
111
  this.initialized = true;
105
112
  }
106
113
  async createThread(resourceId, options) {
@@ -219,10 +226,26 @@ export class KyselyAIStorageService {
219
226
  }
220
227
  const messages = [];
221
228
  for (const row of msgResult) {
229
+ const rawContent = row.content;
230
+ let parsedContent = rawContent ?? undefined;
231
+ if (rawContent) {
232
+ try {
233
+ const parsed = JSON.parse(rawContent);
234
+ if (Array.isArray(parsed)) {
235
+ parsedContent = parsed;
236
+ }
237
+ else if (typeof parsed === 'string') {
238
+ parsedContent = parsed;
239
+ }
240
+ }
241
+ catch {
242
+ // Not JSON, use raw string
243
+ }
244
+ }
222
245
  const msg = {
223
246
  id: row.id,
224
247
  role: row.role,
225
- content: row.content ?? undefined,
248
+ content: parsedContent,
226
249
  createdAt: new Date(row.created_at),
227
250
  };
228
251
  const tcs = tcByMessage.get(msg.id);
@@ -264,7 +287,7 @@ export class KyselyAIStorageService {
264
287
  id: msg.id,
265
288
  thread_id: threadId,
266
289
  role: msg.role,
267
- content: msg.content ?? null,
290
+ content: msg.content != null ? JSON.stringify(msg.content) : null,
268
291
  created_at: msg.createdAt ?? new Date(),
269
292
  })))
270
293
  .execute();
@@ -335,6 +358,7 @@ export class KyselyAIStorageService {
335
358
  thread_id: run.threadId,
336
359
  resource_id: run.resourceId,
337
360
  status: run.status,
361
+ error_message: run.errorMessage ?? null,
338
362
  suspend_reason: run.suspendReason ?? null,
339
363
  missing_rpcs: run.missingRpcs ? JSON.stringify(run.missingRpcs) : null,
340
364
  usage_input_tokens: run.usage.inputTokens,
@@ -354,6 +378,9 @@ export class KyselyAIStorageService {
354
378
  if (updates.status !== undefined) {
355
379
  setValues.status = updates.status;
356
380
  }
381
+ if (updates.errorMessage !== undefined) {
382
+ setValues.error_message = updates.errorMessage;
383
+ }
357
384
  if (updates.suspendReason !== undefined) {
358
385
  setValues.suspend_reason = updates.suspendReason;
359
386
  }
@@ -398,6 +425,7 @@ export class KyselyAIStorageService {
398
425
  'thread_id',
399
426
  'resource_id',
400
427
  'status',
428
+ 'error_message',
401
429
  'suspend_reason',
402
430
  'missing_rpcs',
403
431
  'usage_input_tokens',
@@ -435,6 +463,7 @@ export class KyselyAIStorageService {
435
463
  'thread_id',
436
464
  'resource_id',
437
465
  'status',
466
+ 'error_message',
438
467
  'suspend_reason',
439
468
  'missing_rpcs',
440
469
  'usage_input_tokens',
@@ -522,6 +551,7 @@ export class KyselyAIStorageService {
522
551
  threadId: row.thread_id,
523
552
  resourceId: row.resource_id,
524
553
  status: row.status,
554
+ errorMessage: row.error_message ?? undefined,
525
555
  suspendReason: row.suspend_reason,
526
556
  missingRpcs: parseJson(row.missing_rpcs),
527
557
  pendingApprovals,
@@ -2,7 +2,7 @@ import type { DeploymentService, DeploymentServiceConfig, DeploymentConfig, Depl
2
2
  import type { Kysely } from 'kysely';
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js';
4
4
  export declare class KyselyDeploymentService implements DeploymentService {
5
- private db;
5
+ protected db: Kysely<KyselyPikkuDB>;
6
6
  private initialized;
7
7
  private heartbeatTimer?;
8
8
  private deploymentConfig?;
@@ -13,5 +13,6 @@ export declare class KyselyDeploymentService implements DeploymentService {
13
13
  start(config: DeploymentConfig): Promise<void>;
14
14
  stop(): Promise<void>;
15
15
  findFunction(name: string): Promise<DeploymentInfo[]>;
16
+ private createIndexSafe;
16
17
  private sendHeartbeat;
17
18
  }
@@ -37,18 +37,16 @@ export class KyselyDeploymentService {
37
37
  'function_name',
38
38
  ])
39
39
  .execute();
40
- await this.db.schema
40
+ await this.createIndexSafe(this.db.schema
41
41
  .createIndex('idx_pikku_deployments_heartbeat')
42
42
  .ifNotExists()
43
43
  .on('pikku_deployments')
44
- .column('last_heartbeat')
45
- .execute();
46
- await this.db.schema
44
+ .column('last_heartbeat'));
45
+ await this.createIndexSafe(this.db.schema
47
46
  .createIndex('idx_pikku_deployment_functions_name')
48
47
  .ifNotExists()
49
48
  .on('pikku_deployment_functions')
50
- .column('function_name')
51
- .execute();
49
+ .column('function_name'));
52
50
  this.initialized = true;
53
51
  }
54
52
  async start(config) {
@@ -111,6 +109,20 @@ export class KyselyDeploymentService {
111
109
  endpoint: row.endpoint,
112
110
  }));
113
111
  }
112
+ async createIndexSafe(builder) {
113
+ try {
114
+ await builder.execute();
115
+ }
116
+ catch (e) {
117
+ if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061)
118
+ return;
119
+ if (e?.code === '42P07')
120
+ return;
121
+ if (e?.message?.includes('already exists'))
122
+ return;
123
+ throw e;
124
+ }
125
+ }
114
126
  async sendHeartbeat() {
115
127
  if (!this.deploymentConfig)
116
128
  return;
@@ -0,0 +1,29 @@
1
+ import type { SecretService } from '@pikku/core/services';
2
+ import type { Kysely } from 'kysely';
3
+ import type { KyselyPikkuDB } from './kysely-tables.js';
4
+ export interface KyselySecretServiceConfig {
5
+ key: string;
6
+ keyVersion?: number;
7
+ previousKey?: string;
8
+ audit?: boolean;
9
+ auditReads?: boolean;
10
+ }
11
+ export declare class KyselySecretService implements SecretService {
12
+ private db;
13
+ private initialized;
14
+ private key;
15
+ private keyVersion;
16
+ private previousKey?;
17
+ private audit;
18
+ private auditReads;
19
+ constructor(db: Kysely<KyselyPikkuDB>, config: KyselySecretServiceConfig);
20
+ init(): Promise<void>;
21
+ private logAudit;
22
+ private getKEK;
23
+ getSecret(key: string): Promise<string>;
24
+ getSecretJSON<R = {}>(key: string): Promise<R>;
25
+ hasSecret(key: string): Promise<boolean>;
26
+ setSecretJSON(key: string, value: unknown): Promise<void>;
27
+ deleteSecret(key: string): Promise<void>;
28
+ rotateKEK(): Promise<number>;
29
+ }
@@ -0,0 +1,142 @@
1
+ import { sql } from 'kysely';
2
+ import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
3
+ export class KyselySecretService {
4
+ db;
5
+ initialized = false;
6
+ key;
7
+ keyVersion;
8
+ previousKey;
9
+ audit;
10
+ auditReads;
11
+ constructor(db, config) {
12
+ this.db = db;
13
+ this.key = config.key;
14
+ this.keyVersion = config.keyVersion ?? 1;
15
+ this.previousKey = config.previousKey;
16
+ this.audit = config.audit ?? false;
17
+ this.auditReads = config.auditReads ?? false;
18
+ }
19
+ async init() {
20
+ if (this.initialized)
21
+ return;
22
+ await this.db.schema
23
+ .createTable('secrets')
24
+ .ifNotExists()
25
+ .addColumn('key', 'varchar(255)', (col) => col.primaryKey())
26
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
27
+ .addColumn('wrapped_dek', 'text', (col) => col.notNull())
28
+ .addColumn('key_version', 'integer', (col) => col.notNull())
29
+ .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
30
+ .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
31
+ .execute();
32
+ if (this.audit) {
33
+ await this.db.schema
34
+ .createTable('secrets_audit')
35
+ .ifNotExists()
36
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
37
+ .addColumn('secret_key', 'varchar(255)', (col) => col.notNull())
38
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
39
+ .addColumn('performed_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
40
+ .execute();
41
+ }
42
+ this.initialized = true;
43
+ }
44
+ async logAudit(secretKey, action) {
45
+ if (!this.audit)
46
+ return;
47
+ if (action === 'read' && !this.auditReads)
48
+ return;
49
+ await this.db
50
+ .insertInto('secrets_audit')
51
+ .values({
52
+ id: crypto.randomUUID(),
53
+ secret_key: secretKey,
54
+ action,
55
+ performed_at: new Date().toISOString(),
56
+ })
57
+ .execute();
58
+ }
59
+ getKEK(version) {
60
+ if (version === this.keyVersion)
61
+ return this.key;
62
+ if (this.previousKey)
63
+ return this.previousKey;
64
+ throw new Error(`No KEK available for key_version ${version}`);
65
+ }
66
+ async getSecret(key) {
67
+ const row = await this.db
68
+ .selectFrom('secrets')
69
+ .select(['ciphertext', 'wrapped_dek', 'key_version'])
70
+ .where('key', '=', key)
71
+ .executeTakeFirst();
72
+ if (!row)
73
+ throw new Error(`Secret not found: ${key}`);
74
+ const kek = this.getKEK(row.key_version);
75
+ const result = await envelopeDecrypt(kek, row.ciphertext, row.wrapped_dek);
76
+ await this.logAudit(key, 'read');
77
+ return result;
78
+ }
79
+ async getSecretJSON(key) {
80
+ const raw = await this.getSecret(key);
81
+ return JSON.parse(raw);
82
+ }
83
+ async hasSecret(key) {
84
+ const row = await this.db
85
+ .selectFrom('secrets')
86
+ .select('key')
87
+ .where('key', '=', key)
88
+ .executeTakeFirst();
89
+ return !!row;
90
+ }
91
+ async setSecretJSON(key, value) {
92
+ const plaintext = JSON.stringify(value);
93
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, plaintext);
94
+ const now = new Date().toISOString();
95
+ await this.db
96
+ .insertInto('secrets')
97
+ .values({
98
+ key,
99
+ ciphertext,
100
+ wrapped_dek: wrappedDEK,
101
+ key_version: this.keyVersion,
102
+ created_at: now,
103
+ updated_at: now,
104
+ })
105
+ .onConflict((oc) => oc.column('key').doUpdateSet({
106
+ ciphertext,
107
+ wrapped_dek: wrappedDEK,
108
+ key_version: this.keyVersion,
109
+ updated_at: now,
110
+ }))
111
+ .execute();
112
+ await this.logAudit(key, 'write');
113
+ }
114
+ async deleteSecret(key) {
115
+ await this.db.deleteFrom('secrets').where('key', '=', key).execute();
116
+ await this.logAudit(key, 'delete');
117
+ }
118
+ async rotateKEK() {
119
+ if (!this.previousKey) {
120
+ throw new Error('No previousKey configured — nothing to rotate from');
121
+ }
122
+ const rows = await this.db
123
+ .selectFrom('secrets')
124
+ .select(['key', 'wrapped_dek'])
125
+ .where('key_version', '<', this.keyVersion)
126
+ .execute();
127
+ for (const row of rows) {
128
+ const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrapped_dek);
129
+ await this.db
130
+ .updateTable('secrets')
131
+ .set({
132
+ wrapped_dek: newWrappedDEK,
133
+ key_version: this.keyVersion,
134
+ updated_at: new Date().toISOString(),
135
+ })
136
+ .where('key', '=', row.key)
137
+ .execute();
138
+ await this.logAudit(row.key, 'rotate');
139
+ }
140
+ return rows.length;
141
+ }
142
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Generated } from 'kysely';
2
+ import type { WorkflowStatus, StepStatus, WorkflowVersionStatus } from '@pikku/core/workflow';
2
3
  export interface ChannelsTable {
3
4
  channel_id: string;
4
5
  channel_name: string;
@@ -14,7 +15,7 @@ export interface ChannelSubscriptionsTable {
14
15
  export interface WorkflowRunsTable {
15
16
  workflow_run_id: Generated<string>;
16
17
  workflow: string;
17
- status: string;
18
+ status: WorkflowStatus;
18
19
  input: string;
19
20
  output: string | null;
20
21
  error: string | null;
@@ -31,7 +32,7 @@ export interface WorkflowStepTable {
31
32
  step_name: string;
32
33
  rpc_name: string | null;
33
34
  data: string | null;
34
- status: Generated<string>;
35
+ status: Generated<StepStatus>;
35
36
  result: string | null;
36
37
  error: string | null;
37
38
  branch_taken: string | null;
@@ -43,7 +44,7 @@ export interface WorkflowStepTable {
43
44
  export interface WorkflowStepHistoryTable {
44
45
  history_id: Generated<string>;
45
46
  workflow_step_id: string;
46
- status: string;
47
+ status: StepStatus;
47
48
  result: string | null;
48
49
  error: string | null;
49
50
  created_at: Generated<Date>;
@@ -57,6 +58,7 @@ export interface WorkflowVersionsTable {
57
58
  graph_hash: string;
58
59
  graph: string;
59
60
  source: string;
61
+ status: Generated<WorkflowVersionStatus>;
60
62
  created_at: Generated<Date>;
61
63
  }
62
64
  export interface AIThreadsTable {
@@ -70,7 +72,7 @@ export interface AIThreadsTable {
70
72
  export interface AIMessageTable {
71
73
  id: string;
72
74
  thread_id: string;
73
- role: string;
75
+ role: 'system' | 'user' | 'assistant' | 'tool';
74
76
  content: string | null;
75
77
  created_at: Generated<Date>;
76
78
  }
@@ -82,8 +84,8 @@ export interface AIToolCallTable {
82
84
  tool_name: string;
83
85
  args: string;
84
86
  result: string | null;
85
- approval_status: string | null;
86
- approval_type: string | null;
87
+ approval_status: 'approved' | 'denied' | 'pending' | null;
88
+ approval_type: 'agent-call' | 'tool-call' | null;
87
89
  agent_run_id: string | null;
88
90
  display_tool_name: string | null;
89
91
  display_args: string | null;
@@ -100,8 +102,9 @@ export interface AIRunTable {
100
102
  agent_name: string;
101
103
  thread_id: string;
102
104
  resource_id: string;
103
- status: Generated<string>;
104
- suspend_reason: string | null;
105
+ status: Generated<'running' | 'suspended' | 'completed' | 'failed'>;
106
+ error_message: string | null;
107
+ suspend_reason: 'approval' | 'rpc-missing' | null;
105
108
  missing_rpcs: string | null;
106
109
  usage_input_tokens: Generated<number>;
107
110
  usage_output_tokens: Generated<number>;
@@ -119,6 +122,20 @@ export interface PikkuDeploymentFunctionsTable {
119
122
  deployment_id: string;
120
123
  function_name: string;
121
124
  }
125
+ export interface SecretsTable {
126
+ key: string;
127
+ ciphertext: string;
128
+ wrapped_dek: string;
129
+ key_version: number;
130
+ created_at: Generated<Date>;
131
+ updated_at: Generated<Date>;
132
+ }
133
+ export interface SecretsAuditTable {
134
+ id: string;
135
+ secret_key: string;
136
+ action: string;
137
+ performed_at: Generated<Date>;
138
+ }
122
139
  export interface KyselyPikkuDB {
123
140
  channels: ChannelsTable;
124
141
  channel_subscriptions: ChannelSubscriptionsTable;
@@ -133,4 +150,6 @@ export interface KyselyPikkuDB {
133
150
  ai_run: AIRunTable;
134
151
  pikku_deployments: PikkuDeploymentsTable;
135
152
  pikku_deployment_functions: PikkuDeploymentFunctionsTable;
153
+ secrets: SecretsTable;
154
+ secrets_audit: SecretsAuditTable;
136
155
  }
@@ -24,6 +24,11 @@ export declare class KyselyWorkflowRunService implements WorkflowRunService {
24
24
  graph: any;
25
25
  source: string;
26
26
  } | null>;
27
+ getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
28
+ workflowName: string;
29
+ graphHash: string;
30
+ graph: any;
31
+ }>>;
27
32
  deleteRun(id: string): Promise<boolean>;
28
33
  private mapRunRow;
29
34
  }