@adula/kit 0.2.0-alpha.3 → 1.0.0

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.
Files changed (72) hide show
  1. package/README.md +12 -2
  2. package/build/agent/capabilities.md +49 -7
  3. package/build/agent/skills/idea-review/SKILL.md +26 -1
  4. package/build/agent/skills/module-review/SKILL.md +22 -0
  5. package/build/agent/skills/perf-review/SKILL.md +24 -0
  6. package/build/agent/skills/schema-review/SKILL.md +22 -0
  7. package/build/agent/skills/security-review/SKILL.md +24 -0
  8. package/build/agent/skills/ui-review/SKILL.md +22 -0
  9. package/build/commands/capabilities.d.ts +4 -0
  10. package/build/commands/capabilities.js +35 -4
  11. package/build/commands/gaps.js +5 -8
  12. package/build/commands/main.d.ts +4 -2
  13. package/build/commands/main.js +2 -0
  14. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  15. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  16. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  17. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  18. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  19. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  20. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  21. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  22. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  23. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  24. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  25. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  26. package/build/index.d.ts +22 -2
  27. package/build/index.js +13 -2
  28. package/build/src/admin/contracts.js +19 -10
  29. package/build/src/admin/controller.d.ts +1 -0
  30. package/build/src/admin/controller.js +6 -0
  31. package/build/src/admin/resource_service.d.ts +48 -3
  32. package/build/src/admin/resource_service.js +194 -18
  33. package/build/src/attachments/attachment_service.d.ts +18 -1
  34. package/build/src/attachments/attachment_service.js +62 -0
  35. package/build/src/auth/ability.d.ts +2 -0
  36. package/build/src/collaboration/assignments.d.ts +78 -0
  37. package/build/src/collaboration/assignments.js +219 -0
  38. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  39. package/build/src/collaboration/record_collaboration.js +360 -0
  40. package/build/src/commands/agent_assets.js +5 -0
  41. package/build/src/commands/capabilities.d.ts +19 -0
  42. package/build/src/commands/capabilities.js +160 -0
  43. package/build/src/commands/gap_report.d.ts +8 -0
  44. package/build/src/commands/gap_report.js +12 -0
  45. package/build/src/core/message_templates.d.ts +80 -0
  46. package/build/src/core/message_templates.js +288 -0
  47. package/build/src/core/saved_views.js +31 -6
  48. package/build/src/core/user_invitations.js +2 -2
  49. package/build/src/database/schema.d.ts +18 -0
  50. package/build/src/database/schema.js +192 -0
  51. package/build/src/events/outbox.d.ts +1 -0
  52. package/build/src/events/outbox.js +1 -1
  53. package/build/src/events/record_mutation.d.ts +9 -0
  54. package/build/src/events/record_mutation.js +18 -3
  55. package/build/src/integrations/imports.d.ts +74 -0
  56. package/build/src/integrations/imports.js +331 -0
  57. package/build/src/integrations/openapi.d.ts +39 -0
  58. package/build/src/integrations/openapi.js +256 -0
  59. package/build/src/integrations/print.d.ts +37 -0
  60. package/build/src/integrations/print.js +123 -0
  61. package/build/src/integrations/webhooks.d.ts +98 -0
  62. package/build/src/integrations/webhooks.js +298 -0
  63. package/build/src/resource/define_resource.d.ts +1 -0
  64. package/build/src/resource/define_resource.js +9 -1
  65. package/build/src/resource/registry.d.ts +2 -0
  66. package/build/src/resource/registry.js +9 -0
  67. package/build/src/resource/types.d.ts +13 -1
  68. package/build/src/workflows/define_workflow.d.ts +95 -0
  69. package/build/src/workflows/define_workflow.js +83 -0
  70. package/build/src/workflows/engine.d.ts +119 -0
  71. package/build/src/workflows/engine.js +687 -0
  72. package/package.json +7 -13
