@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.
Files changed (87) hide show
  1. package/build/agent/AGENTS.template.md +2 -2
  2. package/build/agent/capabilities.md +10 -5
  3. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  4. package/build/commands/doctor.js +37 -1
  5. package/build/commands/gaps.js +16 -6
  6. package/build/commands/install.js +19 -5
  7. package/build/commands/main.js +2 -0
  8. package/build/commands/module_add.js +2 -2
  9. package/build/commands/resource.js +1 -1
  10. package/build/commands/resource_snapshot.d.ts +15 -0
  11. package/build/commands/resource_snapshot.js +61 -0
  12. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  13. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  14. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  15. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  16. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  17. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  18. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  19. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  20. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  21. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  22. package/build/index.d.ts +14 -5
  23. package/build/index.js +6 -2
  24. package/build/src/admin/contracts.d.ts +7 -0
  25. package/build/src/admin/contracts.js +15 -2
  26. package/build/src/admin/controller.d.ts +1 -0
  27. package/build/src/admin/controller.js +46 -1
  28. package/build/src/admin/presentation.d.ts +6 -0
  29. package/build/src/admin/record_title.d.ts +14 -0
  30. package/build/src/admin/record_title.js +50 -0
  31. package/build/src/admin/resource_service.d.ts +163 -2
  32. package/build/src/admin/resource_service.js +552 -43
  33. package/build/src/attachments/attachment_service.d.ts +12 -0
  34. package/build/src/attachments/attachment_service.js +28 -2
  35. package/build/src/attachments/upload_grants.d.ts +44 -0
  36. package/build/src/attachments/upload_grants.js +105 -0
  37. package/build/src/auth/ability.d.ts +1 -1
  38. package/build/src/auth/ability.js +4 -1
  39. package/build/src/auth/actor_store.js +6 -1
  40. package/build/src/auth/conditions.d.ts +15 -0
  41. package/build/src/auth/conditions.js +36 -0
  42. package/build/src/auth/sql.js +6 -2
  43. package/build/src/collaboration/assignments.d.ts +54 -3
  44. package/build/src/collaboration/assignments.js +125 -11
  45. package/build/src/collaboration/record_collaboration.js +5 -17
  46. package/build/src/commands/capabilities.js +22 -3
  47. package/build/src/commands/doctor.d.ts +31 -0
  48. package/build/src/commands/doctor.js +114 -0
  49. package/build/src/commands/gap_report.d.ts +50 -2
  50. package/build/src/commands/gap_report.js +102 -4
  51. package/build/src/commands/generator.js +3 -3
  52. package/build/src/commands/snapshot.d.ts +28 -0
  53. package/build/src/commands/snapshot.js +48 -0
  54. package/build/src/commands/source_markers.d.ts +10 -1
  55. package/build/src/commands/source_markers.js +36 -4
  56. package/build/src/core/administration_guard.js +3 -1
  57. package/build/src/core/message_templates.d.ts +4 -1
  58. package/build/src/core/message_templates.js +7 -2
  59. package/build/src/core/module_seed.d.ts +15 -0
  60. package/build/src/core/module_seed.js +31 -0
  61. package/build/src/core/notifications.d.ts +18 -1
  62. package/build/src/core/notifications.js +27 -1
  63. package/build/src/core/roles.d.ts +27 -1
  64. package/build/src/core/roles.js +133 -5
  65. package/build/src/database/schema.d.ts +27 -0
  66. package/build/src/database/schema.js +98 -0
  67. package/build/src/eslint/index.js +26 -0
  68. package/build/src/events/record_mutation.d.ts +4 -0
  69. package/build/src/events/record_mutation.js +2 -0
  70. package/build/src/integrations/imports.js +4 -2
  71. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  72. package/build/src/integrations/inbound_webhooks.js +276 -0
  73. package/build/src/integrations/openapi.js +69 -2
  74. package/build/src/integrations/print.js +1 -0
  75. package/build/src/resource/define_resource.js +12 -0
  76. package/build/src/resource/registry.d.ts +1 -0
  77. package/build/src/resource/registry.js +33 -0
  78. package/build/src/resource/types.d.ts +64 -1
  79. package/build/src/resource/values.js +2 -1
  80. package/build/src/services/settings.d.ts +13 -1
  81. package/build/src/services/settings.js +9 -2
  82. package/build/src/workflows/define_workflow.d.ts +30 -1
  83. package/build/src/workflows/define_workflow.js +44 -4
  84. package/build/src/workflows/engine.d.ts +24 -2
  85. package/build/src/workflows/engine.js +89 -24
  86. package/build/stubs/resource_contract.txt +103 -41
  87. 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
- constructor(db, resources, actors) {
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('* as 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
- /** The assignee marks the task done, or the assigner cancels it. */
148
- async complete(assignmentId, actor, outcome = 'done') {
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({ status: outcome, completed_at: trx.fn.now(), completed_by: actor.id });
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
- .select('a.*', 'u.full_name as assignee_name', 'b.full_name as assigned_by_name');
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
- change: verbs[event] ?? 'تحديث',
307
- resource: this.label(resource),
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 = ['condition', 'update', 'notify', 'approval', 'delay', 'http', 'end'];
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, and gap titles are listed for a quick review.
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
  };