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