@@ -0,0 +1,687 @@
1
+ import { KitError } from '../admin/errors.js';
2
+ import { fromRow } from '../admin/contracts.js';
3
+ import { columnName } from '../resource/define_resource.js';
4
+ import { recordMutation } from '../events/record_mutation.js';
5
+ import { notifyWithTemplate } from '../core/message_templates.js';
6
+ import { nextStep, } from './define_workflow.js';
7
+ const MAX_STEPS_PER_TICK = 25;
8
+ const BACKOFF_SECONDS = [30, 120, 600, 1800, 7200];
9
+ class StepFailure extends Error {
10
+ }
11
+ /**
12
+ * Durable workflow engine over workflow_runs. Every step runs inside the run's
13
+ * row lock (FOR UPDATE); its database effects and the transition commit together,
14
+ * so a crash repeats at most the step's external calls (HTTP carries a stable
15
+ * Idempotency-Key). Failed steps retry with growing delays up to maxAttempts.
16
+ */
17
+ export class WorkflowEngine {
18
+ db;
19
+ registry;
20
+ resources;
21
+ actors;
22
+ assignments;
23
+ options;
24
+ #definitions = new Map();
25
+ constructor(db, registry, resources, actors, assignments, definitions, options = {}) {
26
+ this.db = db;
27
+ this.registry = registry;
28
+ this.resources = resources;
29
+ this.actors = actors;
30
+ this.assignments = assignments;
31
+ this.options = options;
32
+ for (const definition of definitions)
33
+ this.register(definition);
34
+ }
35
+ register(definition) {
36
+ const resource = this.registry.get(definition.resource);
37
+ if (!resource.submittable)
38
+ throw new Error(`Workflow ${definition.name} needs a submittable resource`);
39
+ const versions = this.#definitions.get(definition.name) ?? new Map();
40
+ if (versions.has(definition.version))
41
+ throw new Error(`Duplicate workflow version ${definition.name}@${definition.version}`);
42
+ versions.set(definition.version, definition);
43
+ this.#definitions.set(definition.name, versions);
44
+ return this;
45
+ }
46
+ /** The newest version of each workflow attached to a resource. */
47
+ latestFor(resource) {
48
+ const found = [];
49
+ for (const versions of this.#definitions.values()) {
50
+ const latest = [...versions.values()].sort((a, b) => b.version - a.version)[0];
51
+ if (latest.resource === resource)
52
+ found.push(latest);
53
+ }
54
+ if (found.length > 1)
55
+ throw new Error(`Resource ${resource} has more than one workflow; use one per resource`);
56
+ return found[0];
57
+ }
58
+ definition(name, version) {
59
+ const definition = this.#definitions.get(name)?.get(version);
60
+ if (!definition)
61
+ throw new StepFailure(`Workflow ${name}@${version} is not registered`);
62
+ return definition;
63
+ }
64
+ /** Listeners: submitted documents start their workflow; cancelled ones stop it. */
65
+ listeners() {
66
+ return this.registry.all().flatMap((resource) => {
67
+ if (!resource.submittable)
68
+ return [];
69
+ const module = this.registry.owner(resource.name);
70
+ return [
71
+ {
72
+ name: `kit.workflows.start.${resource.name}`,
73
+ event: `${module}.${resource.name}.submitted`,
74
+ handle: (event, trx) => this.start(trx, event),
75
+ },
76
+ {
77
+ name: `kit.workflows.cancel.${resource.name}`,
78
+ event: `${module}.${resource.name}.cancelled`,
79
+ handle: (event, trx) => this.cancelRuns(trx, resource.name, Number(event.payload.id), Number(event.payload.actorId)),
80
+ },
81
+ ];
82
+ });
83
+ }
84
+ /** Claims the submission envelope written in the submit transaction. */
85
+ async start(trx, event) {
86
+ const run = await trx('workflow_runs').where('id', event.id).forUpdate().first();
87
+ if (!run || run.status !== 'pending_definition')
88
+ return;
89
+ const definition = this.latestFor(run.resource);
90
+ if (!definition) {
91
+ await trx('workflow_runs').where('id', run.id).update({
92
+ status: 'completed',
93
+ outcome: 'no_workflow',
94
+ updated_at: trx.fn.now(),
95
+ completed_at: trx.fn.now(),
96
+ });
97
+ return;
98
+ }
99
+ await trx('workflow_runs')
100
+ .where('id', run.id)
101
+ .update({
102
+ definition: definition.name,
103
+ definition_version: definition.version,
104
+ snapshot: JSON.stringify({ value: definition.start }),
105
+ status: 'running',
106
+ current_step: definition.start,
107
+ wake_at: this.now(),
108
+ started_by: Number(event.payload.actorId) || null,
109
+ updated_at: trx.fn.now(),
110
+ });
111
+ await this.log(trx, run.id, definition.start, 'started', Number(event.payload.actorId) || null, {
112
+ workflow: definition.name,
113
+ version: definition.version,
114
+ });
115
+ await this.advance(trx, run.id);
116
+ }
117
+ /** Worker step: advances due runs, each under its own row lock. */
118
+ async tick(limit = 20) {
119
+ let processed = 0;
120
+ for (let index = 0; index < limit; index++) {
121
+ const advanced = await this.db.transaction(async (trx) => {
122
+ const run = await trx('workflow_runs')
123
+ .where('status', 'running')
124
+ .where('wake_at', '<=', this.now())
125
+ .orderBy('wake_at')
126
+ .forUpdate()
127
+ .skipLocked()
128
+ .first('id');
129
+ if (!run)
130
+ return false;
131
+ await this.advance(trx, run.id);
132
+ return true;
133
+ });
134
+ if (!advanced)
135
+ break;
136
+ processed++;
137
+ }
138
+ return processed;
139
+ }
140
+ /**
141
+ * Runs steps until the run waits, ends, fails or is scheduled later. The caller
142
+ * holds the row lock. Each step executes in a savepoint so a failing step leaves
143
+ * no partial effects behind.
144
+ */
145
+ async advance(trx, runId) {
146
+ for (let count = 0; count < MAX_STEPS_PER_TICK; count++) {
147
+ const run = await trx('workflow_runs').where('id', runId).forUpdate().first();
148
+ if (!run || run.status !== 'running')
149
+ return;
150
+ if (run.wake_at && new Date(run.wake_at) > this.now())
151
+ return;
152
+ let definition;
153
+ try {
154
+ definition = this.definition(run.definition, run.definition_version);
155
+ }
156
+ catch (error) {
157
+ await this.fail(trx, run, error, true);
158
+ return;
159
+ }
160
+ const stepName = String(run.current_step);
161
+ const step = definition.steps[stepName];
162
+ if (!step) {
163
+ await this.fail(trx, run, new StepFailure(`Unknown step ${stepName}`), true);
164
+ return;
165
+ }
166
+ try {
167
+ const outcome = await trx.transaction(async (savepoint) => {
168
+ const context = await this.context(savepoint, run);
169
+ const result = await this.execute(savepoint, run, definition, stepName, context);
170
+ await this.options.beforeCommit?.({ id: run.id, step: stepName });
171
+ return result;
172
+ });
173
+ if (outcome === 'stop')
174
+ return;
175
+ }
176
+ catch (error) {
177
+ await this.fail(trx, run, error, false);
178
+ return;
179
+ }
180
+ }
181
+ }
182
+ async execute(trx, run, definition, stepName, context) {
183
+ const step = definition.steps[stepName];
184
+ const move = async (event, detail = {}) => {
185
+ const target = nextStep(definition, stepName, event);
186
+ await trx('workflow_runs')
187
+ .where('id', run.id)
188
+ .update({
189
+ current_step: target,
190
+ snapshot: JSON.stringify({ value: target }),
191
+ attempts: 0,
192
+ last_error: null,
193
+ wake_at: this.now(),
194
+ updated_at: trx.fn.now(),
195
+ });
196
+ await this.log(trx, run.id, stepName, event.type.toLowerCase(), null, {
197
+ ...detail,
198
+ next: target,
199
+ });
200
+ return 'continue';
201
+ };
202
+ switch (step.type) {
203
+ case 'condition':
204
+ return move({ type: 'EVALUATE', record: context.record });
205
+ case 'update': {
206
+ const values = typeof step.values === 'function' ? step.values(context) : step.values;
207
+ await this.updateRecord(trx, run, context.record, values);
208
+ return move({ type: 'DONE' }, { fields: Object.keys(values) });
209
+ }
210
+ case 'notify': {
211
+ const users = await this.recipients(trx, run, context, step.to);
212
+ for (const userId of users)
213
+ await notifyWithTemplate(trx, userId, step.template ?? 'workflow.decided', step.variables?.(context) ?? {
214
+ outcome: definition.label,
215
+ resource: this.resources.label(run.resource),
216
+ id: run.record_id,
217
+ workflow: definition.label,
218
+ step: step.label ?? stepName,
219
+ });
220
+ return move({ type: 'DONE' }, { recipients: users.length });
221
+ }
222
+ case 'approval': {
223
+ const open = await trx('assignments')
224
+ .where({ workflow_run_id: run.id, workflow_step: stepName, status: 'open' })
225
+ .first('id');
226
+ if (!open) {
227
+ const users = await this.recipients(trx, run, context, step.assignees);
228
+ const eligible = [];
229
+ for (const userId of users)
230
+ if (await this.canView(run.resource, run.record_id, userId))
231
+ eligible.push(userId);
232
+ if (!eligible.length)
233
+ throw new StepFailure(`No eligible approver for ${stepName}`);
234
+ const due = step.dueInDays
235
+ ? new Date(this.now().getTime() + step.dueInDays * 86400000).toISOString().slice(0, 10)
236
+ : null;
237
+ for (const userId of eligible)
238
+ await this.assignments.create(trx, {
239
+ resource: run.resource,
240
+ recordId: Number(run.record_id),
241
+ assigneeId: userId,
242
+ assignedBy: run.started_by,
243
+ title: step.label,
244
+ kind: 'approval',
245
+ dueOn: due,
246
+ workflowRunId: run.id,
247
+ workflowStep: stepName,
248
+ });
249
+ await this.log(trx, run.id, stepName, 'approval_requested', null, { approvers: eligible });
250
+ }
251
+ await trx('workflow_runs').where('id', run.id).update({
252
+ status: 'waiting',
253
+ wake_at: null,
254
+ attempts: 0,
255
+ last_error: null,
256
+ updated_at: trx.fn.now(),
257
+ });
258
+ return 'stop';
259
+ }
260
+ case 'delay': {
261
+ // The deadline lives in the snapshot, so a crash or a later loop back to
262
+ // this step never skips or doubles the wait.
263
+ const snapshot = (run.snapshot ?? {});
264
+ if (!snapshot.until) {
265
+ const wake = new Date(this.now().getTime() + step.ms);
266
+ await trx('workflow_runs')
267
+ .where('id', run.id)
268
+ .update({
269
+ wake_at: wake,
270
+ snapshot: JSON.stringify({ value: stepName, until: wake.toISOString() }),
271
+ updated_at: trx.fn.now(),
272
+ });
273
+ await this.log(trx, run.id, stepName, 'delay_started', null, {
274
+ until: wake.toISOString(),
275
+ });
276
+ return 'stop';
277
+ }
278
+ if (new Date(snapshot.until) > this.now())
279
+ return 'stop';
280
+ return move({ type: 'DONE' });
281
+ }
282
+ case 'http': {
283
+ if (!this.options.post)
284
+ throw new StepFailure('No HTTP client configured for workflow steps');
285
+ const url = typeof step.url === 'function' ? step.url(context) : step.url;
286
+ const body = JSON.stringify(step.body?.(context) ?? { run: run.id, resource: run.resource, recordId: run.record_id });
287
+ const response = await this.options.post(url, {
288
+ headers: {
289
+ 'Content-Type': 'application/json',
290
+ 'Idempotency-Key': `${run.id}:${stepName}`,
291
+ 'User-Agent': 'adula-kit-workflows',
292
+ },
293
+ body,
294
+ signal: AbortSignal.timeout(10000),
295
+ });
296
+ if (response.status < 200 || response.status >= 300)
297
+ throw new StepFailure(`HTTP ${response.status} from ${new URL(url).host}`);
298
+ return move({ type: 'DONE' }, { status: response.status });
299
+ }
300
+ case 'end': {
301
+ if (step.cancelDocument && context.record.docStatus === 1)
302
+ await this.cancelDocument(trx, run, context.record);
303
+ await trx('workflow_runs').where('id', run.id).update({
304
+ status: 'completed',
305
+ outcome: step.outcome,
306
+ wake_at: null,
307
+ completed_at: trx.fn.now(),
308
+ updated_at: trx.fn.now(),
309
+ });
310
+ await this.log(trx, run.id, stepName, 'completed', null, { outcome: step.outcome });
311
+ return 'stop';
312
+ }
313
+ }
314
+ }
315
+ /**
316
+ * Records an approver's decision. Only an open approval assigned to the actor
317
+ * counts, and the run is locked so concurrent decisions cannot both apply.
318
+ */
319
+ async decide(runId, actor, decision, comment) {
320
+ if (!['approve', 'reject'].includes(decision))
321
+ throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
322
+ const note = typeof comment === 'string' ? comment.trim().slice(0, 1000) : '';
323
+ await this.db.transaction(async (trx) => {
324
+ const run = await trx('workflow_runs').where('id', runId).forUpdate().first();
325
+ if (!run)
326
+ throw new KitError(404, 'E_WORKFLOW_NOT_FOUND', 'التدفق غير موجود');
327
+ if (!(await this.resources.permits(run.resource, Number(run.record_id), actor)))
328
+ throw new KitError(404, 'E_WORKFLOW_NOT_FOUND', 'التدفق غير موجود');
329
+ const assignment = await trx('assignments')
330
+ .where({
331
+ workflow_run_id: run.id,
332
+ workflow_step: run.current_step,
333
+ assignee_id: actor.id,
334
+ status: 'open',
335
+ })
336
+ .first();
337
+ if (run.status !== 'waiting' || !assignment)
338
+ throw new KitError(409, 'E_WORKFLOW_STATE', 'لا توجد موافقة مطلوبة منك في هذه الخطوة');
339
+ const definition = this.definition(run.definition, run.definition_version);
340
+ const target = nextStep(definition, run.current_step, {
341
+ type: decision === 'approve' ? 'APPROVE' : 'REJECT',
342
+ });
343
+ await trx('assignments').where('id', assignment.id).update({
344
+ status: 'done',
345
+ outcome: decision,
346
+ completed_at: trx.fn.now(),
347
+ completed_by: actor.id,
348
+ });
349
+ // Other approvers of the same step are no longer needed.
350
+ await trx('assignments')
351
+ .where({ workflow_run_id: run.id, workflow_step: run.current_step, status: 'open' })
352
+ .update({ status: 'cancelled', completed_at: trx.fn.now() });
353
+ await trx('workflow_runs')
354
+ .where('id', run.id)
355
+ .update({
356
+ status: 'running',
357
+ current_step: target,
358
+ snapshot: JSON.stringify({ value: target }),
359
+ wake_at: this.now(),
360
+ attempts: 0,
361
+ last_error: null,
362
+ updated_at: trx.fn.now(),
363
+ });
364
+ await this.log(trx, run.id, run.current_step, decision === 'approve' ? 'approved' : 'rejected', actor.id, {
365
+ ...(note ? { comment: note } : {}),
366
+ next: target,
367
+ });
368
+ await this.advance(trx, run.id);
369
+ });
370
+ return this.run(runId, actor);
371
+ }
372
+ /** Administrators put a failed run back to its failed step. */
373
+ async retry(runId, actorId) {
374
+ const updated = await this.db('workflow_runs').where({ id: runId, status: 'failed' }).update({
375
+ status: 'running',
376
+ attempts: 0,
377
+ wake_at: this.now(),
378
+ updated_at: this.db.fn.now(),
379
+ });
380
+ if (!updated)
381
+ throw new KitError(404, 'E_WORKFLOW_NOT_FOUND', 'لا يوجد تدفق فاشل بهذا المعرّف');
382
+ await this.log(this.db, runId, null, 'retried', actorId, {});
383
+ }
384
+ /**
385
+ * Explicit version migration for runs that have not finished: `mapStep` returns
386
+ * the equivalent step name in the new version.
387
+ */
388
+ async migrateRuns(name, from, to, mapStep) {
389
+ const target = this.definition(name, to);
390
+ return this.db.transaction(async (trx) => {
391
+ const runs = await trx('workflow_runs')
392
+ .where({ definition: name, definition_version: from })
393
+ .whereIn('status', ['running', 'waiting', 'failed'])
394
+ .forUpdate();
395
+ for (const run of runs) {
396
+ const step = mapStep(run.current_step);
397
+ if (!(step in target.steps))
398
+ throw new Error(`Step ${step} is not in ${name}@${to}`);
399
+ await trx('workflow_runs')
400
+ .where('id', run.id)
401
+ .update({
402
+ definition_version: to,
403
+ current_step: step,
404
+ snapshot: JSON.stringify({ value: step }),
405
+ updated_at: trx.fn.now(),
406
+ });
407
+ await this.log(trx, run.id, step, 'migrated', null, { from, to });
408
+ }
409
+ return runs.length;
410
+ });
411
+ }
412
+ async runsFor(name, id, actor) {
413
+ await this.resources.access(name, id, actor);
414
+ const rows = await this.db('workflow_runs')
415
+ .where({ resource: name, record_id: id })
416
+ .whereNot('status', 'pending_definition')
417
+ .orderBy('created_at', 'desc')
418
+ .limit(20);
419
+ return Promise.all(rows.map((row) => this.present(row, actor)));
420
+ }
421
+ async run(runId, actor) {
422
+ const row = await this.db('workflow_runs').where('id', runId).first();
423
+ if (!row || !(await this.resources.permits(row.resource, Number(row.record_id), actor)))
424
+ throw new KitError(404, 'E_WORKFLOW_NOT_FOUND', 'التدفق غير موجود');
425
+ return this.present(row, actor);
426
+ }
427
+ /** Runs waiting for the actor's decision, newest first, on records they can still read. */
428
+ async inbox(actor) {
429
+ const rows = await this.db('workflow_runs as r')
430
+ .join('assignments as a', 'a.workflow_run_id', 'r.id')
431
+ .where({ 'a.assignee_id': actor.id, 'a.status': 'open', 'r.status': 'waiting' })
432
+ .whereRaw('a.workflow_step = r.current_step')
433
+ .orderBy('a.id', 'desc')
434
+ .limit(100)
435
+ .select('r.*');
436
+ const runs = [];
437
+ for (const row of rows)
438
+ if (await this.resources.permits(row.resource, Number(row.record_id), actor))
439
+ runs.push(await this.present(row, actor));
440
+ return runs;
441
+ }
442
+ async failed(limit = 100) {
443
+ const rows = await this.db('workflow_runs')
444
+ .where('status', 'failed')
445
+ .orderBy('updated_at', 'desc')
446
+ .limit(limit);
447
+ return Promise.all(rows.map((row) => this.present(row)));
448
+ }
449
+ async present(row, actor) {
450
+ let definition;
451
+ try {
452
+ definition = this.definition(row.definition, row.definition_version);
453
+ }
454
+ catch { }
455
+ const history = await this.db('workflow_events as e')
456
+ .leftJoin('users as u', 'u.id', 'e.actor_id')
457
+ .where('e.run_id', row.id)
458
+ .orderBy('e.id')
459
+ .limit(200)
460
+ .select('e.*', 'u.full_name as actor_name');
461
+ const mine = actor
462
+ ? await this.db('assignments')
463
+ .where({
464
+ workflow_run_id: row.id,
465
+ workflow_step: row.current_step,
466
+ assignee_id: actor.id,
467
+ status: 'open',
468
+ })
469
+ .first('id', 'title')
470
+ : undefined;
471
+ const step = row.current_step ? definition?.steps[row.current_step] : undefined;
472
+ let resourceLabel = String(row.resource);
473
+ try {
474
+ resourceLabel = this.resources.label(row.resource);
475
+ }
476
+ catch { }
477
+ return {
478
+ id: String(row.id),
479
+ resource: String(row.resource),
480
+ resourceLabel,
481
+ recordId: Number(row.record_id),
482
+ definition: String(row.definition),
483
+ label: definition?.label ?? String(row.definition),
484
+ version: Number(row.definition_version),
485
+ status: row.status,
486
+ step: row.current_step ?? null,
487
+ stepLabel: step?.label ?? row.current_step ?? null,
488
+ outcome: row.outcome ?? null,
489
+ attempts: Number(row.attempts ?? 0),
490
+ lastError: row.last_error ?? null,
491
+ wakeAt: row.wake_at ? new Date(row.wake_at).toISOString() : null,
492
+ createdAt: new Date(row.created_at).toISOString(),
493
+ completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
494
+ history: history.map((event) => ({
495
+ step: event.step ?? null,
496
+ event: String(event.event),
497
+ actorName: event.actor_name ? String(event.actor_name) : null,
498
+ detail: (event.detail ?? {}),
499
+ at: new Date(event.created_at).toISOString(),
500
+ })),
501
+ myApproval: mine ? { assignmentId: Number(mine.id), title: String(mine.title) } : null,
502
+ };
503
+ }
504
+ async fail(trx, run, error, permanent) {
505
+ const attempts = Number(run.attempts ?? 0) + 1;
506
+ let maxAttempts = 5;
507
+ try {
508
+ maxAttempts = this.definition(run.definition, run.definition_version).maxAttempts ?? 5;
509
+ }
510
+ catch { }
511
+ const message = String(error?.message ?? error).slice(0, 1000);
512
+ const final = permanent || attempts >= maxAttempts;
513
+ await trx('workflow_runs')
514
+ .where('id', run.id)
515
+ .update({
516
+ status: final ? 'failed' : 'running',
517
+ attempts,
518
+ last_error: message,
519
+ wake_at: final
520
+ ? null
521
+ : new Date(this.now().getTime() +
522
+ BACKOFF_SECONDS[Math.min(attempts - 1, BACKOFF_SECONDS.length - 1)] * 1000),
523
+ updated_at: trx.fn.now(),
524
+ });
525
+ await this.log(trx, run.id, run.current_step, final ? 'failed' : 'retry_scheduled', null, {
526
+ error: message,
527
+ attempts,
528
+ });
529
+ if (final && run.started_by)
530
+ await notifyWithTemplate(trx, Number(run.started_by), 'workflow.failed', {
531
+ workflow: run.definition,
532
+ resource: this.resources.label(run.resource),
533
+ id: run.record_id,
534
+ error: message,
535
+ });
536
+ }
537
+ async cancelRuns(trx, resource, id, actorId) {
538
+ const runs = await trx('workflow_runs')
539
+ .where({ resource, record_id: id })
540
+ .whereIn('status', ['running', 'waiting', 'failed', 'pending_definition'])
541
+ .forUpdate();
542
+ for (const run of runs) {
543
+ await trx('workflow_runs').where('id', run.id).update({
544
+ status: 'cancelled',
545
+ wake_at: null,
546
+ completed_at: trx.fn.now(),
547
+ updated_at: trx.fn.now(),
548
+ });
549
+ await trx('assignments')
550
+ .where({ workflow_run_id: run.id, status: 'open' })
551
+ .update({ status: 'cancelled', completed_at: trx.fn.now() });
552
+ await this.log(trx, run.id, run.current_step, 'cancelled', actorId || null, {});
553
+ }
554
+ }
555
+ async context(trx, run) {
556
+ const resource = this.registry.get(run.resource);
557
+ const row = await trx(resource.name).where('id', run.record_id).first();
558
+ if (!row)
559
+ throw new StepFailure('The workflow record no longer exists');
560
+ return {
561
+ record: fromRow(row, resource),
562
+ run: {
563
+ id: String(run.id),
564
+ resource: String(run.resource),
565
+ recordId: Number(run.record_id),
566
+ startedBy: run.started_by === null ? null : Number(run.started_by),
567
+ },
568
+ db: trx,
569
+ };
570
+ }
571
+ /** Workflow writes are system writes attributed to the submitter, with history. */
572
+ async updateRecord(trx, run, record, values) {
573
+ const resource = this.registry.get(run.resource);
574
+ const update = { updated_at: trx.fn.now() };
575
+ const changes = [];
576
+ for (const [key, value] of Object.entries(values)) {
577
+ const field = resource.fields[key];
578
+ if (!field || ['hasMany', 'attachment'].includes(field.type) || field.sequence)
579
+ throw new StepFailure(`Workflow cannot write field ${key}`);
580
+ if (field.type === 'lookup' && value !== null && value !== undefined) {
581
+ const valid = await trx('lookups')
582
+ .where({ group: field.group, key: value, active: true })
583
+ .first();
584
+ if (!valid)
585
+ throw new StepFailure(`Invalid lookup value for ${key}`);
586
+ }
587
+ update[field.column ?? columnName(key)] =
588
+ field.type === 'json' ? JSON.stringify(value) : value;
589
+ if (JSON.stringify(record[key] ?? null) !== JSON.stringify(value ?? null))
590
+ changes.push({ field: key, before: record[key] ?? null, after: value ?? null });
591
+ }
592
+ if (resource.version)
593
+ update.version = Number(record.version) + 1;
594
+ if (run.started_by)
595
+ update.updated_by = run.started_by;
596
+ await trx(resource.name).where('id', run.record_id).update(update);
597
+ if (run.started_by)
598
+ await recordMutation(trx, {
599
+ module: this.registry.owner(resource.name),
600
+ resource: resource.name,
601
+ id: run.record_id,
602
+ actorId: Number(run.started_by),
603
+ action: 'update',
604
+ fields: Object.keys(values),
605
+ changes,
606
+ });
607
+ }
608
+ async cancelDocument(trx, run, record) {
609
+ const resource = this.registry.get(run.resource);
610
+ await trx(resource.name)
611
+ .where('id', run.record_id)
612
+ .update({
613
+ doc_status: 2,
614
+ updated_at: trx.fn.now(),
615
+ ...(resource.version ? { version: Number(record.version) + 1 } : {}),
616
+ });
617
+ if (run.started_by)
618
+ await recordMutation(trx, {
619
+ module: this.registry.owner(resource.name),
620
+ resource: resource.name,
621
+ id: run.record_id,
622
+ actorId: Number(run.started_by),
623
+ action: 'cancel',
624
+ fields: [],
625
+ });
626
+ }
627
+ async recipients(trx, run, context, to) {
628
+ let ids;
629
+ if (to === 'creator')
630
+ ids = [Number(context.record.createdBy)];
631
+ else if (to === 'submitter')
632
+ ids = run.started_by ? [Number(run.started_by)] : [];
633
+ else if (typeof to === 'function')
634
+ ids = await to(context);
635
+ else if ('users' in to)
636
+ ids = to.users;
637
+ else {
638
+ const members = await trx('user_roles as ur')
639
+ .join('roles as r', 'r.id', 'ur.role_id')
640
+ .join('users as u', 'u.id', 'ur.user_id')
641
+ .where('r.name', to.role)
642
+ .whereNull('u.disabled_at')
643
+ .distinct('ur.user_id');
644
+ ids = members.map((row) => Number(row.user_id));
645
+ }
646
+ return [...new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0))];
647
+ }
648
+ async canView(resource, id, userId) {
649
+ const user = await this.db('users').where('id', userId).first('disabled_at');
650
+ if (!user || user.disabled_at)
651
+ return false;
652
+ return this.resources.permits(resource, Number(id), await this.actors.load(userId));
653
+ }
654
+ async log(db, runId, step, event, actorId, detail) {
655
+ await db('workflow_events').insert({
656
+ run_id: runId,
657
+ step,
658
+ event,
659
+ actor_id: actorId,
660
+ detail: JSON.stringify(detail),
661
+ });
662
+ }
663
+ now() {
664
+ return this.options.now?.() ?? new Date();
665
+ }
666
+ }
667
+ /**
668
+ * Listeners resolved per call, so hosts can build the engine lazily (for example
669
+ * per request) while the listener list is fixed at boot from the registry.
670
+ */
671
+ export function workflowListeners(registry, engine) {
672
+ return registry.all().flatMap((resource) => {
673
+ if (!resource.submittable)
674
+ return [];
675
+ const module = registry.owner(resource.name);
676
+ return ['start', 'cancel'].map((kind) => ({
677
+ name: `kit.workflows.${kind}.${resource.name}`,
678
+ event: `${module}.${resource.name}.${kind === 'start' ? 'submitted' : 'cancelled'}`,
679
+ handle: (event, trx) => {
680
+ const listener = engine()
681
+ .listeners()
682
+ .find((entry) => entry.name === `kit.workflows.${kind}.${resource.name}`);
683
+ return listener ? listener.handle(event, trx) : Promise.resolve();
684
+ },
685
+ }));
686
+ });
687
+ }