@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.
- package/README.md +12 -2
- package/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +54 -7
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- package/build/agent/skills/idea-review/SKILL.md +26 -1
- package/build/agent/skills/module-review/SKILL.md +22 -0
- package/build/agent/skills/perf-review/SKILL.md +24 -0
- package/build/agent/skills/schema-review/SKILL.md +22 -0
- package/build/agent/skills/security-review/SKILL.md +24 -0
- package/build/agent/skills/ui-review/SKILL.md +22 -0
- package/build/commands/capabilities.d.ts +4 -0
- package/build/commands/capabilities.js +35 -4
- package/build/commands/doctor.js +37 -1
- package/build/commands/gaps.js +16 -6
- package/build/commands/install.js +19 -5
- package/build/commands/main.d.ts +4 -2
- package/build/commands/main.js +2 -0
- package/build/commands/module_add.js +2 -2
- package/build/commands/resource.js +1 -1
- package/build/commands/resource_snapshot.d.ts +15 -0
- package/build/commands/resource_snapshot.js +61 -0
- package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
- package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
- package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
- package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
- package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
- package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
- package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
- package/build/database/migrations/1770000000008_kit_imports.js +10 -0
- package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
- package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
- package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
- package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
- package/build/index.d.ts +31 -2
- package/build/index.js +17 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +34 -12
- package/build/src/admin/controller.d.ts +2 -0
- package/build/src/admin/controller.js +52 -1
- package/build/src/admin/presentation.d.ts +6 -0
- package/build/src/admin/record_title.d.ts +14 -0
- package/build/src/admin/record_title.js +50 -0
- package/build/src/admin/resource_service.d.ts +184 -2
- package/build/src/admin/resource_service.js +713 -48
- package/build/src/attachments/attachment_service.d.ts +12 -0
- package/build/src/attachments/attachment_service.js +28 -2
- package/build/src/attachments/upload_grants.d.ts +44 -0
- package/build/src/attachments/upload_grants.js +105 -0
- package/build/src/auth/ability.d.ts +1 -1
- package/build/src/auth/ability.js +4 -1
- package/build/src/auth/actor_store.js +6 -1
- package/build/src/auth/conditions.d.ts +15 -0
- package/build/src/auth/conditions.js +36 -0
- package/build/src/auth/sql.js +6 -2
- package/build/src/collaboration/assignments.d.ts +129 -0
- package/build/src/collaboration/assignments.js +333 -0
- package/build/src/collaboration/record_collaboration.d.ts +86 -0
- package/build/src/collaboration/record_collaboration.js +348 -0
- package/build/src/commands/agent_assets.js +5 -0
- package/build/src/commands/capabilities.d.ts +19 -0
- package/build/src/commands/capabilities.js +179 -0
- package/build/src/commands/doctor.d.ts +31 -0
- package/build/src/commands/doctor.js +114 -0
- package/build/src/commands/gap_report.d.ts +50 -2
- package/build/src/commands/gap_report.js +102 -4
- package/build/src/commands/generator.js +3 -3
- package/build/src/commands/snapshot.d.ts +28 -0
- package/build/src/commands/snapshot.js +48 -0
- package/build/src/commands/source_markers.d.ts +10 -1
- package/build/src/commands/source_markers.js +36 -4
- package/build/src/core/administration_guard.js +3 -1
- package/build/src/core/message_templates.d.ts +83 -0
- package/build/src/core/message_templates.js +293 -0
- package/build/src/core/module_seed.d.ts +15 -0
- package/build/src/core/module_seed.js +31 -0
- package/build/src/core/notifications.d.ts +18 -1
- package/build/src/core/notifications.js +27 -1
- package/build/src/core/roles.d.ts +27 -1
- package/build/src/core/roles.js +133 -5
- package/build/src/database/schema.d.ts +45 -0
- package/build/src/database/schema.js +290 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/outbox.d.ts +1 -0
- package/build/src/events/outbox.js +1 -1
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +15 -2
- package/build/src/integrations/imports.d.ts +74 -0
- package/build/src/integrations/imports.js +333 -0
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.d.ts +39 -0
- package/build/src/integrations/openapi.js +323 -0
- package/build/src/integrations/print.d.ts +37 -0
- package/build/src/integrations/print.js +124 -0
- package/build/src/integrations/webhooks.d.ts +98 -0
- package/build/src/integrations/webhooks.js +298 -0
- package/build/src/resource/define_resource.d.ts +1 -0
- package/build/src/resource/define_resource.js +21 -1
- package/build/src/resource/registry.d.ts +3 -0
- package/build/src/resource/registry.js +42 -0
- package/build/src/resource/types.d.ts +67 -1
- package/build/src/resource/values.js +2 -1
- package/build/src/services/settings.d.ts +13 -1
- package/build/src/services/settings.js +9 -2
- package/build/src/workflows/define_workflow.d.ts +124 -0
- package/build/src/workflows/define_workflow.js +123 -0
- package/build/src/workflows/engine.d.ts +141 -0
- package/build/src/workflows/engine.js +752 -0
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +7 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Knex } from 'knex';
|
|
2
2
|
import type { LucidModel } from '@adonisjs/lucid/types/model';
|
|
3
|
+
import type { WorkflowDefinition } from '../workflows/define_workflow.js';
|
|
4
|
+
import type { Conditions } from '../auth/conditions.js';
|
|
3
5
|
export type Label = {
|
|
4
6
|
ar: string;
|
|
5
7
|
en: string;
|
|
@@ -35,6 +37,13 @@ export type Field = {
|
|
|
35
37
|
} | {
|
|
36
38
|
type: 'belongsTo';
|
|
37
39
|
resource: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A user of this deployment (foreign key to users). Choices are active members of
|
|
43
|
+
* the record's organization unit or its ancestors; readers see only the display name.
|
|
44
|
+
*/
|
|
45
|
+
| {
|
|
46
|
+
type: 'user';
|
|
38
47
|
} | {
|
|
39
48
|
type: 'hasMany';
|
|
40
49
|
resource: string;
|
|
@@ -46,9 +55,26 @@ export type Field = {
|
|
|
46
55
|
});
|
|
47
56
|
export type Resource = {
|
|
48
57
|
name: string;
|
|
58
|
+
/** The list heading, usually plural (for example «الحسابات»). */
|
|
49
59
|
label: Label;
|
|
60
|
+
/**
|
|
61
|
+
* The singular record noun (for example «حساب»). Generated pages say «إضافة حساب»
|
|
62
|
+
* instead of the generic «إضافة سجل».
|
|
63
|
+
*/
|
|
64
|
+
recordLabel?: Label;
|
|
65
|
+
/** Full create-button text when «إضافة <recordLabel>» does not fit (for example «مهمة جديدة»). */
|
|
66
|
+
createLabel?: Label;
|
|
50
67
|
model: LucidModel;
|
|
51
68
|
scoped: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The organization unit of a scoped record follows a required belongsTo parent, for
|
|
71
|
+
* example a clinic follows its facility. The unit is copied from the parent before
|
|
72
|
+
* authorization, the actor must be able to view the parent, and the form has no unit
|
|
73
|
+
* picker. `ResourceService.rehome` moves the children after the parent moves.
|
|
74
|
+
*/
|
|
75
|
+
scope?: {
|
|
76
|
+
from: string;
|
|
77
|
+
};
|
|
52
78
|
submittable?: boolean;
|
|
53
79
|
version?: boolean;
|
|
54
80
|
customFields?: boolean;
|
|
@@ -58,6 +84,12 @@ export type Resource = {
|
|
|
58
84
|
show: readonly string[];
|
|
59
85
|
serialize?: readonly string[];
|
|
60
86
|
hidden?: readonly string[];
|
|
87
|
+
/**
|
|
88
|
+
* Fields that name a record wherever it is referenced: relation cells and pickers,
|
|
89
|
+
* "My tasks" and the approvals inbox. Joined with « · ». Defaults to the first
|
|
90
|
+
* sequence field and the first text field in `list`.
|
|
91
|
+
*/
|
|
92
|
+
title?: readonly string[];
|
|
61
93
|
actions: readonly Action[];
|
|
62
94
|
validator: {
|
|
63
95
|
validate(data: unknown): Promise<RecordData>;
|
|
@@ -72,18 +104,52 @@ export type HookContext = {
|
|
|
72
104
|
userId: number;
|
|
73
105
|
action: Action;
|
|
74
106
|
};
|
|
107
|
+
/** A changeable list value a module needs before its records can be saved. */
|
|
108
|
+
export type ModuleLookup = {
|
|
109
|
+
key: string;
|
|
110
|
+
label: Label;
|
|
111
|
+
sort?: number;
|
|
112
|
+
};
|
|
113
|
+
/** A role a module ships; its rules go through the same validation as the roles screen. */
|
|
114
|
+
export type ModuleRole = {
|
|
115
|
+
/** Stable key that workflows address ({ role: key }); see roles.key. */
|
|
116
|
+
key: string;
|
|
117
|
+
/** Arabic display name; administrators may rename it later. */
|
|
118
|
+
name: string;
|
|
119
|
+
permissionLevel?: number;
|
|
120
|
+
rules: readonly {
|
|
121
|
+
subject: string;
|
|
122
|
+
action: string;
|
|
123
|
+
inverted?: boolean;
|
|
124
|
+
conditions?: Conditions | null;
|
|
125
|
+
fields?: string[] | null;
|
|
126
|
+
}[];
|
|
127
|
+
};
|
|
75
128
|
export type Module = {
|
|
76
129
|
name: string;
|
|
77
130
|
reference?: boolean;
|
|
78
131
|
label: Label;
|
|
79
132
|
dependsOn: readonly string[];
|
|
80
133
|
resources: readonly Resource[];
|
|
134
|
+
/** Versioned workflows of this module's submittable resources (phase 4). */
|
|
135
|
+
workflows?: readonly WorkflowDefinition[];
|
|
136
|
+
/**
|
|
137
|
+
* Lookup rows by group. adula:install inserts missing rows and never changes or
|
|
138
|
+
* removes existing ones, so administrator edits survive upgrades.
|
|
139
|
+
*/
|
|
140
|
+
lookups?: Readonly<Record<string, readonly ModuleLookup[]>>;
|
|
141
|
+
/**
|
|
142
|
+
* Roles created by adula:install when no role has their key yet. An existing role
|
|
143
|
+
* is never modified, so administrators own every role after installation.
|
|
144
|
+
*/
|
|
145
|
+
defaultRoles?: readonly ModuleRole[];
|
|
81
146
|
};
|
|
82
|
-
export type ResourceInput<F extends Record<string, Field>> = Omit<Resource, 'fields' | 'list' | 'form' | 'show' | 'hidden' | 'serialize'> & {
|
|
147
|
+
export type ResourceInput<F extends Record<string, Field>> = Omit<Resource, 'fields' | 'list' | 'form' | 'show' | 'hidden' | 'serialize' | 'title'> & {
|
|
83
148
|
fields: F;
|
|
84
149
|
list: readonly (keyof F & string)[];
|
|
85
150
|
form: readonly (keyof F & string)[];
|
|
86
151
|
show: readonly (keyof F & string)[];
|
|
87
152
|
hidden?: readonly (keyof F & string)[];
|
|
88
153
|
serialize?: readonly (keyof F & string)[];
|
|
154
|
+
title?: readonly (keyof F & string)[];
|
|
89
155
|
};
|
|
@@ -48,8 +48,9 @@ export function fieldValue(field, value) {
|
|
|
48
48
|
}
|
|
49
49
|
case 'integer':
|
|
50
50
|
case 'belongsTo':
|
|
51
|
+
case 'user':
|
|
51
52
|
if (!Number.isInteger(value) ||
|
|
52
|
-
Number(value) < (field.type === '
|
|
53
|
+
Number(value) < (field.type === 'integer' ? -2147483648 : 1) ||
|
|
53
54
|
Number(value) > 2147483647)
|
|
54
55
|
throw new Error('Expected a PostgreSQL integer');
|
|
55
56
|
return value;
|
|
@@ -6,4 +6,16 @@ export declare class Settings {
|
|
|
6
6
|
set(key: string, value: unknown, scope?: string, scopeId?: string): Promise<void>;
|
|
7
7
|
}
|
|
8
8
|
export declare function sequence(trx: Knex.Transaction, key: string): Promise<string>;
|
|
9
|
-
|
|
9
|
+
/** The record a notification is about; opening the notification opens the record. */
|
|
10
|
+
export type NotificationTarget = {
|
|
11
|
+
resource: string;
|
|
12
|
+
recordId: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function targetColumns(target?: NotificationTarget | null): {
|
|
15
|
+
resource?: undefined;
|
|
16
|
+
record_id?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
resource: string;
|
|
19
|
+
record_id: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function notify(db: Knex, userId: number, title: string, body: string, target?: NotificationTarget | null): Promise<void>;
|
|
@@ -18,6 +18,13 @@ export async function sequence(trx, key) {
|
|
|
18
18
|
const result = await trx.raw('INSERT INTO sequences (key,value) VALUES (?,1) ON CONFLICT (key) DO UPDATE SET value=sequences.value+1 RETURNING value', [key]);
|
|
19
19
|
return `${key}-${String(result.rows[0].value).padStart(6, '0')}`;
|
|
20
20
|
}
|
|
21
|
-
export
|
|
22
|
-
|
|
21
|
+
export function targetColumns(target) {
|
|
22
|
+
if (!target)
|
|
23
|
+
return {};
|
|
24
|
+
if (!/^[a-z][a-z0-9_]*$/.test(target.resource) || !Number.isSafeInteger(target.recordId))
|
|
25
|
+
throw new Error('Invalid notification target');
|
|
26
|
+
return { resource: target.resource, record_id: target.recordId };
|
|
27
|
+
}
|
|
28
|
+
export async function notify(db, userId, title, body, target) {
|
|
29
|
+
await db('notifications').insert({ user_id: userId, title, body, ...targetColumns(target) });
|
|
23
30
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type AnyStateMachine } from 'xstate';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
import type { RecordData } from '../resource/types.js';
|
|
4
|
+
/** Everything a step callback may read: the current record and the run facts. */
|
|
5
|
+
export type StepContext = {
|
|
6
|
+
record: RecordData;
|
|
7
|
+
run: {
|
|
8
|
+
id: string;
|
|
9
|
+
resource: string;
|
|
10
|
+
recordId: number;
|
|
11
|
+
startedBy: number | null;
|
|
12
|
+
};
|
|
13
|
+
db: Knex;
|
|
14
|
+
};
|
|
15
|
+
export type Recipients = 'creator' | 'submitter'
|
|
16
|
+
/**
|
|
17
|
+
* The members of a role, addressed by its stable key (roles.key). A role without a
|
|
18
|
+
* key is still found by its display name, which administrators can rename.
|
|
19
|
+
*/
|
|
20
|
+
| {
|
|
21
|
+
role: string;
|
|
22
|
+
} | {
|
|
23
|
+
users: number[];
|
|
24
|
+
} | ((context: StepContext) => number[] | Promise<number[]>);
|
|
25
|
+
/** One decision an approver can take at a decision step (#42). */
|
|
26
|
+
export type DecisionOutcome = {
|
|
27
|
+
label: string;
|
|
28
|
+
next: string;
|
|
29
|
+
/** Whether the decision needs a comment; defaults to optional. */
|
|
30
|
+
comment?: 'optional' | 'required';
|
|
31
|
+
};
|
|
32
|
+
export type WorkflowStep = {
|
|
33
|
+
type: 'condition';
|
|
34
|
+
label?: string;
|
|
35
|
+
when: (record: RecordData) => boolean;
|
|
36
|
+
then: string;
|
|
37
|
+
else: string;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'update';
|
|
40
|
+
label?: string;
|
|
41
|
+
values: RecordData | ((context: StepContext) => RecordData);
|
|
42
|
+
next: string;
|
|
43
|
+
} | {
|
|
44
|
+
type: 'notify';
|
|
45
|
+
label?: string;
|
|
46
|
+
to: Recipients;
|
|
47
|
+
/** A message template key; defaults to workflow.decided. */
|
|
48
|
+
template?: string;
|
|
49
|
+
variables?: (context: StepContext) => Record<string, unknown>;
|
|
50
|
+
next: string;
|
|
51
|
+
} | {
|
|
52
|
+
type: 'approval';
|
|
53
|
+
label: string;
|
|
54
|
+
assignees: Recipients;
|
|
55
|
+
dueInDays?: number;
|
|
56
|
+
approve: string;
|
|
57
|
+
reject: string;
|
|
58
|
+
} | {
|
|
59
|
+
/**
|
|
60
|
+
* An approval with named outcomes, for example approve, reject and return. Each
|
|
61
|
+
* outcome names its next step (a later or an earlier one) and may require a comment.
|
|
62
|
+
* The submitted document stays locked: a decision never changes its fields.
|
|
63
|
+
*/
|
|
64
|
+
type: 'decision';
|
|
65
|
+
label: string;
|
|
66
|
+
assignees: Recipients;
|
|
67
|
+
dueInDays?: number;
|
|
68
|
+
outcomes: Record<string, DecisionOutcome>;
|
|
69
|
+
} | {
|
|
70
|
+
type: 'delay';
|
|
71
|
+
label?: string;
|
|
72
|
+
ms: number;
|
|
73
|
+
next: string;
|
|
74
|
+
} | {
|
|
75
|
+
type: 'http';
|
|
76
|
+
label?: string;
|
|
77
|
+
url: string | ((context: StepContext) => string);
|
|
78
|
+
body?: (context: StepContext) => unknown;
|
|
79
|
+
next: string;
|
|
80
|
+
} | {
|
|
81
|
+
type: 'end';
|
|
82
|
+
label?: string;
|
|
83
|
+
outcome: 'approved' | 'rejected' | 'completed';
|
|
84
|
+
/** Cancel the submitted document when the workflow ends (for example on rejection). */
|
|
85
|
+
cancelDocument?: boolean;
|
|
86
|
+
};
|
|
87
|
+
export type WorkflowInput = {
|
|
88
|
+
/** Lower-case letters, digits and underscores, starting with a letter (for example release_approval). */
|
|
89
|
+
name: string;
|
|
90
|
+
version: number;
|
|
91
|
+
resource: string;
|
|
92
|
+
label: string;
|
|
93
|
+
start: string;
|
|
94
|
+
/** Step names follow the workflow name rule (for example notify_approved). */
|
|
95
|
+
steps: Record<string, WorkflowStep>;
|
|
96
|
+
/** Failed step attempts before the run stops as failed (default 5). */
|
|
97
|
+
maxAttempts?: number;
|
|
98
|
+
};
|
|
99
|
+
export type WorkflowDefinition = WorkflowInput & {
|
|
100
|
+
machine: AnyStateMachine;
|
|
101
|
+
};
|
|
102
|
+
export type WorkflowEvent = {
|
|
103
|
+
type: 'EVALUATE';
|
|
104
|
+
record: RecordData;
|
|
105
|
+
} | {
|
|
106
|
+
type: 'DONE';
|
|
107
|
+
} | {
|
|
108
|
+
type: 'APPROVE';
|
|
109
|
+
} | {
|
|
110
|
+
type: 'REJECT';
|
|
111
|
+
} | {
|
|
112
|
+
type: 'DECIDE';
|
|
113
|
+
outcome: string;
|
|
114
|
+
};
|
|
115
|
+
export declare const WORKFLOW_NAME_RULE = "use lower-case letters, digits and underscores, starting with a letter";
|
|
116
|
+
/**
|
|
117
|
+
* Declares a versioned workflow. Steps compile to an XState machine; the engine
|
|
118
|
+
* performs each step's effect and asks the machine for the next step. Runs keep
|
|
119
|
+
* the version they started with, so publishing a new version never changes a
|
|
120
|
+
* running workflow (migrate explicitly with WorkflowEngine.migrateRuns).
|
|
121
|
+
*/
|
|
122
|
+
export declare function defineWorkflow(input: WorkflowInput): WorkflowDefinition;
|
|
123
|
+
/** The step reached from `current` by `event`, or an error if the step does not accept it. */
|
|
124
|
+
export declare function nextStep(definition: WorkflowDefinition, current: string, event: WorkflowEvent): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createMachine, getNextSnapshot } from 'xstate';
|
|
2
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]*$/;
|
|
3
|
+
export const WORKFLOW_NAME_RULE = 'use lower-case letters, digits and underscores, starting with a letter';
|
|
4
|
+
/** Names the rule and a valid spelling, since dotted event-style names are a natural first try. */
|
|
5
|
+
function assertIdentifier(kind, value) {
|
|
6
|
+
if (IDENTIFIER.test(value))
|
|
7
|
+
return;
|
|
8
|
+
const suggestion = value
|
|
9
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
12
|
+
.replace(/^[^a-z]+|_+$/g, '');
|
|
13
|
+
const example = IDENTIFIER.test(suggestion)
|
|
14
|
+
? suggestion
|
|
15
|
+
: kind === 'workflow'
|
|
16
|
+
? 'release_approval'
|
|
17
|
+
: 'notify_approved';
|
|
18
|
+
throw new Error(`Invalid ${kind} name "${value}": ${WORKFLOW_NAME_RULE} (for example ${example}).`);
|
|
19
|
+
}
|
|
20
|
+
function targets(step) {
|
|
21
|
+
switch (step.type) {
|
|
22
|
+
case 'condition':
|
|
23
|
+
return [step.then, step.else];
|
|
24
|
+
case 'approval':
|
|
25
|
+
return [step.approve, step.reject];
|
|
26
|
+
case 'decision':
|
|
27
|
+
return Object.values(step.outcomes).map((outcome) => outcome.next);
|
|
28
|
+
case 'end':
|
|
29
|
+
return [];
|
|
30
|
+
default:
|
|
31
|
+
return [step.next];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Declares a versioned workflow. Steps compile to an XState machine; the engine
|
|
36
|
+
* performs each step's effect and asks the machine for the next step. Runs keep
|
|
37
|
+
* the version they started with, so publishing a new version never changes a
|
|
38
|
+
* running workflow (migrate explicitly with WorkflowEngine.migrateRuns).
|
|
39
|
+
*/
|
|
40
|
+
export function defineWorkflow(input) {
|
|
41
|
+
assertIdentifier('workflow', input.name);
|
|
42
|
+
if (!Number.isInteger(input.version) || input.version < 1)
|
|
43
|
+
throw new Error('Workflow version must be a positive integer');
|
|
44
|
+
if (!(input.start in input.steps))
|
|
45
|
+
throw new Error(`Unknown start step: ${input.start}`);
|
|
46
|
+
const keys = Object.keys(input.steps);
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
assertIdentifier('step', key);
|
|
49
|
+
for (const target of targets(input.steps[key]))
|
|
50
|
+
if (!(target in input.steps))
|
|
51
|
+
throw new Error(`Step ${key} points to unknown step ${target}`);
|
|
52
|
+
const step = input.steps[key];
|
|
53
|
+
if (step.type === 'delay' && (!Number.isInteger(step.ms) || step.ms < 0))
|
|
54
|
+
throw new Error(`Step ${key} needs a non-negative integer delay`);
|
|
55
|
+
if (step.type === 'decision') {
|
|
56
|
+
const outcomes = Object.entries(step.outcomes ?? {});
|
|
57
|
+
if (outcomes.length < 2)
|
|
58
|
+
throw new Error(`Decision step ${key} needs at least two outcomes`);
|
|
59
|
+
for (const [name, outcome] of outcomes) {
|
|
60
|
+
if (!IDENTIFIER.test(name) || name.length > 20)
|
|
61
|
+
throw new Error(`Invalid outcome name "${name}" in step ${key}: ${WORKFLOW_NAME_RULE}, at most 20 characters`);
|
|
62
|
+
if (!outcome.label)
|
|
63
|
+
throw new Error(`Outcome ${name} of step ${key} needs a label`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!keys.some((key) => input.steps[key].type === 'end'))
|
|
68
|
+
throw new Error('A workflow needs at least one end step');
|
|
69
|
+
const machine = createMachine({
|
|
70
|
+
id: `${input.name}@${input.version}`,
|
|
71
|
+
initial: input.start,
|
|
72
|
+
states: Object.fromEntries(keys.map((key) => {
|
|
73
|
+
const step = input.steps[key];
|
|
74
|
+
switch (step.type) {
|
|
75
|
+
case 'condition':
|
|
76
|
+
return [
|
|
77
|
+
key,
|
|
78
|
+
{
|
|
79
|
+
on: {
|
|
80
|
+
EVALUATE: [
|
|
81
|
+
{
|
|
82
|
+
target: step.then,
|
|
83
|
+
guard: ({ event }) => event.type === 'EVALUATE' && step.when(event.record),
|
|
84
|
+
},
|
|
85
|
+
{ target: step.else },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
case 'approval':
|
|
91
|
+
return [key, { on: { APPROVE: step.approve, REJECT: step.reject } }];
|
|
92
|
+
case 'decision':
|
|
93
|
+
return [
|
|
94
|
+
key,
|
|
95
|
+
{
|
|
96
|
+
on: {
|
|
97
|
+
DECIDE: Object.entries(step.outcomes).map(([name, outcome]) => ({
|
|
98
|
+
target: outcome.next,
|
|
99
|
+
guard: ({ event }) => event.type === 'DECIDE' && event.outcome === name,
|
|
100
|
+
})),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
case 'end':
|
|
105
|
+
return [key, { type: 'final' }];
|
|
106
|
+
default:
|
|
107
|
+
return [key, { on: { DONE: step.next } }];
|
|
108
|
+
}
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
return { ...input, machine };
|
|
112
|
+
}
|
|
113
|
+
/** The step reached from `current` by `event`, or an error if the step does not accept it. */
|
|
114
|
+
export function nextStep(definition, current, event) {
|
|
115
|
+
if (!(current in definition.steps))
|
|
116
|
+
throw new Error(`Step ${current} is not part of ${definition.name}@${definition.version}`);
|
|
117
|
+
const snapshot = definition.machine.resolveState({ value: current, context: {} });
|
|
118
|
+
const next = getNextSnapshot(definition.machine, snapshot, event);
|
|
119
|
+
const value = String(next.value);
|
|
120
|
+
if (value === current)
|
|
121
|
+
throw new Error(`Step ${current} does not accept ${event.type}`);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { Actor } from '../auth/ability.js';
|
|
3
|
+
import type { ResourceService } from '../admin/resource_service.js';
|
|
4
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
5
|
+
import type { JsonValue } from '../resource/types.js';
|
|
6
|
+
import type { DomainEvent, Listener } from '../events/outbox.js';
|
|
7
|
+
import type { HttpPoster } from '../integrations/webhooks.js';
|
|
8
|
+
import type { Assignments } from '../collaboration/assignments.js';
|
|
9
|
+
import { type WorkflowDefinition } from './define_workflow.js';
|
|
10
|
+
export type WorkflowRunStatus = 'pending_definition' | 'running' | 'waiting' | 'completed' | 'failed' | 'cancelled';
|
|
11
|
+
export type WorkflowRun = {
|
|
12
|
+
id: string;
|
|
13
|
+
resource: string;
|
|
14
|
+
resourceLabel: string;
|
|
15
|
+
recordId: number;
|
|
16
|
+
/** The record title for the approver (#32); null in admin views or when not readable. */
|
|
17
|
+
recordTitle: string | null;
|
|
18
|
+
definition: string;
|
|
19
|
+
label: string;
|
|
20
|
+
version: number;
|
|
21
|
+
status: WorkflowRunStatus;
|
|
22
|
+
step: string | null;
|
|
23
|
+
stepLabel: string | null;
|
|
24
|
+
outcome: string | null;
|
|
25
|
+
attempts: number;
|
|
26
|
+
lastError: string | null;
|
|
27
|
+
wakeAt: string | null;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
completedAt: string | null;
|
|
30
|
+
history: {
|
|
31
|
+
step: string | null;
|
|
32
|
+
event: string;
|
|
33
|
+
actorName: string | null;
|
|
34
|
+
detail: {
|
|
35
|
+
[key: string]: JsonValue;
|
|
36
|
+
};
|
|
37
|
+
at: string;
|
|
38
|
+
}[];
|
|
39
|
+
/** Open approval assigned to the viewer for this run, if any. */
|
|
40
|
+
myApproval: {
|
|
41
|
+
assignmentId: number;
|
|
42
|
+
title: string;
|
|
43
|
+
/** Set at a decision step: its outcomes in declaration order (#42). */
|
|
44
|
+
decision?: WorkflowDecisionForm;
|
|
45
|
+
} | null;
|
|
46
|
+
};
|
|
47
|
+
/** What an approver needs to take a decision at a decision step. */
|
|
48
|
+
export type WorkflowDecisionForm = {
|
|
49
|
+
outcomes: {
|
|
50
|
+
key: string;
|
|
51
|
+
label: string;
|
|
52
|
+
comment: 'optional' | 'required';
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
export type WorkflowOptions = {
|
|
56
|
+
post?: HttpPoster;
|
|
57
|
+
/** Test hook simulating a crash after a step's effect and before its commit. */
|
|
58
|
+
beforeCommit?: (run: {
|
|
59
|
+
id: string;
|
|
60
|
+
step: string;
|
|
61
|
+
}) => void | Promise<void>;
|
|
62
|
+
now?: () => Date;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Durable workflow engine over workflow_runs. Every step runs inside the run's
|
|
66
|
+
* row lock (FOR UPDATE); its database effects and the transition commit together,
|
|
67
|
+
* so a crash repeats at most the step's external calls (HTTP carries a stable
|
|
68
|
+
* Idempotency-Key). Failed steps retry with growing delays up to maxAttempts.
|
|
69
|
+
*/
|
|
70
|
+
export declare class WorkflowEngine {
|
|
71
|
+
#private;
|
|
72
|
+
private db;
|
|
73
|
+
private registry;
|
|
74
|
+
private resources;
|
|
75
|
+
private actors;
|
|
76
|
+
private assignments;
|
|
77
|
+
private options;
|
|
78
|
+
constructor(db: Knex, registry: ResourceRegistry, resources: ResourceService, actors: {
|
|
79
|
+
load(id: number): Promise<Actor>;
|
|
80
|
+
}, assignments: Assignments, definitions: readonly WorkflowDefinition[], options?: WorkflowOptions);
|
|
81
|
+
register(definition: WorkflowDefinition): this;
|
|
82
|
+
/** The newest version of each workflow attached to a resource. */
|
|
83
|
+
private latestFor;
|
|
84
|
+
private definition;
|
|
85
|
+
/** Listeners: submitted documents start their workflow; cancelled ones stop it. */
|
|
86
|
+
listeners(): Listener[];
|
|
87
|
+
/** Claims the submission envelope written in the submit transaction. */
|
|
88
|
+
start(trx: Knex.Transaction, event: DomainEvent): Promise<void>;
|
|
89
|
+
/** Worker step: advances due runs, each under its own row lock. */
|
|
90
|
+
tick(limit?: number): Promise<number>;
|
|
91
|
+
/**
|
|
92
|
+
* Runs steps until the run waits, ends, fails or is scheduled later. The caller
|
|
93
|
+
* holds the row lock. Each step executes in a savepoint so a failing step leaves
|
|
94
|
+
* no partial effects behind.
|
|
95
|
+
*/
|
|
96
|
+
private advance;
|
|
97
|
+
private execute;
|
|
98
|
+
/**
|
|
99
|
+
* Records an approver's decision. Only an open approval assigned to the actor
|
|
100
|
+
* counts, and the run is locked so concurrent decisions cannot both apply.
|
|
101
|
+
*/
|
|
102
|
+
decide(runId: string, actor: Actor, decision: string, comment?: unknown): Promise<WorkflowRun>;
|
|
103
|
+
/** Administrators put a failed run back to its failed step. */
|
|
104
|
+
retry(runId: string, actorId: number): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Explicit version migration for runs that have not finished: `mapStep` returns
|
|
107
|
+
* the equivalent step name in the new version.
|
|
108
|
+
*/
|
|
109
|
+
migrateRuns(name: string, from: number, to: number, mapStep: (step: string) => string): Promise<number>;
|
|
110
|
+
runsFor(name: string, id: number, actor: Actor): Promise<WorkflowRun[]>;
|
|
111
|
+
run(runId: string, actor: Actor): Promise<WorkflowRun>;
|
|
112
|
+
/** Runs waiting for the actor's decision, newest first, on records they can still read. */
|
|
113
|
+
/**
|
|
114
|
+
* Runs waiting for this user's decision, newest first (at most 100). `runIds` narrows the
|
|
115
|
+
* inbox to those runs, for pages that list their own rows (My tasks).
|
|
116
|
+
*/
|
|
117
|
+
inbox(actor: Actor, options?: {
|
|
118
|
+
runIds?: readonly string[];
|
|
119
|
+
}): Promise<WorkflowRun[]>;
|
|
120
|
+
failed(limit?: number): Promise<WorkflowRun[]>;
|
|
121
|
+
private present;
|
|
122
|
+
/** The outcomes an approver can choose at a decision step. */
|
|
123
|
+
private decisionForm;
|
|
124
|
+
private fail;
|
|
125
|
+
private cancelRuns;
|
|
126
|
+
private context;
|
|
127
|
+
/** Workflow writes are system writes attributed to the submitter, with history. */
|
|
128
|
+
private updateRecord;
|
|
129
|
+
private cancelDocument;
|
|
130
|
+
private recipients;
|
|
131
|
+
/** A role reference is its stable key; for compatibility a display name also matches. */
|
|
132
|
+
private roleId;
|
|
133
|
+
private canView;
|
|
134
|
+
private log;
|
|
135
|
+
private now;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Listeners resolved per call, so hosts can build the engine lazily (for example
|
|
139
|
+
* per request) while the listener list is fixed at boot from the registry.
|
|
140
|
+
*/
|
|
141
|
+
export declare function workflowListeners(registry: ResourceRegistry, engine: () => WorkflowEngine): Listener[];
|