@ultimat3/admin 22.15.0 → 23.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 (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  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 +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
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,
@@ -63,6 +99,31 @@ export {
63
99
  isAllowed,
64
100
  staticAuthz,
65
101
  } from './authz';
102
+ export {
103
+ type AdminBatchAnswer,
104
+ type AdminBatchInput,
105
+ type AdminBatchResult,
106
+ BATCH_NOT_OFFERED_REASON,
107
+ BATCH_QUEUED_REASON,
108
+ type BatchEnqueue,
109
+ type BatchRow,
110
+ type BatchRowOutcome,
111
+ type BatchSelection,
112
+ batchConfirmationToken,
113
+ batchPlan,
114
+ MAX_BATCH_QUEUED_ROWS,
115
+ MAX_BATCH_ROWS,
116
+ runAdminBatch,
117
+ } from './batch';
118
+ export {
119
+ AdminBatchBar,
120
+ type AdminBatchBarProps,
121
+ } from './batch-bar';
122
+ export {
123
+ ADMIN_BATCH_JOB,
124
+ batchEnqueue,
125
+ } from './batch-job';
126
+ export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
66
127
  export {
67
128
  adminCreate,
68
129
  adminDestroy,
@@ -76,11 +137,25 @@ export {
76
137
  type ListResult,
77
138
  permissionsForOperation,
78
139
  } from './crud';
79
- export { AdminDetail, type AdminDetailProps } from './detail';
140
+ export {
141
+ type AdminActionDescription,
142
+ type AdminDescription,
143
+ type AdminResourceDescription,
144
+ type AdminRouteDescription,
145
+ type AdminScopeDescription,
146
+ type AdminSectionDescription,
147
+ describeAdmin,
148
+ } from './describe';
149
+ export {
150
+ AdminDetail,
151
+ type AdminDetailProps,
152
+ HISTORY_PARAM,
153
+ } from './detail';
80
154
  export {
81
155
  type AdminColumnFacts,
82
156
  type AdminColumnReference,
83
157
  adminColumnsOf,
158
+ adminSealedColumnsOf,
84
159
  } from './entity-columns';
85
160
  // The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
86
161
  // host that only mounts the dev panels never loads a production admin component.
@@ -88,13 +163,17 @@ export {
88
163
  ADMIN_ERROR_CODES,
89
164
  ADMIN_ERROR_TITLES,
90
165
  AdminActionDuplicateError,
166
+ AdminActionNotApplicableError,
91
167
  AdminEntityUnknownError,
92
168
  type AdminErrorCode,
93
169
  type AdminErrorParts,
94
170
  AdminFieldUnsupportedError,
171
+ AdminFilterInvalidError,
172
+ AdminMountMissingError,
95
173
  AdminPagePathInvalidError,
96
174
  AdminPageUnguardedError,
97
175
  AdminPolicyMissingError,
176
+ AdminRepoUnboundError,
98
177
  adminErrorFrom,
99
178
  DevDashboardInProdError,
100
179
  DevSourceUnavailableError,
@@ -102,18 +181,53 @@ export {
102
181
  export {
103
182
  type AdminField,
104
183
  type AdminFieldType,
184
+ type AdminFilterKind,
105
185
  type AdminWidget,
106
186
  fieldTypeFromColumn,
107
187
  filterable,
188
+ filterKindFor,
108
189
  listable,
109
190
  searchable,
110
191
  sortable,
111
192
  WIDGET_BY_FIELD_TYPE,
112
193
  widgetFor,
113
194
  } from './fields';
114
- export { AdminForm, type AdminFormProps } from './form';
115
- export { AdminLayout, type AdminLayoutProps } from './layout';
195
+ export { AdminForm, type AdminFormProps, issueText } from './form';
196
+ export { currencyFieldOf, decodeForm, posted } from './form-decode';
197
+ export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
198
+ export { JOB_ENTITY } from './jobs/job-entities';
199
+ export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
200
+ export { AdminLayout, type AdminLayoutProps, actorLabel } from './layout';
116
201
  export { AdminList, type AdminListProps } from './list';
202
+ export { type ResourceColumnsInput, resourceColumns } from './list-columns';
203
+ export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
204
+ export {
205
+ type AskedFilter,
206
+ checkedFilter,
207
+ declaredFilters,
208
+ FILTER_PREFIX,
209
+ filterOpsOf,
210
+ filterParam,
211
+ knownFilterParams,
212
+ } from './list-filters';
213
+ export {
214
+ CURSOR_PARAM,
215
+ type ListLocation,
216
+ listHref,
217
+ pageRequestOf,
218
+ SCOPE_PARAM,
219
+ SORT_PARAM,
220
+ } from './list-request';
221
+ export {
222
+ type AdminListRequest,
223
+ findRow,
224
+ type ListWhere,
225
+ listWhere,
226
+ rowWhere,
227
+ scopeCounts,
228
+ scopeNamed,
229
+ } from './list-scope';
230
+ export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
117
231
  export {
118
232
  type AdminMcpOptions,
119
233
  type AdminToolResult,
@@ -129,8 +243,15 @@ export {
129
243
  adminToolCatalog,
130
244
  adminToolDecisions,
131
245
  } from './mcp-tools';
246
+ export {
247
+ ADMIN_MOUNTS,
248
+ adminMountAt,
249
+ adminMounts,
250
+ clearAdminMounts,
251
+ registerAdminMount,
252
+ } from './mounts';
132
253
  export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
133
- export { AdminPageDenied, guardedPage } from './page-guard';
254
+ export { AdminPageDenied, auditRefusal } from './page-guard';
134
255
  export {
135
256
  type AdminCustomPage,
136
257
  type AdminPageComponent,
@@ -168,48 +289,115 @@ export {
168
289
  isDestructive,
169
290
  ruleFor,
170
291
  } from './permissions';
171
- export { adminPermissions, type PolicyAuthzInput, policyAuthz } from './policy-bridge';
292
+ export {
293
+ adminPermissions,
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 {
386
+ type AdminRouteRequest,
387
+ type AdminRouteResponse,
388
+ type AdminScreen,
389
+ guardedScreen,
390
+ lookupHref,
391
+ widgetContextOf,
392
+ } from './screen-frame';
393
+ export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home';
207
394
  export {
208
395
  type AdminSearchHit,
209
396
  type AdminSearchInput,
210
397
  type AdminSearchResult,
211
398
  adminSearch,
212
399
  } from './search';
400
+ export { type AdminTestCtxInput, adminTestCtx } from './test-ctx';
213
401
  export {
214
402
  type AdminBranding,
215
403
  adminBranding,
@@ -223,9 +411,10 @@ export { type ValidationIssue, type ValidationResult, validateInput } from './va
223
411
  export {
224
412
  assertMoney,
225
413
  assertZone,
414
+ type GuardedField,
226
415
  type SelectOption,
227
416
  type WidgetContext,
228
417
  type WidgetProps,
229
418
  widgetProps,
230
419
  } from './widget-value';
231
- export { Widget, type WidgetInput } from './widgets';
420
+ export { optionLabel, Widget, type WidgetInput } from './widgets';
@@ -0,0 +1,143 @@
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 value === null || value === undefined ? '' : String(value);
18
+ };
19
+
20
+ /** Code-unit order, never a collation: the same set reads the same way on every machine. */
21
+ const order = (a: unknown, b: unknown): number => {
22
+ const x = comparable(a);
23
+ const y = comparable(b);
24
+ return x < y ? -1 : x > y ? 1 : 0;
25
+ };
26
+
27
+ /** A cursor carries its value as text (`pagination.ts`); read back as the column's own type. */
28
+ const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
29
+ const held = row[field];
30
+ if (held instanceof Date) return new Date(bound.value);
31
+ if (typeof held === 'number') return Number(bound.value);
32
+ if (typeof held === 'boolean') return bound.value === 'true';
33
+ return bound.value;
34
+ };
35
+
36
+ export function fleetRepo(idField: string, load: Load): AdminRepo<AdminRow> {
37
+ const matching = async (where: readonly AdminFilter[] | undefined): Promise<AdminRow[]> =>
38
+ (await load()).filter((row) => outsideRowScope(row, where ?? []) === null);
39
+
40
+ /** `(sort value, id)` against a bound, in the query's own direction. */
41
+ const versus = (row: AdminRow, bound: KeysetBound, query: AdminListQuery): number => {
42
+ const sign = query.sort.direction === 'asc' ? 1 : -1;
43
+ const field = query.sort.field;
44
+ const primary = order(row[field], boundValue(row, bound, field));
45
+ return sign * (primary !== 0 ? primary : order(row[idField], bound.id));
46
+ };
47
+
48
+ const readOnly = async (): Promise<never> => {
49
+ assert(
50
+ false,
51
+ 'a queue, task or worker row was written through the admin repo, and none offers a write',
52
+ 'change it through its actions: admin.action.job.queue.pause, admin.action.job.task.run-now, …',
53
+ );
54
+ };
55
+
56
+ return {
57
+ async list(query) {
58
+ const sign = query.sort.direction === 'asc' ? 1 : -1;
59
+ const rows = (await matching(query.where)).sort(
60
+ (a, b) =>
61
+ sign * (order(a[query.sort.field], b[query.sort.field]) || order(a[idField], b[idField])),
62
+ );
63
+ const { after, before } = query;
64
+ if (before !== undefined) {
65
+ const earlier = rows.filter((row) => versus(row, before, query) < 0);
66
+ return earlier.slice(Math.max(0, earlier.length - query.limit));
67
+ }
68
+ const later =
69
+ after === undefined ? rows : rows.filter((row) => versus(row, after, query) > 0);
70
+ return later.slice(0, query.limit);
71
+ },
72
+ async find(id) {
73
+ return (await load()).find((row) => row[idField] === id) ?? null;
74
+ },
75
+ async count(where) {
76
+ return (await matching(where)).length;
77
+ },
78
+ create: readOnly,
79
+ update: readOnly,
80
+ destroy: readOnly,
81
+ };
82
+ }
83
+
84
+ const at = (ms: number | undefined): Date | null => (ms === undefined ? null : new Date(ms));
85
+
86
+ /** Every queue with rows, every paused one and every one a registered job names. */
87
+ export const queueRepo = fleetRepo('name', async () => {
88
+ const { driver, introspect } = jobsOperator();
89
+ const [stats, paused] = await Promise.all([driver.stats(), introspect.pausedQueues()]);
90
+ const pausedAt = new Map(paused.map((entry) => [entry.name, entry.pausedAt]));
91
+ const names = new Set([
92
+ ...stats.map((queue) => queue.queue),
93
+ ...pausedAt.keys(),
94
+ ...registeredJobs().map((handle) => handle.queue),
95
+ ]);
96
+ return [...names].map((name) => {
97
+ const queue = stats.find((one) => one.queue === name);
98
+ return {
99
+ name,
100
+ ready: queue?.ready ?? 0,
101
+ delayed: queue?.delayed ?? 0,
102
+ running: queue?.running ?? 0,
103
+ suspended: queue?.suspended ?? 0,
104
+ failed: queue?.failed ?? 0,
105
+ dead: queue?.dead ?? 0,
106
+ oldestReadyMs: queue?.oldestReadyMs ?? 0,
107
+ paused: pausedAt.has(name),
108
+ pausedAt: at(pausedAt.get(name)),
109
+ };
110
+ });
111
+ });
112
+
113
+ /** Every registered task: its schedule, its zone, the last occurrence that fired and the next. */
114
+ export const taskRepo = fleetRepo('name', async () => {
115
+ const { introspect } = jobsOperator();
116
+ const [paused, fires] = await Promise.all([introspect.pausedTasks(), introspect.taskFires()]);
117
+ const pausedNames = new Set(paused.map((entry) => entry.name));
118
+ const now = new Date();
119
+ return inspectManifest().tasks.map((task) => ({
120
+ name: task.name,
121
+ cron: task.cron,
122
+ tz: task.tz,
123
+ catchUp: task.catchUp,
124
+ enqueues: task.enqueues.join(', '),
125
+ lastFireAt: at(fires.find((fire) => fire.task === task.name)?.firedAt),
126
+ nextFireAt: nextTaskRun(task, now),
127
+ paused: pausedNames.has(task.name),
128
+ }));
129
+ });
130
+
131
+ /** Every worker whose heartbeat has not expired. */
132
+ export const workerRepo = fleetRepo('id', async () => {
133
+ const { introspect } = jobsOperator();
134
+ return (await introspect.workers()).map((worker) => ({
135
+ id: worker.id,
136
+ host: worker.host,
137
+ queues: worker.queues.join(', '),
138
+ concurrency: worker.concurrency,
139
+ inFlight: worker.inFlight.length,
140
+ startedAt: new Date(worker.startedAt),
141
+ heartbeatAt: new Date(worker.heartbeatAt),
142
+ }));
143
+ });
@@ -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
+ ];