@ultimat3/admin 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/src/index.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // Explicit exports only — a barrel that re-exports everything is how internal helpers become
3
3
  // someone's dependency.
4
4
 
5
+ export { AdminActionForm, type AdminActionFormProps } from './action-form';
5
6
  export {
7
+ ACTION_NOT_APPLICABLE_REASON,
6
8
  type ActionGateInput,
7
9
  type AdminActionButton,
8
10
  actionButtons,
@@ -13,7 +15,30 @@ export {
13
15
  invokeAdminAction,
14
16
  permissionsForAction,
15
17
  } from './action-gate';
16
- export { AdminActions, type AdminActionsProps } from './actions';
18
+ export {
19
+ ACTION_INPUT_PREFIX,
20
+ type ActionInputControl,
21
+ type ActionInputField,
22
+ actionInputFields,
23
+ decodeActionInput,
24
+ } from './action-input';
25
+ export { invokeRowAction, type RowActionInput } from './action-row';
26
+ export {
27
+ ACTION_OPERATION,
28
+ ACTION_PARAM,
29
+ AdminActions,
30
+ type AdminActionsProps,
31
+ actionFormHref,
32
+ BATCH_OPERATION,
33
+ DELETE_OPERATION,
34
+ OPERATION_FIELD,
35
+ } from './actions';
36
+ export {
37
+ type AdminRequestActor,
38
+ ANONYMOUS_ADMIN_ACTOR,
39
+ adminActorFrom,
40
+ requestActor,
41
+ } from './actor';
17
42
  export {
18
43
  type AdminApp,
19
44
  type AdminAuth,
@@ -36,19 +61,30 @@ export {
36
61
  runAiPane,
37
62
  } from './ai-panes';
38
63
  export {
64
+ AUDIT_PAGE_DEFAULT,
65
+ AUDIT_PAGE_MAX,
66
+ type AuditCursor,
39
67
  type AuditDraft,
40
68
  type AuditEntry,
41
69
  type AuditFieldDiff,
42
70
  type AuditLog,
43
71
  type AuditLogOptions,
44
72
  type AuditOutcome,
73
+ type AuditQuery,
45
74
  type AuditSink,
75
+ auditCursorOf,
46
76
  auditEntry,
47
77
  deniedDraft,
48
78
  diffRows,
49
79
  memoryAuditLog,
50
80
  REDACTED,
51
81
  } from './audit';
82
+ export {
83
+ type PostgresAuditLogOptions,
84
+ postgresAuditLog,
85
+ SQL_ADMIN_AUDIT_INSERT,
86
+ } from './audit-pg';
87
+ export { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from './audit-schema';
52
88
  export {
53
89
  type AdminActor,
54
90
  type AdminAuthz,
@@ -64,6 +100,32 @@ export {
64
100
  staticAuthz,
65
101
  } from './authz';
66
102
  export {
103
+ type AdminBatchAnswer,
104
+ type AdminBatchInput,
105
+ type AdminBatchResult,
106
+ BATCH_NOT_OFFERED_REASON,
107
+ type BatchEnqueue,
108
+ type BatchRow,
109
+ type BatchRowOutcome,
110
+ type BatchSelection,
111
+ batchConfirmationToken,
112
+ batchPlan,
113
+ MAX_BATCH_QUEUED_ROWS,
114
+ MAX_BATCH_ROWS,
115
+ runAdminBatch,
116
+ } from './batch';
117
+ export {
118
+ AdminBatchBar,
119
+ type AdminBatchBarProps,
120
+ } from './batch-bar';
121
+ export {
122
+ ADMIN_BATCH_JOB,
123
+ batchEnqueue,
124
+ } from './batch-job';
125
+ export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
126
+ export { BATCH_QUEUED_REASON } from './batch-queue';
127
+ export {
128
+ type AdminUpdateOptions,
67
129
  adminCreate,
68
130
  adminDestroy,
69
131
  adminDetail,
@@ -76,11 +138,25 @@ export {
76
138
  type ListResult,
77
139
  permissionsForOperation,
78
140
  } from './crud';
79
- export { AdminDetail, type AdminDetailProps } from './detail';
141
+ export {
142
+ type AdminActionDescription,
143
+ type AdminDescription,
144
+ type AdminResourceDescription,
145
+ type AdminRouteDescription,
146
+ type AdminScopeDescription,
147
+ type AdminSectionDescription,
148
+ describeAdmin,
149
+ } from './describe';
150
+ export {
151
+ AdminDetail,
152
+ type AdminDetailProps,
153
+ HISTORY_PARAM,
154
+ } from './detail';
80
155
  export {
81
156
  type AdminColumnFacts,
82
157
  type AdminColumnReference,
83
158
  adminColumnsOf,
159
+ adminSealedColumnsOf,
84
160
  } from './entity-columns';
85
161
  // The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
86
162
  // host that only mounts the dev panels never loads a production admin component.
@@ -88,13 +164,17 @@ export {
88
164
  ADMIN_ERROR_CODES,
89
165
  ADMIN_ERROR_TITLES,
90
166
  AdminActionDuplicateError,
167
+ AdminActionNotApplicableError,
91
168
  AdminEntityUnknownError,
92
169
  type AdminErrorCode,
93
170
  type AdminErrorParts,
94
171
  AdminFieldUnsupportedError,
172
+ AdminFilterInvalidError,
173
+ AdminMountMissingError,
95
174
  AdminPagePathInvalidError,
96
175
  AdminPageUnguardedError,
97
176
  AdminPolicyMissingError,
177
+ AdminRepoUnboundError,
98
178
  adminErrorFrom,
99
179
  DevDashboardInProdError,
100
180
  DevSourceUnavailableError,
@@ -102,18 +182,53 @@ export {
102
182
  export {
103
183
  type AdminField,
104
184
  type AdminFieldType,
185
+ type AdminFilterKind,
105
186
  type AdminWidget,
106
187
  fieldTypeFromColumn,
107
188
  filterable,
189
+ filterKindFor,
108
190
  listable,
109
191
  searchable,
110
192
  sortable,
111
193
  WIDGET_BY_FIELD_TYPE,
112
194
  widgetFor,
113
195
  } from './fields';
114
- export { AdminForm, type AdminFormProps } from './form';
115
- export { AdminLayout, type AdminLayoutProps } from './layout';
196
+ export { AdminForm, type AdminFormProps, issueText } from './form';
197
+ export { currencyFieldOf, decodeForm, posted } from './form-decode';
198
+ export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
199
+ export { JOB_ENTITY } from './jobs/job-entities';
200
+ export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
201
+ export { AdminLayout, type AdminLayoutProps, actorLabel } from './layout';
116
202
  export { AdminList, type AdminListProps } from './list';
203
+ export { type ResourceColumnsInput, resourceColumns } from './list-columns';
204
+ export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
205
+ export {
206
+ type AskedFilter,
207
+ checkedFilter,
208
+ declaredFilters,
209
+ FILTER_PREFIX,
210
+ filterOpsOf,
211
+ filterParam,
212
+ knownFilterParams,
213
+ } from './list-filters';
214
+ export {
215
+ CURSOR_PARAM,
216
+ type ListLocation,
217
+ listHref,
218
+ pageRequestOf,
219
+ SCOPE_PARAM,
220
+ SORT_PARAM,
221
+ } from './list-request';
222
+ export {
223
+ type AdminListRequest,
224
+ findRow,
225
+ type ListWhere,
226
+ listWhere,
227
+ rowWhere,
228
+ scopeCounts,
229
+ scopeNamed,
230
+ } from './list-scope';
231
+ export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
117
232
  export {
118
233
  type AdminMcpOptions,
119
234
  type AdminToolResult,
@@ -129,8 +244,15 @@ export {
129
244
  adminToolCatalog,
130
245
  adminToolDecisions,
131
246
  } from './mcp-tools';
247
+ export {
248
+ ADMIN_MOUNTS,
249
+ adminMountAt,
250
+ adminMounts,
251
+ clearAdminMounts,
252
+ registerAdminMount,
253
+ } from './mounts';
132
254
  export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
133
- export { AdminPageDenied, guardedPage } from './page-guard';
255
+ export { AdminPageDenied, auditRefusal } from './page-guard';
134
256
  export {
135
257
  type AdminCustomPage,
136
258
  type AdminPageComponent,
@@ -168,48 +290,115 @@ export {
168
290
  isDestructive,
169
291
  ruleFor,
170
292
  } from './permissions';
171
- export { adminPermissions, type PolicyAuthzInput, policyAuthz } from './policy-bridge';
293
+ export {
294
+ declareAdminPermissions,
295
+ type PolicyAuthzInput,
296
+ policyAuthz,
297
+ roleAuthz,
298
+ singlePolicyAuthz,
299
+ } from './policy-bridge';
172
300
  export {
173
301
  type AdminAction,
174
302
  type AdminActionCtx,
303
+ type AdminBatchOptions,
175
304
  type AdminColumn,
176
305
  type AdminColumnDescription,
177
306
  type AdminColumnMeta,
307
+ type AdminDb,
178
308
  type AdminEntity,
179
309
  type AdminEntityDescription,
180
310
  type AdminFilter,
181
311
  type AdminJobSummary,
182
312
  type AdminListQuery,
313
+ type AdminMatchingResult,
183
314
  type AdminRepo,
184
315
  type AdminRow,
185
316
  type AdminSort,
317
+ type AdminTable,
318
+ type AdminTableRead,
319
+ computedRow,
320
+ FILTER_OPS,
186
321
  type FilterOp,
187
322
  type KeysetBound,
188
323
  type RegisteredEntity,
189
- type RegisteredRepo,
324
+ type RegisteredTable,
190
325
  readField,
191
326
  rowId,
192
327
  } from './registry';
193
328
  export {
194
- type AdminFieldOverride,
329
+ type AdminRelated,
330
+ type AdminRelatedList,
331
+ RELATED_PAGE_SIZE,
332
+ relatedLists,
333
+ relatedOf,
334
+ } from './related';
335
+ export {
336
+ type AdminLookupRequest,
337
+ type AdminLookupResult,
338
+ type AdminOption,
339
+ adminLookup,
340
+ LOOKUP_SELECT_MAX,
341
+ labelOf,
342
+ type RelationData,
343
+ type RelationNeed,
344
+ relationNeeds,
345
+ relationsFor,
346
+ } from './relations';
347
+ export { ComputedCell, TRUNCATE_AT } from './renderers';
348
+ export { adminEntitiesOf, adminRepoFor, adminTablesOf } from './repo-entity';
349
+ export {
195
350
  type AdminResource,
196
351
  type AdminResourceOptions,
197
352
  adminResource,
198
353
  repoOf,
199
354
  resourceFor,
200
355
  } from './resource';
356
+ export type { AdminFieldOverride } from './resource-fields';
357
+ export {
358
+ type AdminFormGroupOptions,
359
+ type AdminSection,
360
+ type AdminSectionOptions,
361
+ DEFAULT_SECTION_KEY,
362
+ } from './resource-layout';
363
+ export {
364
+ ADMIN_RENDERERS,
365
+ type AdminColumnComponent,
366
+ type AdminColumnRenderProps,
367
+ type AdminComputedColumn,
368
+ type AdminComputedColumnOptions,
369
+ type AdminRenderer,
370
+ type AdminRowScope,
371
+ type AdminScope,
372
+ type AdminScopeOptions,
373
+ type AdminScopeWhere,
374
+ LOOKUP_SEGMENT,
375
+ } from './resource-list';
201
376
  export {
202
377
  type AdminRouteConfig,
378
+ type AdminRouteMatch,
203
379
  adminRouteConfig,
204
380
  adminRouteFor,
381
+ adminRouteMatch,
205
382
  adminRoutes,
206
383
  } from './routes';
384
+ export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
385
+ export { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
386
+ export {
387
+ type AdminRouteRequest,
388
+ type AdminRouteResponse,
389
+ type AdminScreen,
390
+ guardedScreen,
391
+ lookupHref,
392
+ widgetContextOf,
393
+ } from './screen-frame';
394
+ export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home';
207
395
  export {
208
396
  type AdminSearchHit,
209
397
  type AdminSearchInput,
210
398
  type AdminSearchResult,
211
399
  adminSearch,
212
400
  } from './search';
401
+ export { type AdminTestCtxInput, adminTestCtx } from './test-ctx';
213
402
  export {
214
403
  type AdminBranding,
215
404
  adminBranding,
@@ -223,9 +412,10 @@ export { type ValidationIssue, type ValidationResult, validateInput } from './va
223
412
  export {
224
413
  assertMoney,
225
414
  assertZone,
415
+ type GuardedField,
226
416
  type SelectOption,
227
417
  type WidgetContext,
228
418
  type WidgetProps,
229
419
  widgetProps,
230
420
  } from './widget-value';
231
- export { Widget, type WidgetInput } from './widgets';
421
+ export { optionLabel, Widget, type WidgetInput } from './widgets';
@@ -0,0 +1,155 @@
1
+ // The queues, the tasks and the workers as `AdminRepo`s. Each is a SMALL set the store answers
2
+ // whole — one `stats()`, one registry read — so a page is that set, filtered by the package's one
3
+ // predicate evaluator (`outsideRowScope`), ordered and cut by keyset here. Read-only.
4
+
5
+ import { assert } from '@ultimat3/core';
6
+ import { inspectManifest, nextTaskRun, registeredJobs } from '@ultimat3/jobs';
7
+ import type { AdminFilter, AdminListQuery, AdminRepo, AdminRow, KeysetBound } from '../registry';
8
+ import { outsideRowScope } from '../row-scope-write';
9
+ import { jobsOperator } from './operator';
10
+
11
+ type Load = () => Promise<readonly AdminRow[]>;
12
+
13
+ const comparable = (value: unknown): string | number => {
14
+ if (value instanceof Date) return value.getTime();
15
+ if (typeof value === 'number') return value;
16
+ if (typeof value === 'boolean') return value ? 1 : 0;
17
+ return String(value);
18
+ };
19
+
20
+ const absent = (value: unknown): boolean => value === null || value === undefined;
21
+
22
+ /**
23
+ * Code-unit order, never a collation: the same set reads the same way on every machine. NULL is
24
+ * the LARGEST value — last ascending, first descending — as the database orders a nullable sort
25
+ * key and as `repo-entity.ts` seeks one; it read as `''` here, which sorted it as a zero.
26
+ */
27
+ const order = (a: unknown, b: unknown): number => {
28
+ if (absent(a) || absent(b)) return absent(a) && absent(b) ? 0 : absent(a) ? 1 : -1;
29
+ const x = comparable(a);
30
+ const y = comparable(b);
31
+ return x < y ? -1 : x > y ? 1 : 0;
32
+ };
33
+
34
+ /**
35
+ * A cursor carries its value as text (`pagination.ts`), or `null` for a row that held none; read
36
+ * back as the type of the row it is compared with. A `null` bound stays `null` — `new Date(null)`
37
+ * is the epoch and `new Date('')` is NaN, and either made the page after a NULL row the wrong one.
38
+ */
39
+ const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
40
+ if (bound.value === null) return null;
41
+ const held = row[field];
42
+ if (held instanceof Date) return new Date(bound.value);
43
+ if (typeof held === 'number') return Number(bound.value);
44
+ if (typeof held === 'boolean') return bound.value === 'true';
45
+ return bound.value;
46
+ };
47
+
48
+ export function fleetRepo(idField: string, load: Load): AdminRepo<AdminRow> {
49
+ const matching = async (where: readonly AdminFilter[] | undefined): Promise<AdminRow[]> =>
50
+ (await load()).filter((row) => outsideRowScope(row, where ?? []) === null);
51
+
52
+ /** `(sort value, id)` against a bound, in the query's own direction. */
53
+ const versus = (row: AdminRow, bound: KeysetBound, query: AdminListQuery): number => {
54
+ const sign = query.sort.direction === 'asc' ? 1 : -1;
55
+ const field = query.sort.field;
56
+ const primary = order(row[field], boundValue(row, bound, field));
57
+ return sign * (primary !== 0 ? primary : order(row[idField], bound.id));
58
+ };
59
+
60
+ const readOnly = async (): Promise<never> => {
61
+ assert(
62
+ false,
63
+ 'a queue, task or worker row was written through the admin repo, and none offers a write',
64
+ 'change it through its actions: admin.action.job.queue.pause, admin.action.job.task.run-now, …',
65
+ );
66
+ };
67
+
68
+ return {
69
+ async list(query) {
70
+ const sign = query.sort.direction === 'asc' ? 1 : -1;
71
+ const rows = (await matching(query.where)).sort(
72
+ (a, b) =>
73
+ sign * (order(a[query.sort.field], b[query.sort.field]) || order(a[idField], b[idField])),
74
+ );
75
+ const { after, before } = query;
76
+ if (before !== undefined) {
77
+ const earlier = rows.filter((row) => versus(row, before, query) < 0);
78
+ return earlier.slice(Math.max(0, earlier.length - query.limit));
79
+ }
80
+ const later =
81
+ after === undefined ? rows : rows.filter((row) => versus(row, after, query) > 0);
82
+ return later.slice(0, query.limit);
83
+ },
84
+ async find(id) {
85
+ return (await load()).find((row) => row[idField] === id) ?? null;
86
+ },
87
+ async count(where) {
88
+ return (await matching(where)).length;
89
+ },
90
+ create: readOnly,
91
+ update: readOnly,
92
+ destroy: readOnly,
93
+ };
94
+ }
95
+
96
+ const at = (ms: number | undefined): Date | null => (ms === undefined ? null : new Date(ms));
97
+
98
+ /** Every queue with rows, every paused one and every one a registered job names. */
99
+ export const queueRepo = fleetRepo('name', async () => {
100
+ const { driver, introspect } = jobsOperator();
101
+ const [stats, paused] = await Promise.all([driver.stats(), introspect.pausedQueues()]);
102
+ const pausedAt = new Map(paused.map((entry) => [entry.name, entry.pausedAt]));
103
+ const names = new Set([
104
+ ...stats.map((queue) => queue.queue),
105
+ ...pausedAt.keys(),
106
+ ...registeredJobs().map((handle) => handle.queue),
107
+ ]);
108
+ return [...names].map((name) => {
109
+ const queue = stats.find((one) => one.queue === name);
110
+ return {
111
+ name,
112
+ ready: queue?.ready ?? 0,
113
+ delayed: queue?.delayed ?? 0,
114
+ running: queue?.running ?? 0,
115
+ suspended: queue?.suspended ?? 0,
116
+ failed: queue?.failed ?? 0,
117
+ dead: queue?.dead ?? 0,
118
+ oldestReadyMs: queue?.oldestReadyMs ?? 0,
119
+ paused: pausedAt.has(name),
120
+ pausedAt: at(pausedAt.get(name)),
121
+ };
122
+ });
123
+ });
124
+
125
+ /** Every registered task: its schedule, its zone, the last occurrence that fired and the next. */
126
+ export const taskRepo = fleetRepo('name', async () => {
127
+ const { introspect } = jobsOperator();
128
+ const [paused, fires] = await Promise.all([introspect.pausedTasks(), introspect.taskFires()]);
129
+ const pausedNames = new Set(paused.map((entry) => entry.name));
130
+ const now = new Date();
131
+ return inspectManifest().tasks.map((task) => ({
132
+ name: task.name,
133
+ cron: task.cron,
134
+ tz: task.tz,
135
+ catchUp: task.catchUp,
136
+ enqueues: task.enqueues.join(', '),
137
+ lastFireAt: at(fires.find((fire) => fire.task === task.name)?.firedAt),
138
+ nextFireAt: nextTaskRun(task, now),
139
+ paused: pausedNames.has(task.name),
140
+ }));
141
+ });
142
+
143
+ /** Every worker whose heartbeat has not expired. */
144
+ export const workerRepo = fleetRepo('id', async () => {
145
+ const { introspect } = jobsOperator();
146
+ return (await introspect.workers()).map((worker) => ({
147
+ id: worker.id,
148
+ host: worker.host,
149
+ queues: worker.queues.join(', '),
150
+ concurrency: worker.concurrency,
151
+ inFlight: worker.inFlight.length,
152
+ startedAt: new Date(worker.startedAt),
153
+ heartbeatAt: new Date(worker.heartbeatAt),
154
+ }));
155
+ });
@@ -0,0 +1,172 @@
1
+ // Every change an operator makes to the queue, as an `AdminAction`: the button on a row, the batch
2
+ // bar's "checked" and "all matching", the MCP tool and the audit entry come from these declarations
3
+ // and from nothing jobs-specific. One permission gates them all — `job:manage`; reading is
4
+ // `job:read`, so an operator without the first sees every screen and no control.
5
+
6
+ import {
7
+ cancelJob,
8
+ getTask,
9
+ type JobRecord,
10
+ pauseQueue,
11
+ promoteJob,
12
+ removeJob,
13
+ resumeQueue,
14
+ retryFromStep,
15
+ } from '@ultimat3/jobs';
16
+ import { t } from '@ultimat3/schema';
17
+ import { AdminFilterInvalidError } from '../errors';
18
+ import type { AdminAction, AdminFilter, AdminMatchingResult, AdminRow } from '../registry';
19
+ import { JOB_ENTITY } from './job-entities';
20
+ import { bulkWhere, JOBS_RESOURCE } from './job-where';
21
+ import { jobsOperator } from './operator';
22
+
23
+ /** Reading the jobs screens. The admin's own `<noun>:read`, for every jobs resource. */
24
+ export const JOB_READ = 'job:read';
25
+ /** Every action on the queue. Without it the screens render no control and refuse the call. */
26
+ export const JOB_MANAGE = 'job:manage';
27
+
28
+ const LIVE: ReadonlySet<unknown> = new Set(['ready', 'delayed', 'running', 'suspended']);
29
+
30
+ const idOf = (input: Readonly<Record<string, unknown>>): string => String(input['id'] ?? '');
31
+
32
+ const waitingOnClock = (row: AdminRow): boolean => {
33
+ const runAt = row['runAt'];
34
+ return (
35
+ (row['state'] === 'ready' || row['state'] === 'delayed') &&
36
+ runAt instanceof Date &&
37
+ runAt.getTime() > Date.now()
38
+ );
39
+ };
40
+
41
+ /** A bulk verb over the list's `where`, held to the one state the action is for. */
42
+ function bulk(
43
+ where: readonly AdminFilter[],
44
+ states: readonly JobRecord['state'][],
45
+ run: (filter: NonNullable<ReturnType<typeof bulkWhere>>) => Promise<AdminMatchingResult>,
46
+ ): Promise<AdminMatchingResult> {
47
+ const filter = bulkWhere(where);
48
+ if (filter === null) return Promise.resolve({ affected: 0, remaining: 0 });
49
+ if (!new Set<string>(states).has(filter.state)) {
50
+ throw new AdminFilterInvalidError({
51
+ entity: JOBS_RESOURCE,
52
+ asked: `all matching in scope=${filter.state}`,
53
+ cause: 'is not a state this action applies to',
54
+ known: states.map((state) => `scope=${state}`),
55
+ });
56
+ }
57
+ return run(filter);
58
+ }
59
+
60
+ const jobAction = (action: Omit<AdminAction, 'permission' | 'entity'>): AdminAction => ({
61
+ ...action,
62
+ permission: JOB_MANAGE,
63
+ entity: JOB_ENTITY.jobs,
64
+ });
65
+
66
+ export const jobActions: readonly AdminAction[] = [
67
+ jobAction({
68
+ name: 'job.retry',
69
+ // A dead letter, and only one: a finished `done` row run again is a second side effect.
70
+ when: (row) => row['state'] === 'dead',
71
+ batch: true,
72
+ matching: ({ where }) =>
73
+ bulk(where, ['dead'], (filter) => jobsOperator().introspect.requeueMany(filter)),
74
+ async handle({ input }) {
75
+ return { id: (await jobsOperator().introspect.requeue(idOf(input))).id };
76
+ },
77
+ }),
78
+ jobAction({
79
+ name: 'job.retry-from-step',
80
+ when: (row) => row['state'] === 'dead' || row['state'] === 'failed',
81
+ input: t.object({ step: t.string.min(1) }),
82
+ async handle({ input }) {
83
+ const step = String(input['step'] ?? '');
84
+ return { id: (await retryFromStep(jobsOperator().driver, idOf(input), step))?.id ?? null };
85
+ },
86
+ }),
87
+ jobAction({
88
+ name: 'job.run-now',
89
+ when: waitingOnClock,
90
+ batch: true,
91
+ matching: ({ where }) =>
92
+ bulk(where, ['delayed', 'ready'], (filter) => jobsOperator().introspect.promoteMany(filter)),
93
+ async handle({ input }) {
94
+ return { id: (await promoteJob(jobsOperator().driver, idOf(input))).id };
95
+ },
96
+ }),
97
+ jobAction({
98
+ name: 'job.cancel',
99
+ when: (row) => LIVE.has(row['state']),
100
+ async handle({ input }) {
101
+ return { id: (await cancelJob(jobsOperator().driver, idOf(input)))?.id ?? null };
102
+ },
103
+ }),
104
+ jobAction({
105
+ name: 'job.remove',
106
+ destructive: true,
107
+ // A running job is a worker's: cancel it first, as the store itself insists.
108
+ when: (row) => row['state'] !== 'running',
109
+ batch: true,
110
+ matching: ({ where }) =>
111
+ bulk(
112
+ where,
113
+ ['ready', 'delayed', 'suspended', 'done', 'failed', 'dead', 'cancelled'],
114
+ (filter) => jobsOperator().introspect.removeMany(filter),
115
+ ),
116
+ async handle({ input }) {
117
+ return { id: (await removeJob(jobsOperator().driver, idOf(input)))?.id ?? null };
118
+ },
119
+ }),
120
+ ];
121
+
122
+ const fleetAction = (
123
+ entity: string,
124
+ action: Omit<AdminAction, 'permission' | 'entity'>,
125
+ ): AdminAction => ({ ...action, permission: JOB_MANAGE, entity });
126
+
127
+ export const queueActions: readonly AdminAction[] = [
128
+ fleetAction(JOB_ENTITY.queues, {
129
+ name: 'job.queue.pause',
130
+ when: (row) => row['paused'] !== true,
131
+ async handle({ input }) {
132
+ await pauseQueue(jobsOperator().driver, idOf(input));
133
+ return { queue: idOf(input), paused: true };
134
+ },
135
+ }),
136
+ fleetAction(JOB_ENTITY.queues, {
137
+ name: 'job.queue.resume',
138
+ when: (row) => row['paused'] === true,
139
+ async handle({ input }) {
140
+ await resumeQueue(jobsOperator().driver, idOf(input));
141
+ return { queue: idOf(input), paused: false };
142
+ },
143
+ }),
144
+ ];
145
+
146
+ export const taskActions: readonly AdminAction[] = [
147
+ fleetAction(JOB_ENTITY.tasks, {
148
+ name: 'job.task.pause',
149
+ when: (row) => row['paused'] !== true,
150
+ async handle({ input }) {
151
+ await jobsOperator().introspect.pauseTask(idOf(input));
152
+ return { task: idOf(input), paused: true };
153
+ },
154
+ }),
155
+ fleetAction(JOB_ENTITY.tasks, {
156
+ name: 'job.task.resume',
157
+ when: (row) => row['paused'] === true,
158
+ async handle({ input }) {
159
+ await jobsOperator().introspect.resumeTask(idOf(input));
160
+ return { task: idOf(input), paused: false };
161
+ },
162
+ }),
163
+ fleetAction(JOB_ENTITY.tasks, {
164
+ name: 'job.task.run-now',
165
+ async handle({ input }) {
166
+ // The row came from the registry, so a task that is gone is a task renamed since the page.
167
+ const handle = getTask(idOf(input));
168
+ const jobs = handle === undefined ? [] : await handle.enqueue();
169
+ return { task: idOf(input), jobs: jobs.length };
170
+ },
171
+ }),
172
+ ];