@pikku/kysely 0.13.5 → 0.13.7
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 +406 -0
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/src/kysely-credential-service.d.ts +3 -0
- package/dist/src/kysely-credential-service.js +50 -11
- package/dist/src/kysely-scope-service.d.ts +38 -0
- package/dist/src/kysely-scope-service.js +125 -2
- package/dist/src/kysely-secret-service.d.ts +3 -0
- package/dist/src/kysely-secret-service.js +50 -11
- package/dist/src/kysely-tables.d.ts +26 -2
- package/dist/src/kysely-workflow-mirror.js +37 -25
- package/dist/src/kysely-workflow-run-service.d.ts +0 -5
- package/dist/src/kysely-workflow-run-service.js +4 -16
- package/dist/src/kysely-workflow-service.d.ts +35 -7
- package/dist/src/kysely-workflow-service.js +194 -146
- package/dist/src/schema/credential.schema.js +5 -0
- package/dist/src/schema/scope.schema.js +12 -0
- package/dist/src/schema/secret.schema.js +5 -0
- package/dist/src/schema/workflow.schema.d.ts +4 -2
- package/dist/src/schema/workflow.schema.js +28 -2
- package/package.json +3 -2
- package/src/kysely-credential-service.ts +57 -12
- package/src/kysely-scope-service.ts +151 -2
- package/src/kysely-secret-service.ts +60 -12
- package/src/kysely-services.test.ts +123 -1
- package/src/kysely-tables.ts +29 -2
- package/src/kysely-workflow-mirror.test.ts +1 -13
- package/src/kysely-workflow-mirror.ts +44 -20
- package/src/kysely-workflow-run-service.ts +7 -20
- package/src/kysely-workflow-service.test.ts +431 -0
- package/src/kysely-workflow-service.ts +212 -178
- package/src/schema/credential.schema.ts +9 -0
- package/src/schema/scope.schema.ts +14 -0
- package/src/schema/secret.schema.ts +9 -0
- package/src/schema/workflow.schema.ts +38 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SystemRoleImmutableError, SystemRoleShadowedError, } from '@pikku/core/errors';
|
|
1
2
|
import { ensurePikkuSchema } from './schema/index.js';
|
|
2
3
|
import { scopeSchema } from './schema/scope.schema.js';
|
|
3
4
|
/**
|
|
@@ -79,11 +80,69 @@ export class KyselyScopeService {
|
|
|
79
80
|
declared: !!row.declared,
|
|
80
81
|
}));
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Registers the roles declared with `defineSystemRole`.
|
|
85
|
+
*
|
|
86
|
+
* Additive on the same terms as `syncScopes` — a role whose declaration has
|
|
87
|
+
* gone is marked `declared = false` rather than deleted, so a rollback or a
|
|
88
|
+
* rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
|
|
89
|
+
* outright, because that is the declaration's entire content: editing
|
|
90
|
+
* `defineSystemRole` is how you change what a role means, and the deploy is
|
|
91
|
+
* when it takes effect.
|
|
92
|
+
*/
|
|
93
|
+
async syncSystemRoles(roles) {
|
|
94
|
+
await this.db.transaction().execute(async (trx) => {
|
|
95
|
+
for (const role of roles) {
|
|
96
|
+
await trx
|
|
97
|
+
.insertInto('pikkuRoles')
|
|
98
|
+
.values({
|
|
99
|
+
name: role.name,
|
|
100
|
+
description: role.description ?? null,
|
|
101
|
+
system: true,
|
|
102
|
+
declared: true,
|
|
103
|
+
})
|
|
104
|
+
.onConflict((oc) => oc.column('name').doUpdateSet((eb) => ({
|
|
105
|
+
description: eb.ref('excluded.description'),
|
|
106
|
+
system: true,
|
|
107
|
+
declared: true,
|
|
108
|
+
})))
|
|
109
|
+
.execute();
|
|
110
|
+
await trx
|
|
111
|
+
.deleteFrom('pikkuRoleScopes')
|
|
112
|
+
.where('role', '=', role.name)
|
|
113
|
+
.execute();
|
|
114
|
+
if (role.scopes.length > 0) {
|
|
115
|
+
await trx
|
|
116
|
+
.insertInto('pikkuRoleScopes')
|
|
117
|
+
.values(role.scopes.map((scope) => ({ role: role.name, scope })))
|
|
118
|
+
.execute();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Only system rows are in scope here: an admin's own role is not
|
|
122
|
+
// undeclared, it was never declared, and marking it would take it out of
|
|
123
|
+
// the console for a reason nobody could act on.
|
|
124
|
+
const markStale = trx
|
|
125
|
+
.updateTable('pikkuRoles')
|
|
126
|
+
.set({ declared: false })
|
|
127
|
+
.where('system', '=', true);
|
|
128
|
+
await (roles.length > 0
|
|
129
|
+
? markStale.where('name', 'not in', roles.map((role) => role.name))
|
|
130
|
+
: markStale).execute();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
82
133
|
async createRole(role) {
|
|
134
|
+
if (await this.isSystemRole(role.name)) {
|
|
135
|
+
throw new SystemRoleShadowedError(role.name);
|
|
136
|
+
}
|
|
83
137
|
await this.db.transaction().execute(async (trx) => {
|
|
84
138
|
await trx
|
|
85
139
|
.insertInto('pikkuRoles')
|
|
86
|
-
.values({
|
|
140
|
+
.values({
|
|
141
|
+
name: role.name,
|
|
142
|
+
description: role.description ?? null,
|
|
143
|
+
system: false,
|
|
144
|
+
declared: true,
|
|
145
|
+
})
|
|
87
146
|
.execute();
|
|
88
147
|
if (role.scopes.length > 0) {
|
|
89
148
|
await trx
|
|
@@ -94,9 +153,15 @@ export class KyselyScopeService {
|
|
|
94
153
|
});
|
|
95
154
|
}
|
|
96
155
|
async deleteRole(name) {
|
|
156
|
+
if (await this.isSystemRole(name)) {
|
|
157
|
+
throw new SystemRoleImmutableError(name, 'delete');
|
|
158
|
+
}
|
|
97
159
|
await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute();
|
|
98
160
|
}
|
|
99
161
|
async setRoleScopes(name, scopes) {
|
|
162
|
+
if (await this.isSystemRole(name)) {
|
|
163
|
+
throw new SystemRoleImmutableError(name, 're-scope');
|
|
164
|
+
}
|
|
100
165
|
await this.db.transaction().execute(async (trx) => {
|
|
101
166
|
await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute();
|
|
102
167
|
if (scopes.length > 0) {
|
|
@@ -107,10 +172,25 @@ export class KyselyScopeService {
|
|
|
107
172
|
}
|
|
108
173
|
});
|
|
109
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether the store holds this name as a system role.
|
|
177
|
+
*
|
|
178
|
+
* Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
|
|
179
|
+
* store is where a shadow would actually collide — and because a role whose
|
|
180
|
+
* declaration was deleted is still immutable until someone prunes it.
|
|
181
|
+
*/
|
|
182
|
+
async isSystemRole(name) {
|
|
183
|
+
const row = await this.db
|
|
184
|
+
.selectFrom('pikkuRoles')
|
|
185
|
+
.select('system')
|
|
186
|
+
.where('name', '=', name)
|
|
187
|
+
.executeTakeFirst();
|
|
188
|
+
return !!row?.system;
|
|
189
|
+
}
|
|
110
190
|
async listRoles() {
|
|
111
191
|
const roles = await this.db
|
|
112
192
|
.selectFrom('pikkuRoles')
|
|
113
|
-
.select(['name', 'description'])
|
|
193
|
+
.select(['name', 'description', 'system', 'declared'])
|
|
114
194
|
.execute();
|
|
115
195
|
const scopeRows = await this.db
|
|
116
196
|
.selectFrom('pikkuRoleScopes')
|
|
@@ -130,6 +210,8 @@ export class KyselyScopeService {
|
|
|
130
210
|
name: role.name,
|
|
131
211
|
description: role.description ?? undefined,
|
|
132
212
|
scopes: byRole.get(role.name) ?? [],
|
|
213
|
+
system: !!role.system,
|
|
214
|
+
declared: !!role.declared,
|
|
133
215
|
}));
|
|
134
216
|
}
|
|
135
217
|
async addUserToRole(userId, role, grantedBy) {
|
|
@@ -215,4 +297,45 @@ export class KyselyScopeService {
|
|
|
215
297
|
.execute();
|
|
216
298
|
return deleted.map((r) => r.name);
|
|
217
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* System roles the last sync found no declaration for, with how many people
|
|
302
|
+
* still hold each. Powers `pikku roles audit`.
|
|
303
|
+
*
|
|
304
|
+
* The count is the whole point: "this role is gone from the code" and "this
|
|
305
|
+
* role is gone from the code and 40 people are standing on it" call for
|
|
306
|
+
* different decisions, and the number is what makes pruning a choice rather
|
|
307
|
+
* than a formality.
|
|
308
|
+
*/
|
|
309
|
+
async findStaleSystemRoles() {
|
|
310
|
+
const stale = await this.db
|
|
311
|
+
.selectFrom('pikkuRoles')
|
|
312
|
+
.leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
|
|
313
|
+
.select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
|
|
314
|
+
.where('pikkuRoles.system', '=', true)
|
|
315
|
+
.where('pikkuRoles.declared', '=', false)
|
|
316
|
+
.execute();
|
|
317
|
+
const byRole = new Map();
|
|
318
|
+
for (const row of stale) {
|
|
319
|
+
byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0));
|
|
320
|
+
}
|
|
321
|
+
return [...byRole.entries()].map(([role, users]) => ({ role, users }));
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Removes undeclared system roles, cascading them out of every user grant
|
|
325
|
+
* that holds them. This revokes access, so it is never run implicitly.
|
|
326
|
+
*/
|
|
327
|
+
async pruneSystemRoles() {
|
|
328
|
+
const stale = await this.findStaleSystemRoles();
|
|
329
|
+
if (stale.length === 0) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const deleted = await this.db
|
|
333
|
+
.deleteFrom('pikkuRoles')
|
|
334
|
+
.where('name', 'in', stale.map((s) => s.role))
|
|
335
|
+
.where('system', '=', true)
|
|
336
|
+
.where('declared', '=', false)
|
|
337
|
+
.returning('name')
|
|
338
|
+
.execute();
|
|
339
|
+
return deleted.map((r) => r.name);
|
|
340
|
+
}
|
|
218
341
|
}
|
|
@@ -11,6 +11,8 @@ export interface KyselySecretServiceConfig {
|
|
|
11
11
|
export declare class KyselySecretService implements SecretService {
|
|
12
12
|
private db;
|
|
13
13
|
private initialized;
|
|
14
|
+
private kekSalts;
|
|
15
|
+
private keks;
|
|
14
16
|
private key;
|
|
15
17
|
private keyVersion;
|
|
16
18
|
private previousKey?;
|
|
@@ -19,6 +21,7 @@ export declare class KyselySecretService implements SecretService {
|
|
|
19
21
|
constructor(db: Kysely<KyselyPikkuDB>, config: KyselySecretServiceConfig);
|
|
20
22
|
init(): Promise<void>;
|
|
21
23
|
private logAudit;
|
|
24
|
+
private getKEKSalt;
|
|
22
25
|
private getKEK;
|
|
23
26
|
getSecret<T = string>(key: string): Promise<T>;
|
|
24
27
|
hasSecret(key: string): Promise<boolean>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
|
|
1
|
+
import { deriveKEK, generateKEKSalt, envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
|
|
2
2
|
import { ensurePikkuSchema } from './schema/index.js';
|
|
3
3
|
import { secretSchema } from './schema/secret.schema.js';
|
|
4
4
|
export class KyselySecretService {
|
|
5
5
|
db;
|
|
6
6
|
initialized = false;
|
|
7
|
+
kekSalts = new Map();
|
|
8
|
+
keks = new Map();
|
|
7
9
|
key;
|
|
8
10
|
keyVersion;
|
|
9
11
|
previousKey;
|
|
@@ -38,12 +40,47 @@ export class KyselySecretService {
|
|
|
38
40
|
})
|
|
39
41
|
.execute();
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
async getKEKSalt(version) {
|
|
44
|
+
const cached = this.kekSalts.get(version);
|
|
45
|
+
if (cached)
|
|
46
|
+
return cached;
|
|
47
|
+
const existing = await this.db
|
|
48
|
+
.selectFrom('secretKekSalts')
|
|
49
|
+
.select('salt')
|
|
50
|
+
.where('keyVersion', '=', version)
|
|
51
|
+
.executeTakeFirst();
|
|
52
|
+
let salt = existing?.salt;
|
|
53
|
+
if (!salt) {
|
|
54
|
+
await this.db
|
|
55
|
+
.insertInto('secretKekSalts')
|
|
56
|
+
.values({
|
|
57
|
+
keyVersion: version,
|
|
58
|
+
salt: generateKEKSalt(),
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
})
|
|
61
|
+
.onConflict((oc) => oc.column('keyVersion').doNothing())
|
|
62
|
+
.execute();
|
|
63
|
+
const row = await this.db
|
|
64
|
+
.selectFrom('secretKekSalts')
|
|
65
|
+
.select('salt')
|
|
66
|
+
.where('keyVersion', '=', version)
|
|
67
|
+
.executeTakeFirstOrThrow();
|
|
68
|
+
salt = row.salt;
|
|
69
|
+
}
|
|
70
|
+
this.kekSalts.set(version, salt);
|
|
71
|
+
return salt;
|
|
72
|
+
}
|
|
73
|
+
async getKEK(version) {
|
|
74
|
+
const cached = this.keks.get(version);
|
|
75
|
+
if (cached)
|
|
76
|
+
return cached;
|
|
77
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey;
|
|
78
|
+
if (!passphrase) {
|
|
79
|
+
throw new Error(`No KEK available for key_version ${version}`);
|
|
80
|
+
}
|
|
81
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version));
|
|
82
|
+
this.keks.set(version, kek);
|
|
83
|
+
return kek;
|
|
47
84
|
}
|
|
48
85
|
async getSecret(key) {
|
|
49
86
|
const row = await this.db
|
|
@@ -53,7 +90,7 @@ export class KyselySecretService {
|
|
|
53
90
|
.executeTakeFirst();
|
|
54
91
|
if (!row)
|
|
55
92
|
throw new Error('Requested secret not found');
|
|
56
|
-
const kek = this.getKEK(row.keyVersion);
|
|
93
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
57
94
|
const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
58
95
|
await this.logAudit(key, 'read');
|
|
59
96
|
return result;
|
|
@@ -67,7 +104,7 @@ export class KyselySecretService {
|
|
|
67
104
|
return !!row;
|
|
68
105
|
}
|
|
69
106
|
async setSecret(key, value) {
|
|
70
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.
|
|
107
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), value);
|
|
71
108
|
const now = new Date().toISOString();
|
|
72
109
|
await this.db
|
|
73
110
|
.insertInto('secrets')
|
|
@@ -101,7 +138,7 @@ export class KyselySecretService {
|
|
|
101
138
|
const out = {};
|
|
102
139
|
for (const row of rows) {
|
|
103
140
|
try {
|
|
104
|
-
const kek = this.getKEK(row.keyVersion);
|
|
141
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
105
142
|
out[row.key] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
106
143
|
}
|
|
107
144
|
catch (cause) {
|
|
@@ -126,8 +163,10 @@ export class KyselySecretService {
|
|
|
126
163
|
.select(['key', 'wrappedDek'])
|
|
127
164
|
.where('keyVersion', '<', this.keyVersion)
|
|
128
165
|
.execute();
|
|
166
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1);
|
|
167
|
+
const newKEK = await this.getKEK(this.keyVersion);
|
|
129
168
|
for (const row of rows) {
|
|
130
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
169
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek);
|
|
131
170
|
await this.db
|
|
132
171
|
.updateTable('secrets')
|
|
133
172
|
.set({
|
|
@@ -44,6 +44,8 @@ export interface WorkflowStepTable {
|
|
|
44
44
|
retries: number | null;
|
|
45
45
|
retryDelay: string | null;
|
|
46
46
|
fromStepName: string | null;
|
|
47
|
+
/** The history attempt currently in flight; see WorkflowStepHistoryTable.attempt. */
|
|
48
|
+
currentAttempt: number | null;
|
|
47
49
|
createdAt: Generated<Date>;
|
|
48
50
|
updatedAt: Generated<Date>;
|
|
49
51
|
}
|
|
@@ -53,6 +55,12 @@ export interface WorkflowStepHistoryTable {
|
|
|
53
55
|
status: StepStatus;
|
|
54
56
|
result: string | null;
|
|
55
57
|
error: string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Monotonic attempt number within the step, starting at 1. Orders attempts
|
|
60
|
+
* and identifies the live one without relying on `createdAt`, which cannot
|
|
61
|
+
* separate a retry from the attempt it replaces in the same millisecond.
|
|
62
|
+
*/
|
|
63
|
+
attempt: number | null;
|
|
56
64
|
createdAt: Generated<Date>;
|
|
57
65
|
runningAt: Date | null;
|
|
58
66
|
scheduledAt: Date | null;
|
|
@@ -108,7 +116,12 @@ export interface AIRunTable {
|
|
|
108
116
|
agentName: string;
|
|
109
117
|
threadId: string;
|
|
110
118
|
resourceId: string;
|
|
111
|
-
|
|
119
|
+
/**
|
|
120
|
+
* `interrupted` is distinct from `failed`: the run was stopped on purpose,
|
|
121
|
+
* usually because the user talked over it. Nothing went wrong, so it should
|
|
122
|
+
* not show up in error reporting.
|
|
123
|
+
*/
|
|
124
|
+
status: Generated<'running' | 'suspended' | 'completed' | 'failed' | 'interrupted'>;
|
|
112
125
|
errorMessage: string | null;
|
|
113
126
|
suspendReason: 'approval' | 'credential' | 'rpc-missing' | null;
|
|
114
127
|
missingRpcs: string | null;
|
|
@@ -138,6 +151,11 @@ export interface SecretsTable {
|
|
|
138
151
|
createdAt: Generated<Date>;
|
|
139
152
|
updatedAt: Generated<Date>;
|
|
140
153
|
}
|
|
154
|
+
export interface KekSaltsTable {
|
|
155
|
+
keyVersion: number;
|
|
156
|
+
salt: string;
|
|
157
|
+
createdAt: Generated<Date>;
|
|
158
|
+
}
|
|
141
159
|
export interface SecretsAuditTable {
|
|
142
160
|
id: string;
|
|
143
161
|
secretKey: string;
|
|
@@ -183,10 +201,14 @@ export interface PikkuScopesTable {
|
|
|
183
201
|
*/
|
|
184
202
|
declared: Generated<boolean>;
|
|
185
203
|
}
|
|
186
|
-
/** A role: an admin
|
|
204
|
+
/** A role: a bag of scopes, composed by an admin or declared in code. */
|
|
187
205
|
export interface PikkuRolesTable {
|
|
188
206
|
name: string;
|
|
189
207
|
description: string | null;
|
|
208
|
+
/** Declared with `defineSystemRole`, and therefore not editable here. */
|
|
209
|
+
system: Generated<boolean>;
|
|
210
|
+
/** False for a system role whose declaration has gone: held, but inert. */
|
|
211
|
+
declared: Generated<boolean>;
|
|
190
212
|
createdAt: Generated<Date>;
|
|
191
213
|
}
|
|
192
214
|
export interface PikkuRoleScopesTable {
|
|
@@ -246,8 +268,10 @@ export interface KyselyPikkuDB {
|
|
|
246
268
|
pikkuDeployments: PikkuDeploymentsTable;
|
|
247
269
|
pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable;
|
|
248
270
|
secrets: SecretsTable;
|
|
271
|
+
secretKekSalts: KekSaltsTable;
|
|
249
272
|
secretsAudit: SecretsAuditTable;
|
|
250
273
|
credentials: CredentialsTable;
|
|
274
|
+
credentialKekSalts: KekSaltsTable;
|
|
251
275
|
credentialsAudit: CredentialsAuditTable;
|
|
252
276
|
pikkuUserSessions: UserSessionsTable;
|
|
253
277
|
webhookDelivery: WebhookDeliveryTable;
|
|
@@ -77,11 +77,12 @@ export class KyselyWorkflowMirror {
|
|
|
77
77
|
retries: step.retries ?? null,
|
|
78
78
|
retryDelay: step.retryDelay?.toString() ?? null,
|
|
79
79
|
fromStepName: step.fromStepName ?? null,
|
|
80
|
+
currentAttempt: 1,
|
|
80
81
|
createdAt: now,
|
|
81
82
|
updatedAt: now,
|
|
82
83
|
})
|
|
83
84
|
.execute();
|
|
84
|
-
await this.insertHistoryRecord(step.stepId, step.status);
|
|
85
|
+
await this.insertHistoryRecord(step.stepId, step.status, 1);
|
|
85
86
|
}
|
|
86
87
|
async setStepRunning(stepId) {
|
|
87
88
|
await this.updateStepStatus(stepId, 'running');
|
|
@@ -126,17 +127,24 @@ export class KyselyWorkflowMirror {
|
|
|
126
127
|
await this.appendOrUpdateLatestHistory(stepId, 'failed', undefined, errorJson);
|
|
127
128
|
}
|
|
128
129
|
async createRetryAttempt(failedStepId, newStep) {
|
|
130
|
+
const previous = await this.db
|
|
131
|
+
.selectFrom('workflowStep')
|
|
132
|
+
.select('currentAttempt')
|
|
133
|
+
.where('workflowStepId', '=', failedStepId)
|
|
134
|
+
.executeTakeFirst();
|
|
135
|
+
const attempt = (previous?.currentAttempt ?? 0) + 1;
|
|
129
136
|
await this.db
|
|
130
137
|
.updateTable('workflowStep')
|
|
131
138
|
.set({
|
|
132
139
|
status: newStep.status,
|
|
133
140
|
result: null,
|
|
134
141
|
error: null,
|
|
142
|
+
currentAttempt: attempt,
|
|
135
143
|
updatedAt: new Date(),
|
|
136
144
|
})
|
|
137
145
|
.where('workflowStepId', '=', failedStepId)
|
|
138
146
|
.execute();
|
|
139
|
-
await this.insertHistoryRecord(failedStepId, newStep.status);
|
|
147
|
+
await this.insertHistoryRecord(failedStepId, newStep.status, attempt);
|
|
140
148
|
}
|
|
141
149
|
async setBranchTaken(stepId, branchKey) {
|
|
142
150
|
await this.db
|
|
@@ -192,12 +200,13 @@ export class KyselyWorkflowMirror {
|
|
|
192
200
|
.where('workflowStepId', '=', stepId)
|
|
193
201
|
.execute();
|
|
194
202
|
}
|
|
195
|
-
async insertHistoryRecord(stepId, status, resultJson, errorJson) {
|
|
203
|
+
async insertHistoryRecord(stepId, status, attempt, resultJson, errorJson) {
|
|
196
204
|
const now = new Date();
|
|
197
205
|
const values = {
|
|
198
206
|
historyId: crypto.randomUUID(),
|
|
199
207
|
workflowStepId: stepId,
|
|
200
208
|
status,
|
|
209
|
+
attempt,
|
|
201
210
|
result: resultJson ?? null,
|
|
202
211
|
error: errorJson ?? null,
|
|
203
212
|
createdAt: now,
|
|
@@ -219,30 +228,33 @@ export class KyselyWorkflowMirror {
|
|
|
219
228
|
* returns the same sequence the executor would have produced.
|
|
220
229
|
*/
|
|
221
230
|
async appendOrUpdateLatestHistory(stepId, status, resultJson, errorJson) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
.
|
|
231
|
+
const update = { status };
|
|
232
|
+
if (resultJson !== undefined)
|
|
233
|
+
update.result = resultJson;
|
|
234
|
+
if (errorJson !== undefined)
|
|
235
|
+
update.error = errorJson;
|
|
236
|
+
const tsField = timestampFieldFor(status);
|
|
237
|
+
if (tsField)
|
|
238
|
+
update[tsField] = new Date();
|
|
239
|
+
const result = await this.db
|
|
240
|
+
.updateTable('workflowStepHistory')
|
|
241
|
+
.set(update)
|
|
225
242
|
.where('workflowStepId', '=', stepId)
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
// Correlate through the step row rather than sorting this table by
|
|
244
|
+
// createdAt: two attempts can land in the same millisecond, and the
|
|
245
|
+
// step row already knows which attempt is current.
|
|
246
|
+
.where('attempt', '=', (eb) => eb
|
|
247
|
+
.selectFrom('workflowStep')
|
|
248
|
+
.select('currentAttempt')
|
|
249
|
+
.where('workflowStepId', '=', stepId))
|
|
228
250
|
.executeTakeFirst();
|
|
229
|
-
if (
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (tsField)
|
|
237
|
-
update[tsField] = new Date();
|
|
238
|
-
await this.db
|
|
239
|
-
.updateTable('workflowStepHistory')
|
|
240
|
-
.set(update)
|
|
241
|
-
.where('historyId', '=', latest.historyId)
|
|
242
|
-
.execute();
|
|
243
|
-
}
|
|
244
|
-
else {
|
|
245
|
-
await this.insertHistoryRecord(stepId, status, resultJson, errorJson);
|
|
251
|
+
if (Number(result?.numUpdatedRows ?? 0n) === 0) {
|
|
252
|
+
const step = await this.db
|
|
253
|
+
.selectFrom('workflowStep')
|
|
254
|
+
.select('currentAttempt')
|
|
255
|
+
.where('workflowStepId', '=', stepId)
|
|
256
|
+
.executeTakeFirst();
|
|
257
|
+
await this.insertHistoryRecord(stepId, status, step?.currentAttempt ?? 1, resultJson, errorJson);
|
|
246
258
|
}
|
|
247
259
|
}
|
|
248
260
|
}
|
|
@@ -24,11 +24,6 @@ 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
|
-
}>>;
|
|
32
27
|
deleteRun(id: string): Promise<boolean>;
|
|
33
28
|
private mapRunRow;
|
|
34
29
|
}
|
|
@@ -143,6 +143,7 @@ export class KyselyWorkflowRunService {
|
|
|
143
143
|
'h.status',
|
|
144
144
|
'h.result',
|
|
145
145
|
'h.error',
|
|
146
|
+
'h.attempt',
|
|
146
147
|
'h.createdAt',
|
|
147
148
|
'h.runningAt',
|
|
148
149
|
'h.scheduledAt',
|
|
@@ -150,7 +151,10 @@ export class KyselyWorkflowRunService {
|
|
|
150
151
|
'h.failedAt',
|
|
151
152
|
])
|
|
152
153
|
.where('s.workflowRunId', '=', runId)
|
|
154
|
+
// `attempt` breaks the tie when a retry lands in the same millisecond as
|
|
155
|
+
// the attempt it replaces, which `createdAt` alone cannot order.
|
|
153
156
|
.orderBy('h.createdAt', 'asc')
|
|
157
|
+
.orderBy('h.attempt', 'asc')
|
|
154
158
|
.execute();
|
|
155
159
|
let attemptCounters = {};
|
|
156
160
|
return result.map((row) => {
|
|
@@ -206,22 +210,6 @@ export class KyselyWorkflowRunService {
|
|
|
206
210
|
source: row.source,
|
|
207
211
|
};
|
|
208
212
|
}
|
|
209
|
-
async getAIGeneratedWorkflows(agentName) {
|
|
210
|
-
let query = this.db
|
|
211
|
-
.selectFrom('workflowVersions')
|
|
212
|
-
.select(['workflowName', 'graphHash', 'graph'])
|
|
213
|
-
.where('source', '=', 'dynamic-workflow')
|
|
214
|
-
.where('status', '=', 'active');
|
|
215
|
-
if (agentName) {
|
|
216
|
-
query = query.where('workflowName', 'like', `ai:${agentName}:%`);
|
|
217
|
-
}
|
|
218
|
-
const rows = await query.execute();
|
|
219
|
-
return rows.map((row) => ({
|
|
220
|
-
workflowName: row.workflowName,
|
|
221
|
-
graphHash: row.graphHash,
|
|
222
|
-
graph: parseJson(row.graph),
|
|
223
|
-
}));
|
|
224
|
-
}
|
|
225
213
|
async deleteRun(id) {
|
|
226
214
|
const result = await this.db
|
|
227
215
|
.deleteFrom('workflowRuns')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SerializedError } from '@pikku/core';
|
|
2
2
|
import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowQueueOptions, type WorkflowRun, type WorkflowRunWire, type StepState, type StepStatus, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
|
|
3
|
-
import type
|
|
3
|
+
import { type Kysely } from 'kysely';
|
|
4
4
|
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
5
5
|
export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
6
6
|
protected db: Kysely<KyselyPikkuDB>;
|
|
@@ -19,11 +19,26 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
19
19
|
retryDelay?: string | number;
|
|
20
20
|
}, fromStepName?: string): Promise<StepState>;
|
|
21
21
|
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
22
|
+
protected listStepStates(runId: string): Promise<Array<StepState & {
|
|
23
|
+
stepName: string;
|
|
24
|
+
}>>;
|
|
22
25
|
getRunHistory(runId: string): Promise<Array<StepState & {
|
|
23
26
|
stepName: string;
|
|
24
27
|
}>>;
|
|
25
28
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
26
29
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Move a step and its current history attempt to the same status in one
|
|
32
|
+
* transaction.
|
|
33
|
+
*
|
|
34
|
+
* Both halves matter: a crash between them used to leave the step row saying
|
|
35
|
+
* `succeeded` while its history row still said `running`, which silently
|
|
36
|
+
* corrupts `getRunHistory` and every timeline reconstruction built on it.
|
|
37
|
+
* The history row is targeted by its `attempt` rather than by a `LIMIT 1`
|
|
38
|
+
* over `created_at`, so a retry that lands in the same millisecond as the
|
|
39
|
+
* attempt it replaces still resolves the newer row.
|
|
40
|
+
*/
|
|
41
|
+
private writeStepTransition;
|
|
27
42
|
private insertHistoryRecord;
|
|
28
43
|
private getTimestampFieldForStatus;
|
|
29
44
|
protected setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
|
|
@@ -37,7 +52,6 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
37
52
|
failedNodeIds: string[];
|
|
38
53
|
branchKeys: Record<string, string>;
|
|
39
54
|
}>;
|
|
40
|
-
getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
41
55
|
getStepInstances(runId: string): Promise<Array<{
|
|
42
56
|
stepName: string;
|
|
43
57
|
status: StepStatus;
|
|
@@ -45,6 +59,25 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
45
59
|
}>>;
|
|
46
60
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
47
61
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Merge one key into the run's JSON state, as a single expression evaluated
|
|
64
|
+
* by the database.
|
|
65
|
+
*
|
|
66
|
+
* The SQL is dialect-specific, so subclasses override this rather than the
|
|
67
|
+
* caller. The default is the SQLite form; MySQL and Postgres differ in how
|
|
68
|
+
* they cast a JSON literal.
|
|
69
|
+
*
|
|
70
|
+
* @param path - JSON path to the key, already quoted (`$."key"`)
|
|
71
|
+
* @param json - The value as JSON text
|
|
72
|
+
*/
|
|
73
|
+
protected jsonSetState(path: string, json: string): import("kysely").RawBuilder<string>;
|
|
74
|
+
/**
|
|
75
|
+
* A JSON path for an arbitrary key. State keys are user-supplied (a graph's
|
|
76
|
+
* `setState` name, an approval's hex-encoded reason), so the key is quoted
|
|
77
|
+
* rather than interpolated bare — an unquoted `$.a.b` would silently address
|
|
78
|
+
* a nested path instead of the key literally called `a.b`.
|
|
79
|
+
*/
|
|
80
|
+
private jsonPathFor;
|
|
48
81
|
protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
|
|
49
82
|
getRunState(runId: string): Promise<Record<string, unknown>>;
|
|
50
83
|
protected upsertWorkflowVersionImpl(name: string, graphHash: string, graph: any, source: string, status?: WorkflowVersionStatus): Promise<void>;
|
|
@@ -53,10 +86,5 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
53
86
|
graph: any;
|
|
54
87
|
source: string;
|
|
55
88
|
} | null>;
|
|
56
|
-
getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
|
|
57
|
-
workflowName: string;
|
|
58
|
-
graphHash: string;
|
|
59
|
-
graph: any;
|
|
60
|
-
}>>;
|
|
61
89
|
close(): Promise<void>;
|
|
62
90
|
}
|