@adula/kit 1.0.0 → 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/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +10 -5
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- 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.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/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 +14 -5
- package/build/index.js +6 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +15 -2
- package/build/src/admin/controller.d.ts +1 -0
- package/build/src/admin/controller.js +46 -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 +163 -2
- package/build/src/admin/resource_service.js +552 -43
- 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 +54 -3
- package/build/src/collaboration/assignments.js +125 -11
- package/build/src/collaboration/record_collaboration.js +5 -17
- package/build/src/commands/capabilities.js +22 -3
- 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 +4 -1
- package/build/src/core/message_templates.js +7 -2
- 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 +27 -0
- package/build/src/database/schema.js +98 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/record_mutation.d.ts +4 -0
- package/build/src/events/record_mutation.js +2 -0
- package/build/src/integrations/imports.js +4 -2
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.js +69 -2
- package/build/src/integrations/print.js +1 -0
- package/build/src/resource/define_resource.js +12 -0
- package/build/src/resource/registry.d.ts +1 -0
- package/build/src/resource/registry.js +33 -0
- package/build/src/resource/types.d.ts +64 -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 +30 -1
- package/build/src/workflows/define_workflow.js +44 -4
- package/build/src/workflows/engine.d.ts +24 -2
- package/build/src/workflows/engine.js +89 -24
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { KitError } from '../admin/errors.js';
|
|
|
2
2
|
import { notifyWithTemplate } from '../core/message_templates.js';
|
|
3
3
|
const TITLE_LIMIT = 200;
|
|
4
4
|
const NOTE_LIMIT = 2000;
|
|
5
|
+
const REASON_LIMIT = 500;
|
|
5
6
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
7
|
function dateOnly(value) {
|
|
7
8
|
if (value === null || value === undefined)
|
|
@@ -19,10 +20,12 @@ export class Assignments {
|
|
|
19
20
|
db;
|
|
20
21
|
resources;
|
|
21
22
|
actors;
|
|
22
|
-
|
|
23
|
+
options;
|
|
24
|
+
constructor(db, resources, actors, options = {}) {
|
|
23
25
|
this.db = db;
|
|
24
26
|
this.resources = resources;
|
|
25
27
|
this.actors = actors;
|
|
28
|
+
this.options = options;
|
|
26
29
|
}
|
|
27
30
|
async forRecord(name, id, actor) {
|
|
28
31
|
await this.resources.access(name, id, actor);
|
|
@@ -30,7 +33,7 @@ export class Assignments {
|
|
|
30
33
|
.where({ 'a.resource': name, 'a.record_id': id })
|
|
31
34
|
.orderByRaw("(a.status = 'open') DESC, a.id DESC")
|
|
32
35
|
.limit(100);
|
|
33
|
-
return rows.map((row) => this.present(row, actor));
|
|
36
|
+
return this.titled(rows.map((row) => this.present(row, actor)), actor);
|
|
34
37
|
}
|
|
35
38
|
async assign(name, id, actor, input) {
|
|
36
39
|
await this.resources.access(name, id, actor, 'update');
|
|
@@ -86,6 +89,7 @@ export class Assignments {
|
|
|
86
89
|
due_on: input.dueOn ?? null,
|
|
87
90
|
workflow_run_id: input.workflowRunId ?? null,
|
|
88
91
|
workflow_step: input.workflowStep ?? null,
|
|
92
|
+
managed: input.managed ?? false,
|
|
89
93
|
})
|
|
90
94
|
.returning('id');
|
|
91
95
|
await notifyWithTemplate(trx, input.assigneeId, input.kind === 'approval' ? 'assignment.approval' : 'assignment.created', {
|
|
@@ -93,7 +97,7 @@ export class Assignments {
|
|
|
93
97
|
resource: this.label(input.resource),
|
|
94
98
|
id: input.recordId,
|
|
95
99
|
due: input.dueOn ?? '',
|
|
96
|
-
});
|
|
100
|
+
}, undefined, { resource: input.resource, recordId: input.recordId });
|
|
97
101
|
return this.query(trx).where('a.id', row.id).first();
|
|
98
102
|
};
|
|
99
103
|
return 'isTransaction' in db && db.isTransaction ? insert(db) : this.db.transaction(insert);
|
|
@@ -107,6 +111,10 @@ export class Assignments {
|
|
|
107
111
|
query.where('a.status', 'open');
|
|
108
112
|
if (status === 'done')
|
|
109
113
|
query.whereNot('a.status', 'open');
|
|
114
|
+
if (options.kind === 'approval')
|
|
115
|
+
query.whereNotNull('a.workflow_run_id');
|
|
116
|
+
if (options.kind === 'task')
|
|
117
|
+
query.whereNull('a.workflow_run_id');
|
|
110
118
|
let last = null;
|
|
111
119
|
if (options.cursor !== undefined && options.cursor !== '') {
|
|
112
120
|
last = Number(options.cursor);
|
|
@@ -135,20 +143,29 @@ export class Assignments {
|
|
|
135
143
|
data.push(this.present(row, actor));
|
|
136
144
|
}
|
|
137
145
|
}
|
|
138
|
-
const [{ count }] = await this.db('assignments')
|
|
146
|
+
const [{ count, approvals }] = await this.db('assignments')
|
|
139
147
|
.where({ assignee_id: actor.id, status: 'open' })
|
|
140
|
-
.count(
|
|
148
|
+
.select(this.db.raw('count(*) as count'), this.db.raw('count(workflow_run_id) as approvals'));
|
|
141
149
|
return {
|
|
142
|
-
data,
|
|
150
|
+
data: await this.titled(data, actor),
|
|
143
151
|
nextCursor: more && last !== null ? String(last) : null,
|
|
144
152
|
open: Number(count),
|
|
153
|
+
approvals: Number(approvals),
|
|
145
154
|
};
|
|
146
155
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The assignee marks the task done, or the assigner cancels it, with a closing note. The
|
|
158
|
+
* note is required when the application's policy says so and for managed tasks.
|
|
159
|
+
*/
|
|
160
|
+
async complete(assignmentId, actor, outcome = 'done', input = {}) {
|
|
149
161
|
const id = Number(assignmentId);
|
|
150
162
|
if (!Number.isSafeInteger(id) || id <= 0)
|
|
151
163
|
throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
|
|
164
|
+
if (input.note !== undefined && input.note !== null && typeof input.note !== 'string')
|
|
165
|
+
throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'ملاحظة الإغلاق غير صالحة');
|
|
166
|
+
const note = typeof input.note === 'string' ? input.note.trim() : '';
|
|
167
|
+
if (note.length > REASON_LIMIT)
|
|
168
|
+
throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'ملاحظة الإغلاق لا تتجاوز 500 حرف');
|
|
152
169
|
return this.db.transaction(async (trx) => {
|
|
153
170
|
const row = await trx('assignments').where('id', id).forUpdate().first();
|
|
154
171
|
if (!row)
|
|
@@ -164,19 +181,90 @@ export class Assignments {
|
|
|
164
181
|
throw new KitError(403, 'E_FORBIDDEN', 'يلغي المهمة من أسندها فقط');
|
|
165
182
|
if (row.status !== 'open')
|
|
166
183
|
throw new KitError(409, 'E_ASSIGNMENT_CLOSED', 'المهمة مغلقة بالفعل');
|
|
184
|
+
if (!note && this.closeNote(row) === 'required')
|
|
185
|
+
throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'اكتب ملاحظة الإغلاق قبل إغلاق المهمة');
|
|
167
186
|
await trx('assignments')
|
|
168
187
|
.where('id', id)
|
|
169
|
-
.update({
|
|
188
|
+
.update({
|
|
189
|
+
status: outcome,
|
|
190
|
+
completed_at: trx.fn.now(),
|
|
191
|
+
completed_by: actor.id,
|
|
192
|
+
close_reason: note || null,
|
|
193
|
+
});
|
|
194
|
+
// A managed task closed by hand leaves the note in the record's history.
|
|
195
|
+
if (row.managed)
|
|
196
|
+
await trx('activities').insert({
|
|
197
|
+
resource: row.resource,
|
|
198
|
+
record_id: row.record_id,
|
|
199
|
+
actor_id: actor.id,
|
|
200
|
+
action: 'assignment_closed',
|
|
201
|
+
changes: JSON.stringify({
|
|
202
|
+
fields: [],
|
|
203
|
+
assignmentId: id,
|
|
204
|
+
outcome,
|
|
205
|
+
reason: note,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
170
208
|
const notify = outcome === 'done' ? row.assigned_by : row.assignee_id;
|
|
171
209
|
if (notify && notify !== actor.id)
|
|
172
|
-
await notifyWithTemplate(trx, notify, outcome === 'done' ? 'assignment.done' : 'assignment.cancelled', { title: row.title, resource: this.label(row.resource), id: row.record_id });
|
|
210
|
+
await notifyWithTemplate(trx, notify, outcome === 'done' ? 'assignment.done' : 'assignment.cancelled', { title: row.title, resource: this.label(row.resource), id: row.record_id }, undefined, { resource: String(row.resource), recordId: Number(row.record_id) });
|
|
173
211
|
});
|
|
174
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Closes the open managed tasks of a record when module code decides that its work is
|
|
215
|
+
* finished (or no longer needed), for example from a listener on the record's final
|
|
216
|
+
* state. Recorded in the record's activity log with the reason; the assigner (done) or
|
|
217
|
+
* the assignee (cancelled) is notified as for a manual close. Returns the closed count.
|
|
218
|
+
*/
|
|
219
|
+
async close(resource, recordId, options) {
|
|
220
|
+
const outcome = options.outcome ?? 'done';
|
|
221
|
+
if (outcome !== 'done' && outcome !== 'cancelled')
|
|
222
|
+
throw new KitError(422, 'E_ASSIGNMENT_OUTCOME', 'Unsupported assignment outcome');
|
|
223
|
+
if (!Number.isSafeInteger(options.actorId) || options.actorId <= 0)
|
|
224
|
+
throw new KitError(422, 'E_ACTOR', 'Closing tasks requires the author user id');
|
|
225
|
+
const reason = options.reason?.trim() || null;
|
|
226
|
+
if (reason && reason.length > REASON_LIMIT)
|
|
227
|
+
throw new KitError(422, 'E_ASSIGNMENT_REASON', 'سبب الإغلاق لا يتجاوز 500 حرف');
|
|
228
|
+
const run = async (trx) => {
|
|
229
|
+
const rows = await trx('assignments')
|
|
230
|
+
.where({ resource, record_id: recordId, status: 'open', managed: true })
|
|
231
|
+
.whereNull('workflow_run_id')
|
|
232
|
+
.forUpdate()
|
|
233
|
+
.orderBy('id');
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
await trx('assignments').where('id', row.id).update({
|
|
236
|
+
status: outcome,
|
|
237
|
+
completed_at: trx.fn.now(),
|
|
238
|
+
completed_by: options.actorId,
|
|
239
|
+
close_reason: reason,
|
|
240
|
+
});
|
|
241
|
+
await trx('activities').insert({
|
|
242
|
+
resource,
|
|
243
|
+
record_id: recordId,
|
|
244
|
+
actor_id: options.actorId,
|
|
245
|
+
action: 'assignment_closed',
|
|
246
|
+
changes: JSON.stringify({
|
|
247
|
+
fields: [],
|
|
248
|
+
system: true,
|
|
249
|
+
assignmentId: Number(row.id),
|
|
250
|
+
outcome,
|
|
251
|
+
...(reason ? { reason } : {}),
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
const notify = outcome === 'done' ? row.assigned_by : row.assignee_id;
|
|
255
|
+
if (notify && notify !== options.actorId)
|
|
256
|
+
await notifyWithTemplate(trx, notify, outcome === 'done' ? 'assignment.done' : 'assignment.cancelled', { title: row.title, resource: this.label(row.resource), id: row.record_id }, undefined, { resource: String(row.resource), recordId: Number(row.record_id) });
|
|
257
|
+
}
|
|
258
|
+
return rows.length;
|
|
259
|
+
};
|
|
260
|
+
return options.trx ? run(options.trx) : this.db.transaction(run);
|
|
261
|
+
}
|
|
175
262
|
query(db = this.db) {
|
|
176
263
|
return db('assignments as a')
|
|
177
264
|
.leftJoin('users as u', 'u.id', 'a.assignee_id')
|
|
178
265
|
.leftJoin('users as b', 'b.id', 'a.assigned_by')
|
|
179
|
-
.
|
|
266
|
+
.leftJoin('workflow_runs as wr', 'wr.id', 'a.workflow_run_id')
|
|
267
|
+
.select('a.*', 'u.full_name as assignee_name', 'b.full_name as assigned_by_name', 'wr.status as run_status', 'wr.current_step as run_step');
|
|
180
268
|
}
|
|
181
269
|
present(row, actor) {
|
|
182
270
|
const open = row.status === 'open';
|
|
@@ -185,6 +273,7 @@ export class Assignments {
|
|
|
185
273
|
resource: String(row.resource),
|
|
186
274
|
resourceLabel: this.label(row.resource),
|
|
187
275
|
recordId: Number(row.record_id),
|
|
276
|
+
recordTitle: null,
|
|
188
277
|
assigneeId: Number(row.assignee_id),
|
|
189
278
|
assigneeName: row.assignee_name ? String(row.assignee_name) : null,
|
|
190
279
|
assignedBy: row.assigned_by === null ? null : Number(row.assigned_by),
|
|
@@ -198,10 +287,35 @@ export class Assignments {
|
|
|
198
287
|
createdAt: new Date(row.created_at).toISOString(),
|
|
199
288
|
completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
|
|
200
289
|
workflowRunId: row.workflow_run_id ? String(row.workflow_run_id) : null,
|
|
290
|
+
managed: Boolean(row.managed),
|
|
291
|
+
closeNote: this.closeNote(row),
|
|
292
|
+
closeReason: row.close_reason ? String(row.close_reason) : null,
|
|
201
293
|
canComplete: open && !row.workflow_run_id && Number(row.assignee_id) === actor.id,
|
|
202
294
|
canCancel: open && !row.workflow_run_id && Number(row.assigned_by) === actor.id,
|
|
295
|
+
// The same checks WorkflowEngine.decide() applies before accepting a decision.
|
|
296
|
+
canDecide: open &&
|
|
297
|
+
Boolean(row.workflow_run_id) &&
|
|
298
|
+
row.run_status === 'waiting' &&
|
|
299
|
+
row.run_step === row.workflow_step &&
|
|
300
|
+
Number(row.assignee_id) === actor.id,
|
|
203
301
|
};
|
|
204
302
|
}
|
|
303
|
+
/** Adds record titles in one read per resource, under the viewer's field access. */
|
|
304
|
+
async titled(list, actor) {
|
|
305
|
+
const byResource = new Map();
|
|
306
|
+
for (const item of list)
|
|
307
|
+
byResource.set(item.resource, [...(byResource.get(item.resource) ?? []), item.recordId]);
|
|
308
|
+
for (const [resource, ids] of byResource) {
|
|
309
|
+
const titles = await this.resources.titles(resource, ids, actor);
|
|
310
|
+
for (const item of list)
|
|
311
|
+
if (item.resource === resource)
|
|
312
|
+
item.recordTitle = titles.get(item.recordId) ?? null;
|
|
313
|
+
}
|
|
314
|
+
return list;
|
|
315
|
+
}
|
|
316
|
+
closeNote(row) {
|
|
317
|
+
return row.managed || this.options.closeNote === 'required' ? 'required' : 'optional';
|
|
318
|
+
}
|
|
205
319
|
label(name) {
|
|
206
320
|
try {
|
|
207
321
|
return this.resources.label(name);
|
|
@@ -126,12 +126,7 @@ export class RecordCollaboration {
|
|
|
126
126
|
const authorName = author?.full_name ? String(author.full_name) : `مستخدم #${actor.id}`;
|
|
127
127
|
const label = this.label(name);
|
|
128
128
|
for (const userId of mentioned)
|
|
129
|
-
await notifyWithTemplate(trx, userId, 'comment.mentioned', {
|
|
130
|
-
author: authorName,
|
|
131
|
-
resource: label,
|
|
132
|
-
id,
|
|
133
|
-
excerpt: body.slice(0, 200),
|
|
134
|
-
});
|
|
129
|
+
await notifyWithTemplate(trx, userId, 'comment.mentioned', { author: authorName, resource: label, id, excerpt: body.slice(0, 200) }, undefined, { resource: name, recordId: id });
|
|
135
130
|
const followers = await trx('followers')
|
|
136
131
|
.where({ resource: name, record_id: id })
|
|
137
132
|
.whereNot('user_id', actor.id)
|
|
@@ -140,12 +135,7 @@ export class RecordCollaboration {
|
|
|
140
135
|
for (const userId of followers) {
|
|
141
136
|
if (!(await this.canView(name, id, Number(userId))))
|
|
142
137
|
continue;
|
|
143
|
-
await notifyWithTemplate(trx, Number(userId), 'comment.created', {
|
|
144
|
-
author: authorName,
|
|
145
|
-
resource: label,
|
|
146
|
-
id,
|
|
147
|
-
excerpt: body.slice(0, 200),
|
|
148
|
-
});
|
|
138
|
+
await notifyWithTemplate(trx, Number(userId), 'comment.created', { author: authorName, resource: label, id, excerpt: body.slice(0, 200) }, undefined, { resource: name, recordId: id });
|
|
149
139
|
}
|
|
150
140
|
const names = mentioned.length
|
|
151
141
|
? await trx('users').whereIn('id', mentioned).select('id', 'full_name')
|
|
@@ -302,11 +292,9 @@ export class RecordCollaboration {
|
|
|
302
292
|
for (const userId of followers) {
|
|
303
293
|
if (event !== 'deleted' && !(await this.canView(resource, id, Number(userId))))
|
|
304
294
|
continue;
|
|
305
|
-
await notifyWithTemplate(trx, Number(userId), 'record.changed', {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
id,
|
|
309
|
-
});
|
|
295
|
+
await notifyWithTemplate(trx, Number(userId), 'record.changed', { change: verbs[event] ?? 'تحديث', resource: this.label(resource), id }, undefined,
|
|
296
|
+
// A deleted record has no page to open.
|
|
297
|
+
event === 'deleted' ? null : { resource, recordId: id });
|
|
310
298
|
}
|
|
311
299
|
},
|
|
312
300
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { KIT_VERSION } from '../version.js';
|
|
2
|
+
import { WORKFLOW_NAME_RULE } from '../workflows/define_workflow.js';
|
|
3
|
+
import { ACTOR_ID } from '../auth/conditions.js';
|
|
2
4
|
/** Field kinds accepted by defineResource, with their storage. */
|
|
3
5
|
export const FIELD_TYPES = {
|
|
4
6
|
string: 'varchar',
|
|
@@ -11,6 +13,7 @@ export const FIELD_TYPES = {
|
|
|
11
13
|
json: 'jsonb',
|
|
12
14
|
attachment: 'attachments row id; per-field accept/maxSize',
|
|
13
15
|
belongsTo: 'foreign key, preloaded, restrict on delete',
|
|
16
|
+
user: 'users foreign key, restrict on delete; choices are active members of the record unit or its ancestors; related as { id, fullName }',
|
|
14
17
|
hasMany: 'child resource; inline rows saved with the parent',
|
|
15
18
|
lookup: 'lookups group key',
|
|
16
19
|
};
|
|
@@ -26,6 +29,8 @@ export const FIELD_OPTIONS = [
|
|
|
26
29
|
];
|
|
27
30
|
export const RESOURCE_OPTIONS = [
|
|
28
31
|
'scoped (required)',
|
|
32
|
+
'scope.from (the unit follows a required belongsTo parent)',
|
|
33
|
+
'title (fields that name the record in relations, pickers, tasks and approvals)',
|
|
29
34
|
'submittable (docStatus, submit/cancel/amend-by-copy)',
|
|
30
35
|
'version (optimistic locking, default with submittable)',
|
|
31
36
|
'customFields',
|
|
@@ -34,7 +39,16 @@ export const RESOURCE_OPTIONS = [
|
|
|
34
39
|
'hooks.beforeSave / hooks.afterSave',
|
|
35
40
|
];
|
|
36
41
|
export const CONDITION_OPERATORS = ['$eq', '$ne', '$in', '$lt', '$gt', '$like'];
|
|
37
|
-
export const WORKFLOW_STEPS = [
|
|
42
|
+
export const WORKFLOW_STEPS = [
|
|
43
|
+
'condition',
|
|
44
|
+
'update',
|
|
45
|
+
'notify',
|
|
46
|
+
'approval',
|
|
47
|
+
'decision',
|
|
48
|
+
'delay',
|
|
49
|
+
'http',
|
|
50
|
+
'end',
|
|
51
|
+
];
|
|
38
52
|
/**
|
|
39
53
|
* Kit services by concern. Every name is a public export of @adula/kit; a test
|
|
40
54
|
* checks this list against the package entry so the catalog cannot drift.
|
|
@@ -69,6 +83,7 @@ export const SERVICES = {
|
|
|
69
83
|
'Integration': [
|
|
70
84
|
'Webhooks',
|
|
71
85
|
'signWebhook',
|
|
86
|
+
'InboundWebhooks',
|
|
72
87
|
'openApiDocument',
|
|
73
88
|
'ImportBatches',
|
|
74
89
|
'renderPrintHtml',
|
|
@@ -90,11 +105,15 @@ export const SERVICES = {
|
|
|
90
105
|
};
|
|
91
106
|
export const EXTENSION_POINTS = [
|
|
92
107
|
'Resource hooks (beforeSave, afterSave) inside the save transaction',
|
|
108
|
+
'ResourceService.systemSave for module-decided writes (validator, hooks and audit, no role rules); ResourceService.rehome after a parent moves',
|
|
109
|
+
'Assignment closing notes (Assignments closeNote: optional or required); managed assignments (managed: true) closed by module code with Assignments.close',
|
|
93
110
|
'Page override: inertia/pages/<resource>/{index,form,show}.tsx replaces the generated page',
|
|
111
|
+
'Create links with defaults: /resources/<resource>/create?defaults[field]=value',
|
|
94
112
|
'Domain events <module>.<resource>.{created,updated,deleted,submitted,cancelled,amended} with idempotent listeners',
|
|
95
113
|
'Module workflows (Module.workflows) with versioned definitions',
|
|
96
114
|
'Message templates edited per deployment',
|
|
97
115
|
'Outgoing webhooks and the bearer-token /api/v1 API',
|
|
116
|
+
'Signed inbound webhooks raising inbound.<source>.<event> through the outbox',
|
|
98
117
|
];
|
|
99
118
|
export const OUTSIDE_THE_KIT = [
|
|
100
119
|
'Dynamic fields or a field editor screen',
|
|
@@ -120,9 +139,9 @@ export function capabilityCatalog(options = {}) {
|
|
|
120
139
|
'',
|
|
121
140
|
`Resource options: ${RESOURCE_OPTIONS.join(', ')}.`,
|
|
122
141
|
'',
|
|
123
|
-
`Role rule conditions: ${CONDITION_OPERATORS.join(', ')} on scalar fields; unsupported conditions are refused. Organization scope is always added with AND.`,
|
|
142
|
+
`Role rule conditions: ${CONDITION_OPERATORS.join(', ')} on scalar fields; unsupported conditions are refused. Organization scope is always added with AND. \`${ACTOR_ID}\` names the signed-in user on user fields, createdBy and updatedBy ($eq, $ne, $in), bound per request.`,
|
|
124
143
|
'',
|
|
125
|
-
`Workflow steps: ${WORKFLOW_STEPS.join(', ')}.`,
|
|
144
|
+
`Workflow steps: ${WORKFLOW_STEPS.join(', ')}. Workflow and step names: ${WORKFLOW_NAME_RULE} (for example release_approval, notify_approved).`,
|
|
126
145
|
'',
|
|
127
146
|
'## Services',
|
|
128
147
|
'',
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { Settings } from '../services/settings.js';
|
|
2
|
+
import type { Field, Resource } from '../resource/types.js';
|
|
3
|
+
import type { WorkflowDefinition } from '../workflows/define_workflow.js';
|
|
4
|
+
import type { RuntimeHealth } from '../core/health.js';
|
|
2
5
|
export type Finding = {
|
|
3
6
|
check: string;
|
|
4
7
|
status: 'pass' | 'warn' | 'fail' | 'info';
|
|
@@ -9,3 +12,31 @@ export declare function diagnoseUploads(root: string): Promise<Finding>;
|
|
|
9
12
|
export declare function diagnoseUi(root: string): Promise<Finding[]>;
|
|
10
13
|
export declare function diagnoseAgentSkills(root: string): Promise<Finding>;
|
|
11
14
|
export declare function diagnose(root: string, settings: Settings, production: boolean, env: Record<string, string | undefined>, now?: number): Promise<Finding[]>;
|
|
15
|
+
type TableShape = Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields'> & {
|
|
16
|
+
fields: Record<string, Pick<Field, 'column' | 'required' | 'sequence' | 'unique' | 'searchable'> & {
|
|
17
|
+
type: string;
|
|
18
|
+
resource?: string;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* A generated create-migration embeds the definition as it was at scaffold time. While it is
|
|
23
|
+
* still pending, compare that snapshot with the current resource so the table matches (#20).
|
|
24
|
+
*/
|
|
25
|
+
export declare function diagnoseResourceSnapshots(pending: {
|
|
26
|
+
file: string;
|
|
27
|
+
source: string;
|
|
28
|
+
}[], resource: (name: string) => TableShape | undefined): Finding;
|
|
29
|
+
/**
|
|
30
|
+
* Workflow recipients address roles by their stable key, or for compatibility by
|
|
31
|
+
* their editable display name. List references that match no role, and those that
|
|
32
|
+
* only match a display name, which a rename in the roles screen would break (#25).
|
|
33
|
+
*/
|
|
34
|
+
export declare function diagnoseWorkflowRoles(workflows: readonly Pick<WorkflowDefinition, 'name' | 'version' | 'steps'>[], roles: readonly (string | {
|
|
35
|
+
key: string | null;
|
|
36
|
+
name: string;
|
|
37
|
+
})[]): Finding;
|
|
38
|
+
/** Events wait in the outbox until a worker publishes them; a stale backlog means none runs (#50). */
|
|
39
|
+
export declare function diagnoseOutbox(health: Pick<RuntimeHealth, 'outbox'> & {
|
|
40
|
+
heartbeats: Pick<RuntimeHealth['heartbeats'], 'worker'>;
|
|
41
|
+
}): Finding;
|
|
42
|
+
export {};
|
|
@@ -2,9 +2,12 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
|
|
2
2
|
import { isAbsolute, join, relative } from 'node:path';
|
|
3
3
|
import { KIT_VERSION } from '../version.js';
|
|
4
4
|
import { agentAssets, managedRules, digest } from './agent_assets.js';
|
|
5
|
+
import { columnName } from '../resource/define_resource.js';
|
|
5
6
|
// The deployment volume and backup scripts share this application-relative path.
|
|
6
7
|
const UPLOADS_PATH = 'storage/uploads';
|
|
7
8
|
const UPLOADS_WARNING_BYTES = 5_000_000_000;
|
|
9
|
+
/** An event the worker has not published within this age suggests that no worker runs. */
|
|
10
|
+
const OUTBOX_STALE_MS = 60_000;
|
|
8
11
|
async function assertContained(root, path) {
|
|
9
12
|
const resolved = relative(await realpath(root), await realpath(path));
|
|
10
13
|
if (resolved === '..' ||
|
|
@@ -259,3 +262,114 @@ export async function diagnose(root, settings, production, env, now = Date.now()
|
|
|
259
262
|
findings.push(...(await diagnoseUi(root)));
|
|
260
263
|
return findings;
|
|
261
264
|
}
|
|
265
|
+
/** The columns and indexes createResourceTable derives from a definition, one entry each. */
|
|
266
|
+
function tableShape(resource) {
|
|
267
|
+
const shape = new Map();
|
|
268
|
+
for (const flag of ['scoped', 'version', 'submittable', 'customFields'])
|
|
269
|
+
if (resource[flag])
|
|
270
|
+
shape.set(flag, flag);
|
|
271
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
272
|
+
if (field.type === 'hasMany')
|
|
273
|
+
continue;
|
|
274
|
+
const column = field.column ?? columnName(key);
|
|
275
|
+
const traits = [
|
|
276
|
+
field.type === 'belongsTo' ? `belongsTo ${field.resource}` : field.type,
|
|
277
|
+
...(field.required || field.sequence ? ['required'] : []),
|
|
278
|
+
...(field.unique ? ['unique'] : []),
|
|
279
|
+
...(field.searchable ? ['searchable'] : []),
|
|
280
|
+
];
|
|
281
|
+
shape.set(column, `${column} (${traits.join(', ')})`);
|
|
282
|
+
}
|
|
283
|
+
return shape;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* A generated create-migration embeds the definition as it was at scaffold time. While it is
|
|
287
|
+
* still pending, compare that snapshot with the current resource so the table matches (#20).
|
|
288
|
+
*/
|
|
289
|
+
export function diagnoseResourceSnapshots(pending, resource) {
|
|
290
|
+
const drift = [];
|
|
291
|
+
for (const { file, source } of pending) {
|
|
292
|
+
const embedded = /createResourceTable\([^,]+,\s*(\{[\s\S]*\})\s*\)\s*\}/.exec(source)?.[1];
|
|
293
|
+
if (!embedded)
|
|
294
|
+
continue;
|
|
295
|
+
let snapshot;
|
|
296
|
+
try {
|
|
297
|
+
snapshot = JSON.parse(embedded);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const current = resource(snapshot.name);
|
|
303
|
+
if (!current)
|
|
304
|
+
continue;
|
|
305
|
+
const [before, after] = [tableShape(snapshot), tableShape(current)];
|
|
306
|
+
const changed = [...new Set([...before.keys(), ...after.keys()])]
|
|
307
|
+
.filter((key) => before.get(key) !== after.get(key))
|
|
308
|
+
.map((key) => after.get(key) ?? `without ${before.get(key)}`);
|
|
309
|
+
if (changed.length)
|
|
310
|
+
drift.push(`${file} (${snapshot.name}): ${changed.join('; ')}`);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
check: 'resources.snapshots',
|
|
314
|
+
status: drift.length ? 'warn' : 'pass',
|
|
315
|
+
message: drift.length
|
|
316
|
+
? `Pending create-migrations differ from their resource definitions; run node ace adula:resource:snapshot <name> (or update the embedded definition) before migration:run: ${drift.join(' | ')}`
|
|
317
|
+
: 'Pending resource migrations match their definitions',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Workflow recipients address roles by their stable key, or for compatibility by
|
|
322
|
+
* their editable display name. List references that match no role, and those that
|
|
323
|
+
* only match a display name, which a rename in the roles screen would break (#25).
|
|
324
|
+
*/
|
|
325
|
+
export function diagnoseWorkflowRoles(workflows, roles) {
|
|
326
|
+
const keys = new Set(roles.flatMap((role) => (typeof role === 'string' || !role.key ? [] : [role.key])));
|
|
327
|
+
const names = new Set(roles.map((role) => (typeof role === 'string' ? role : role.name)));
|
|
328
|
+
const missing = [];
|
|
329
|
+
const byName = [];
|
|
330
|
+
for (const workflow of workflows)
|
|
331
|
+
for (const [key, step] of Object.entries(workflow.steps)) {
|
|
332
|
+
const to = step.type === 'approval' ? step.assignees : step.type === 'notify' ? step.to : null;
|
|
333
|
+
if (!to || typeof to !== 'object' || !('role' in to) || keys.has(to.role))
|
|
334
|
+
continue;
|
|
335
|
+
const reference = `${workflow.name}@${workflow.version}.${key} → "${to.role}"`;
|
|
336
|
+
if (names.has(to.role))
|
|
337
|
+
byName.push(reference);
|
|
338
|
+
else
|
|
339
|
+
missing.push(reference);
|
|
340
|
+
}
|
|
341
|
+
// Plain name lists (older hosts) cannot tell keys from names; only missing roles matter.
|
|
342
|
+
const keyed = roles.some((role) => typeof role !== 'string');
|
|
343
|
+
const messages = [
|
|
344
|
+
...(missing.length
|
|
345
|
+
? [
|
|
346
|
+
`Workflow steps address roles that match no role key or name; approvals there fail: ${missing.join(', ')}`,
|
|
347
|
+
]
|
|
348
|
+
: []),
|
|
349
|
+
...(keyed && byName.length
|
|
350
|
+
? [
|
|
351
|
+
`Workflow steps address roles by display name; give each role a key and address it by key so renaming cannot detach approvers: ${byName.join(', ')}`,
|
|
352
|
+
]
|
|
353
|
+
: []),
|
|
354
|
+
];
|
|
355
|
+
return {
|
|
356
|
+
check: 'workflows.roles',
|
|
357
|
+
status: messages.length ? 'warn' : 'pass',
|
|
358
|
+
message: messages.length
|
|
359
|
+
? messages.join(' | ')
|
|
360
|
+
: 'Every role addressed by a workflow step exists',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/** Events wait in the outbox until a worker publishes them; a stale backlog means none runs (#50). */
|
|
364
|
+
export function diagnoseOutbox(health) {
|
|
365
|
+
const { backlog, oldestAgeMs } = health.outbox;
|
|
366
|
+
const worker = health.heartbeats.worker.healthy ? 'running' : 'not running (no recent heartbeat)';
|
|
367
|
+
const stale = backlog > 0 && oldestAgeMs !== null && oldestAgeMs >= OUTBOX_STALE_MS;
|
|
368
|
+
return {
|
|
369
|
+
check: 'events.outbox',
|
|
370
|
+
status: stale ? 'warn' : 'pass',
|
|
371
|
+
message: stale
|
|
372
|
+
? `${backlog} unpublished events, the oldest ${Math.round(oldestAgeMs / 1000)} s old; the worker is ${worker}. Listeners run only while \`node ace adula:worker\` runs`
|
|
373
|
+
: `No outbox event is older than ${OUTBOX_STALE_MS / 1000} s; the worker is ${worker}`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
@@ -1,8 +1,56 @@
|
|
|
1
|
+
export declare const KIT_ISSUES_URL = "https://github.com/adulash/adula-kit/issues";
|
|
2
|
+
/**
|
|
3
|
+
* KIT_GAPS.md entry fields, in the order of the kit's gap issue form
|
|
4
|
+
* (.github/ISSUE_TEMPLATE/gap.yml). `id` is the form field id used to prefill it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const GAP_FIELDS: readonly [{
|
|
7
|
+
readonly label: "Package";
|
|
8
|
+
readonly id: "package";
|
|
9
|
+
readonly required: true;
|
|
10
|
+
}, {
|
|
11
|
+
readonly label: "Needed by";
|
|
12
|
+
readonly id: "needed";
|
|
13
|
+
readonly required: true;
|
|
14
|
+
}, {
|
|
15
|
+
readonly label: "Tried";
|
|
16
|
+
readonly id: "tried";
|
|
17
|
+
readonly required: true;
|
|
18
|
+
}, {
|
|
19
|
+
readonly label: "Blocked because";
|
|
20
|
+
readonly id: "blocked";
|
|
21
|
+
readonly required: true;
|
|
22
|
+
}, {
|
|
23
|
+
readonly label: "Proposed kit change";
|
|
24
|
+
readonly id: "proposal";
|
|
25
|
+
readonly required: true;
|
|
26
|
+
}, {
|
|
27
|
+
readonly label: "Reproduction";
|
|
28
|
+
readonly id: "reproduction";
|
|
29
|
+
readonly required: true;
|
|
30
|
+
}, {
|
|
31
|
+
readonly label: "Acceptance";
|
|
32
|
+
readonly id: "acceptance";
|
|
33
|
+
readonly required: true;
|
|
34
|
+
}, {
|
|
35
|
+
readonly label: "Workaround";
|
|
36
|
+
readonly id: "workaround";
|
|
37
|
+
readonly required: false;
|
|
38
|
+
}];
|
|
39
|
+
export declare const GAPS_TEMPLATE = "# Kit gaps\n\nRecord each limitation of the kit as one entry. Describe the kit capability, not this\nproject: reproduce it on a new application from create-app. Run `node ace adula:gaps report`\nto open the kit's gap form prefilled, then write the issue number in `Issue:`.\n\n<!--\n## GAP-001 \u2014 Short title naming the kit capability\nPackage: @adula/kit 1.0.0\nNeeded by: the kit capability that is missing, stated without project details\nTried: the kit extension points tried\nBlocked because: why none of them works\nProposed kit change: the API, option or fix the kit should offer\nReproduction: steps on a new application from create-app\nAcceptance: the test that proves the gap is closed\nWorkaround: none, or what the project does meanwhile\nIssue:\n-->\n";
|
|
40
|
+
export interface GapEntry {
|
|
41
|
+
title: string;
|
|
42
|
+
fields: Record<string, string>;
|
|
43
|
+
issue: string;
|
|
44
|
+
missing: string[];
|
|
45
|
+
url?: string;
|
|
46
|
+
}
|
|
1
47
|
/**
|
|
2
48
|
* Prepares KIT_GAPS.md for sharing with the kit maintainers: e-mails, URLs and
|
|
3
|
-
* IPv4 addresses are masked,
|
|
49
|
+
* IPv4 addresses are masked, gap titles are listed for a quick review, and each
|
|
50
|
+
* unreported gap gets a link that opens the kit's gap form prefilled. Nothing is sent.
|
|
4
51
|
*/
|
|
5
|
-
export declare function gapReport(content: string): {
|
|
52
|
+
export declare function gapReport(content: string, issuesUrl?: string): {
|
|
6
53
|
masked: string;
|
|
7
54
|
titles: string[];
|
|
55
|
+
gaps: GapEntry[];
|
|
8
56
|
};
|