@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.242.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 (72) hide show
  1. package/package.json +9 -9
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
  3. package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
  4. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
  5. package/src/admin-shell/changes.json +6 -0
  6. package/src/admin-shell/feature.ts +60 -6
  7. package/src/admin-shell/i18n.ts +0 -7
  8. package/src/admin-shell/web/client-plugin.tsx +1 -11
  9. package/src/agent-tools/__tests__/agent-doc-lint.test.ts +40 -0
  10. package/src/agent-tools/__tests__/agent-manifest.test.ts +36 -0
  11. package/src/agent-tools/__tests__/tool-catalog.test.ts +51 -0
  12. package/src/agent-tools/agent-doc-lint.ts +14 -3
  13. package/src/agent-tools/agent-manifest.ts +16 -3
  14. package/src/agent-tools/feature.ts +1 -1
  15. package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
  16. package/src/audit/changes.json +7 -0
  17. package/src/audit/feature.ts +57 -7
  18. package/src/audit/handlers/list.query.ts +50 -14
  19. package/src/audit/i18n.ts +3 -18
  20. package/src/audit/web/client-plugin.tsx +1 -7
  21. package/src/audit/web/index.ts +0 -2
  22. package/src/config/__tests__/config.integration.test.ts +31 -0
  23. package/src/config/handlers/readiness.query.ts +8 -1
  24. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  25. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  26. package/src/jobs/changes.json +7 -0
  27. package/src/jobs/feature.ts +101 -9
  28. package/src/jobs/handlers/list.query.ts +21 -4
  29. package/src/jobs/handlers/trigger.write.ts +16 -3
  30. package/src/jobs/i18n.ts +3 -25
  31. package/src/jobs/index.ts +1 -0
  32. package/src/jobs/web/client-plugin.tsx +1 -7
  33. package/src/jobs/web/index.ts +0 -2
  34. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  35. package/src/tenant/handlers/list.query.ts +1 -0
  36. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  37. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  38. package/src/tier-engine/changes.json +9 -1
  39. package/src/tier-engine/constants.ts +5 -5
  40. package/src/tier-engine/feature.ts +36 -10
  41. package/src/tier-engine/i18n.ts +19 -22
  42. package/src/tier-engine/web/client-plugin.tsx +5 -11
  43. package/src/tier-engine/web/index.ts +0 -1
  44. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  45. package/src/user-data-rights/changes.json +7 -0
  46. package/src/user-data-rights/constants.ts +20 -6
  47. package/src/user-data-rights/feature.ts +113 -14
  48. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  49. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  50. package/src/user-data-rights/web/i18n.ts +6 -11
  51. package/src/user-data-rights/web/index.ts +1 -1
  52. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  53. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  54. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  55. package/src/user-profile/changes.json +7 -0
  56. package/src/user-profile/constants.ts +15 -7
  57. package/src/user-profile/feature.ts +96 -9
  58. package/src/user-profile/i18n.ts +8 -12
  59. package/src/user-profile/web/client-plugin.ts +22 -5
  60. package/src/user-profile/web/index.ts +1 -1
  61. package/src/user-profile/web/profile-screen.tsx +122 -265
  62. package/src/admin-shell/web/overview-layout.tsx +0 -69
  63. package/src/admin-shell/web/overview-query.ts +0 -15
  64. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  65. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  66. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  67. package/src/audit/web/audit-log-detail-screen.tsx +0 -169
  68. package/src/audit/web/audit-log-screen.tsx +0 -290
  69. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  70. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  71. package/src/jobs/web/job-runs-screen.tsx +0 -290
  72. package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
@@ -5,6 +5,7 @@ import { createConfigFeature } from "../../config/feature";
5
5
  import { createTenantFeature } from "../../tenant/feature";
6
6
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
7
7
  import { createAuditFeature } from "../feature";
8
+ import { AUDIT_I18N } from "../i18n";
8
9
 
9
10
  describe("audit log screen + handler access alignment", () => {
10
11
  const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
@@ -13,11 +14,13 @@ describe("audit log screen + handler access alignment", () => {
13
14
  expect(() => validateBoot(features)).not.toThrow();
14
15
  });
15
16
 
16
- test("audit-log screen is custom, access.admin-gated", () => {
17
+ test("audit-log screen is declarative, access.admin-gated", () => {
17
18
  const audit = createAuditFeature();
18
19
  const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
19
- if (screen?.type !== "custom") {
20
- throw new Error(`expected a custom screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`);
20
+ if (screen?.type !== "projectionList") {
21
+ throw new Error(
22
+ `expected a projectionList screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`,
23
+ );
21
24
  }
22
25
  if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
23
26
  throw new Error(`expected role-gated access on ${AUDIT_LOG_SCREEN_ID}`);
@@ -25,12 +28,12 @@ describe("audit log screen + handler access alignment", () => {
25
28
  expect(screen.access.roles).toEqual(access.admin);
26
29
  });
27
30
 
28
- test("audit-log-detail screen is custom, admin-gated, breadcrumb-linked to list", () => {
31
+ test("audit-log-detail screen is declarative, admin-gated, breadcrumb-linked to list", () => {
29
32
  const audit = createAuditFeature();
30
33
  const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
31
- if (screen?.type !== "custom") {
34
+ if (screen?.type !== "projectionDetail") {
32
35
  throw new Error(
33
- `expected a custom screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
36
+ `expected a projectionDetail screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
34
37
  );
35
38
  }
36
39
  if (!("listScreenId" in screen)) {
@@ -43,6 +46,23 @@ describe("audit log screen + handler access alignment", () => {
43
46
  expect(screen.access.roles).toEqual(access.admin);
44
47
  });
45
48
 
49
+ test("audit-log-detail: aggregate type and aggregate id resolve to distinct labels", () => {
50
+ const audit = createAuditFeature();
51
+ const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
52
+ if (screen?.type !== "projectionDetail") {
53
+ throw new Error(
54
+ `expected a projectionDetail screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
55
+ );
56
+ }
57
+ const aggregateTypeKey = screen.fieldLabels?.["aggregateType"];
58
+ const aggregateIdKey = screen.fieldLabels?.["aggregateId"];
59
+ if (aggregateTypeKey === undefined || aggregateIdKey === undefined) {
60
+ throw new Error("expected fieldLabels for both aggregateType and aggregateId");
61
+ }
62
+ expect(aggregateTypeKey).not.toBe(aggregateIdKey);
63
+ expect(AUDIT_I18N[aggregateTypeKey]?.en).not.toBe(AUDIT_I18N[aggregateIdKey]?.en);
64
+ });
65
+
46
66
  test("audit queries use access.admin (screen ⊆ handler)", () => {
47
67
  const audit = createAuditFeature();
48
68
  expect(rolesOf(audit.queryHandlers["list"]?.access)).toEqual([...access.admin]);
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "breaking",
5
+ "title": "AuditLogScreen and AuditLogDetailScreen removed; audit-log/audit-log-detail are now declarative screens (fw#2312).",
6
+ "detail": "`audit-log` and `audit-log-detail` render through the generic renderer (`projectionList`/`projectionDetail`) instead of custom React components — the renderer selects a screen by its `screen.type`, not a client component registry, so `AuditLogScreen`/`AuditLogDetailScreen` were dead exports the moment the screen definitions switched. The `payload`/`metadata` fields now use the `format: \"json\"` field-renderer (see framework changelog) instead of a raw escaped-string dump.",
7
+ "migration": "Breaking only if you imported these components directly (no shipped consumer app did). Remove `import { AuditLogScreen, AuditLogDetailScreen } from \"@cosmicdrift/kumiko-bundled-features/audit/web\"` and any `components: { AuditLogScreen: ..., AuditLogDetailScreen: ... }` entry in your `auditClient()`/renderer setup — the bundled feature registers its own `audit-log`/`audit-log-detail` screens now, nothing left for an app to wire."
8
+ },
2
9
  {
3
10
  "version": "0.127.0",
4
11
  "type": "improvement",
@@ -3,7 +3,7 @@ import {
3
3
  defineFeature,
4
4
  type FeatureDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
- import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID } from "./constants";
6
+ import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants";
7
7
  import { detailsQuery } from "./handlers/details.query";
8
8
  import { listQuery } from "./handlers/list.query";
9
9
  import { AUDIT_I18N } from "./i18n";
@@ -39,20 +39,70 @@ export function createAuditFeature(): FeatureDefinition {
39
39
  details: r.queryHandler(detailsQuery),
40
40
  };
41
41
 
42
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
43
42
  r.screen({
44
43
  id: AUDIT_LOG_SCREEN_ID,
45
- type: "custom",
46
- renderer: { react: { __component: "AuditLogScreen" } },
44
+ type: "projectionList",
45
+ query: AuditQueries.list,
46
+ columns: [
47
+ { field: "createdAt", label: "audit.log.col.when", renderer: { format: "timestamp" } },
48
+ { field: "type", label: "audit.log.col.type" },
49
+ { field: "createdBy", label: "audit.log.col.actor" },
50
+ ],
51
+ searchable: true,
52
+ defaultSort: { field: "createdAt", dir: "desc" },
53
+ rowActions: [
54
+ {
55
+ kind: "navigate",
56
+ id: "details",
57
+ label: "audit.log.details",
58
+ screen: AUDIT_LOG_DETAIL_SCREEN_ID,
59
+ entityId: "id",
60
+ rowClick: true,
61
+ },
62
+ ],
63
+ pagination: "infinite",
47
64
  description:
48
65
  "Admin table of the tenant's audit-trail events with actor names and event-type/date filters; open it to browse recent changes and drill into a single event.",
49
66
  access: { roles: access.admin },
50
67
  });
51
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
52
68
  r.screen({
53
69
  id: AUDIT_LOG_DETAIL_SCREEN_ID,
54
- type: "custom",
55
- renderer: { react: { __component: "AuditLogDetailScreen" } },
70
+ type: "projectionDetail",
71
+ query: AuditQueries.details,
72
+ idParam: "id",
73
+ fieldLabels: {
74
+ type: "audit.log.col.type",
75
+ createdAt: "audit.log.col.when",
76
+ aggregateType: "audit.log.col.aggregateType",
77
+ aggregateId: "audit.log.col.aggregateId",
78
+ createdBy: "audit.log.col.actor",
79
+ id: "audit.log.detail.field.id",
80
+ payload: "audit.log.detail.payload",
81
+ metadata: "audit.log.detail.metadata",
82
+ },
83
+ layout: {
84
+ sections: [
85
+ {
86
+ fields: [
87
+ "type",
88
+ { field: "createdAt", renderer: { format: "timestamp" } },
89
+ "aggregateType",
90
+ "aggregateId",
91
+ "createdBy",
92
+ "id",
93
+ ],
94
+ },
95
+ // No section `title` here (fw#2312 label-dedup fix): each section
96
+ // holds exactly one field, so a title would repeat the field's own
97
+ // label (rendered by RenderField as the Field's heading) verbatim.
98
+ {
99
+ fields: [{ field: "payload", renderer: { format: "json" } }],
100
+ },
101
+ {
102
+ fields: [{ field: "metadata", renderer: { format: "json" } }],
103
+ },
104
+ ],
105
+ },
56
106
  description:
57
107
  "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
58
108
  listScreenId: AUDIT_LOG_SCREEN_ID,
@@ -19,18 +19,60 @@ import { z } from "zod";
19
19
 
20
20
  const MAX_LIMIT = 100;
21
21
 
22
+ function buildDateRange(
23
+ from: string | undefined,
24
+ to: string | undefined,
25
+ ): { gte?: unknown; lte?: unknown } | null {
26
+ if (!from && !to) return null;
27
+ const range: { gte?: unknown; lte?: unknown } = {};
28
+ if (from) range.gte = Temporal.Instant.from(from);
29
+ if (to) range.lte = Temporal.Instant.from(to);
30
+ return range;
31
+ }
32
+
33
+ function buildAuditWhere(
34
+ tenantId: string,
35
+ p: {
36
+ aggregateType?: string;
37
+ aggregateId?: string;
38
+ eventType?: string;
39
+ search?: string;
40
+ userId?: string;
41
+ from?: string;
42
+ to?: string;
43
+ cursor?: string;
44
+ before?: string;
45
+ },
46
+ ): WhereObject {
47
+ const where: WhereObject = { tenantId };
48
+ if (p.aggregateType) where["aggregateType"] = p.aggregateType;
49
+ if (p.aggregateId) where["aggregateId"] = p.aggregateId;
50
+ const type = p.eventType ?? p.search;
51
+ if (type) where["type"] = type;
52
+ if (p.userId) where["createdBy"] = p.userId;
53
+ const range = buildDateRange(p.from, p.to);
54
+ if (range) where["createdAt"] = range;
55
+ const cursor = p.cursor ?? p.before;
56
+ if (cursor) where["id"] = { lt: BigInt(cursor) };
57
+ return where;
58
+ }
59
+
22
60
  export const listQuery = defineQueryHandler({
23
61
  name: "list",
24
62
  description:
25
63
  "Lists the tenant's audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when.",
26
64
  schema: z
27
65
  .object({
66
+ cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
28
67
  before: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
68
+ search: z.string().trim().optional(),
29
69
  limit: z.number().int().min(1).max(MAX_LIMIT).default(50),
30
70
  aggregateType: z.string().optional(),
31
71
  aggregateId: z.uuid().optional(),
32
72
  eventType: z.string().optional(),
33
73
  userId: z.string().optional(),
74
+ sort: z.enum(["createdAt", "type"]).optional(),
75
+ sortDirection: z.enum(["asc", "desc"]).optional(),
34
76
  from: z.iso.datetime().optional(),
35
77
  to: z.iso.datetime().optional(),
36
78
  })
@@ -41,18 +83,7 @@ export const listQuery = defineQueryHandler({
41
83
  access: { roles: access.admin },
42
84
  handler: async (query, ctx) => {
43
85
  const p = query.payload;
44
- const where: WhereObject = { tenantId: query.user.tenantId };
45
- if (p.aggregateType) where["aggregateType"] = p.aggregateType;
46
- if (p.aggregateId) where["aggregateId"] = p.aggregateId;
47
- if (p.eventType) where["type"] = p.eventType;
48
- if (p.userId) where["createdBy"] = p.userId;
49
- if (p.from || p.to) {
50
- const range: { gte?: unknown; lte?: unknown } = {};
51
- if (p.from) range.gte = Temporal.Instant.from(p.from);
52
- if (p.to) range.lte = Temporal.Instant.from(p.to);
53
- where["createdAt"] = range;
54
- }
55
- if (p.before) where["id"] = { lt: BigInt(p.before) };
86
+ const where = buildAuditWhere(query.user.tenantId, p);
56
87
 
57
88
  const rows = await selectMany<{
58
89
  id: bigint;
@@ -65,7 +96,10 @@ export const listQuery = defineQueryHandler({
65
96
  createdAt: unknown;
66
97
  createdBy: string;
67
98
  }>(ctx.db, eventsTable, where, {
68
- orderBy: { col: "id", direction: "desc" },
99
+ orderBy: {
100
+ col: query.payload.sort === "type" ? "type" : "createdAt",
101
+ direction: query.payload.sortDirection ?? "desc",
102
+ },
69
103
  limit: p.limit,
70
104
  });
71
105
 
@@ -81,9 +115,11 @@ export const listQuery = defineQueryHandler({
81
115
  createdBy: r.createdBy,
82
116
  }));
83
117
  const last = serialised[serialised.length - 1];
118
+ const nextId = serialised.length === p.limit && last ? last.id : null;
84
119
  return {
85
120
  rows: serialised,
86
- nextBefore: serialised.length === p.limit && last ? last.id : null,
121
+ nextCursor: nextId,
122
+ nextBefore: nextId,
87
123
  };
88
124
  },
89
125
  });
package/src/audit/i18n.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // @runtime client
2
- // Server + client i18n for audit (nav labels + AuditLogScreen).
2
+ // Server + client i18n for audit (nav labels + audit-log/-detail screens).
3
3
 
4
4
  type LocalizedString = { readonly en: string };
5
5
 
@@ -7,27 +7,12 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
7
7
  "screen:audit-log.title": { en: "Audit log" },
8
8
  "screen:audit-log-detail.title": { en: "Event" },
9
9
  "audit:nav.auditLog": { en: "Audit" },
10
- "audit.log.title": { en: "Audit log" },
11
- "audit.log.loading": { en: "Loading events…" },
12
- "audit.log.empty": { en: "No events." },
13
- "audit.log.newest": { en: "Newest" },
14
- "audit.log.older": { en: "Load older" },
15
10
  "audit.log.col.when": { en: "When" },
16
11
  "audit.log.col.type": { en: "Event" },
17
- "audit.log.col.aggregate": { en: "Aggregate" },
12
+ "audit.log.col.aggregateType": { en: "Aggregate type" },
13
+ "audit.log.col.aggregateId": { en: "Aggregate ID" },
18
14
  "audit.log.col.actor": { en: "Actor" },
19
- "audit.log.actor.system": { en: "System" },
20
- "audit.log.actor.anonymous": { en: "Anonymous" },
21
- "audit.log.actor.unknown": { en: "Unknown actor" },
22
- "audit.log.filter.eventType": { en: "Event type" },
23
- "audit.log.filter.aggregateType": { en: "Aggregate type" },
24
- "audit.log.filter.from": { en: "From" },
25
- "audit.log.filter.to": { en: "To" },
26
- "audit.log.filter.apply": { en: "Filter" },
27
- "audit.log.filter.reset": { en: "Reset" },
28
15
  "audit.log.details": { en: "Details" },
29
- "audit.log.detail.loading": { en: "Loading event…" },
30
- "audit.log.detail.missing": { en: "Event not found." },
31
16
  "audit.log.detail.payload": { en: "Event payload" },
32
17
  "audit.log.detail.metadata": { en: "Metadata" },
33
18
  "audit.log.detail.field.id": { en: "Event ID" },
@@ -1,9 +1,7 @@
1
1
  // @runtime client
2
2
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { AUDIT_FEATURE, AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID } from "../constants";
5
- import { AuditLogDetailScreen } from "./audit-log-detail-screen";
6
- import { AuditLogScreen } from "./audit-log-screen";
4
+ import { AUDIT_FEATURE } from "../constants";
7
5
  import { defaultTranslations } from "./i18n";
8
6
 
9
7
  export type AuditClientOptions = {
@@ -14,9 +12,5 @@ export function auditClient(options?: AuditClientOptions): ClientFeatureDefiniti
14
12
  return {
15
13
  name: AUDIT_FEATURE,
16
14
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
17
- components: {
18
- [AUDIT_LOG_SCREEN_ID]: AuditLogScreen,
19
- [AUDIT_LOG_DETAIL_SCREEN_ID]: AuditLogDetailScreen,
20
- },
21
15
  };
22
16
  }
@@ -1,5 +1,3 @@
1
1
  // @runtime client
2
2
 
3
- export { AuditLogDetailScreen } from "./audit-log-detail-screen";
4
- export { AuditLogScreen } from "./audit-log-screen";
5
3
  export { type AuditClientOptions, auditClient } from "./client-plugin";
@@ -939,6 +939,37 @@ describe("config.readiness query handler", () => {
939
939
  expect(keys).not.toContain("orders:config:max-order-count");
940
940
  });
941
941
 
942
+ type MissingWithSummary = Missing & { missingCount: number; missingTone: "default" | "warn" };
943
+
944
+ test("missingCount matches missing.length and missingTone is warn while something is missing (fw#2312)", async () => {
945
+ const result = await stack.http.queryOk<MissingWithSummary>(
946
+ ConfigQueries.readiness,
947
+ {},
948
+ readinessAdminFor(6),
949
+ );
950
+
951
+ expect(result.missingCount).toBe(result.missing.length);
952
+ expect(result.missingCount).toBeGreaterThan(0);
953
+ expect(result.missingTone).toBe("warn");
954
+ });
955
+
956
+ test("missingCount is 0 and missingTone is default once every required key is set (fw#2312)", async () => {
957
+ const admin = readinessAdminFor(7);
958
+ for (const [key, value] of [
959
+ ["transport:config:smtp-host", "smtp.example.com"],
960
+ ["transport:config:api-url", "https://api.example.com"],
961
+ ["transport:config:webhook-url", "https://webhook.example.com"],
962
+ ["transport:config:timeout", 30],
963
+ ] as const) {
964
+ await stack.http.writeOk(ConfigHandlers.set, { key, value }, admin);
965
+ }
966
+
967
+ const result = await stack.http.queryOk<MissingWithSummary>(ConfigQueries.readiness, {}, admin);
968
+ expect(result.missing).toEqual([]);
969
+ expect(result.missingCount).toBe(0);
970
+ expect(result.missingTone).toBe("default");
971
+ });
972
+
942
973
  test("whitespace-only text value still counts as missing (requireNonEmpty-Parität)", async () => {
943
974
  const admin = readinessAdminFor(2);
944
975
  await stack.http.writeOk(
@@ -140,6 +140,13 @@ export const readinessQuery = defineQueryHandler({
140
140
  query.user,
141
141
  db,
142
142
  );
143
- return { missing };
143
+ // Additive, display-ready fields for the admin-shell dashboard stat
144
+ // panel (fw#2312) — `missing` itself stays untouched for existing
145
+ // consumers that read the array directly.
146
+ return {
147
+ missing,
148
+ missingCount: missing.length,
149
+ missingTone: missing.length > 0 ? "warn" : "default",
150
+ };
144
151
  },
145
152
  });
@@ -16,9 +16,14 @@ describe("jobs screens + handler access alignment", () => {
16
16
 
17
17
  test("job-runs screens are SystemAdmin-gated", () => {
18
18
  const jobs = createJobsFeature();
19
- for (const id of [JOB_RUNS_SCREEN_ID, JOB_RUN_DETAIL_SCREEN_ID] as const) {
19
+ const expectedTypeById = {
20
+ [JOB_RUNS_SCREEN_ID]: "projectionList",
21
+ [JOB_RUN_DETAIL_SCREEN_ID]: "projectionDetail",
22
+ "job-trigger": "actionForm",
23
+ } as const;
24
+ for (const id of Object.keys(expectedTypeById) as (keyof typeof expectedTypeById)[]) {
20
25
  const screen = jobs.screens[id];
21
- expect(screen?.type).toBe("custom");
26
+ expect(screen?.type).toBe(expectedTypeById[id]);
22
27
  if (screen && "access" in screen && screen.access && "roles" in screen.access) {
23
28
  expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
24
29
  }
@@ -2,7 +2,12 @@
2
2
  // Real HTTP via setupTestStack — no mocks.
3
3
 
4
4
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
- import { defineFeature, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { sql } from "@cosmicdrift/kumiko-framework/db";
6
+ import {
7
+ defineFeature,
8
+ type SessionUser,
9
+ SYSTEM_TENANT_ID,
10
+ } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import {
7
12
  createTestUser,
8
13
  setupTestStack,
@@ -10,7 +15,7 @@ import {
10
15
  TestUsers,
11
16
  unsafePushTables,
12
17
  } from "@cosmicdrift/kumiko-framework/stack";
13
- import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { rolesOf, seedRow } from "@cosmicdrift/kumiko-framework/testing";
14
19
  import { createConfigFeature } from "../../config/feature";
15
20
  import {
16
21
  JOB_RUN_DETAIL_SCREEN_ID,
@@ -108,3 +113,37 @@ describe("SystemAdmin can use jobs queries", () => {
108
113
  expect(Array.isArray(res.rows)).toBe(true);
109
114
  });
110
115
  });
116
+
117
+ describe("jobs:query:list totalCount (fw#2312)", () => {
118
+ test("without totalCount the shape carries no total field", async () => {
119
+ const res = await stack.http.queryOk<{ rows: readonly unknown[]; total?: number }>(
120
+ JobQueries.list,
121
+ { jobName: "jobs-sec-app:job:total-count-shape" },
122
+ systemAdmin,
123
+ );
124
+ expect(res.total).toBeUndefined();
125
+ });
126
+
127
+ test("total reflects the full match count, not capped by limit", async () => {
128
+ const jobName = "jobs-sec-app:job:total-count-probe";
129
+ for (let i = 0; i < 6; i++) {
130
+ await seedRow(stack.db, jobRunsTable, {
131
+ id: crypto.randomUUID(),
132
+ tenantId: SYSTEM_TENANT_ID,
133
+ jobName,
134
+ bullJobId: `total-count-${i}`,
135
+ status: "completed",
136
+ attempt: 1,
137
+ startedAt: sql`now()`,
138
+ });
139
+ }
140
+
141
+ const res = await stack.http.queryOk<{ rows: readonly unknown[]; total?: number }>(
142
+ JobQueries.list,
143
+ { jobName, limit: 2, totalCount: true },
144
+ systemAdmin,
145
+ );
146
+ expect(res.rows.length).toBe(2);
147
+ expect(res.total).toBe(6);
148
+ });
149
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "breaking",
5
+ "title": "JobRunsScreen and JobRunDetailScreen removed; job-runs/job-run-detail are now declarative screens (fw#2312).",
6
+ "detail": "`job-runs`/`job-run-detail` render through the generic renderer (`projectionList`/`projectionDetail`) instead of custom React components — the renderer selects a screen by its `screen.type`, not a client component registry, so `JobRunsScreen`/`JobRunDetailScreen` were dead exports the moment the screen definitions switched. Job triggering moved from an inline control on the old custom screen to a new `job-trigger` `actionForm`, opened via a `kind: \"drawer\"` `toolbarAction` on `job-runs`. `job-run-detail`'s `logs` field now uses the `format: \"json\"` field-renderer (see framework changelog) instead of a raw escaped-string dump.",
7
+ "migration": "Breaking only if you imported these components directly (no shipped consumer app did). Remove `import { JobRunsScreen, JobRunDetailScreen } from \"@cosmicdrift/kumiko-bundled-features/jobs/web\"` and any `components: { JobRunsScreen: ..., JobRunDetailScreen: ... }` entry in your `jobsClient()`/renderer setup — the bundled feature registers its own `job-runs`/`job-run-detail`/`job-trigger` screens now, nothing left for an app to wire."
8
+ },
2
9
  {
3
10
  "version": "0.120.0",
4
11
  "type": "improvement",
@@ -1,5 +1,5 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID } from "./constants";
2
+ import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID, JobHandlers, JobQueries } from "./constants";
3
3
  import { catalogQuery } from "./handlers/catalog.query";
4
4
  import { detailQuery } from "./handlers/detail.query";
5
5
  import { listQuery } from "./handlers/list.query";
@@ -109,20 +109,112 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
109
109
 
110
110
  r.translations({ keys: JOBS_I18N });
111
111
 
112
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
113
112
  r.screen({
114
113
  id: JOB_RUNS_SCREEN_ID,
115
- type: "custom",
116
- renderer: { react: { __component: "JobRunsScreen" } },
117
- description:
118
- "Operator table of recent job runs with status filters, plus a panel to trigger a manual job with a payload; open it to monitor background jobs and start one.",
114
+ type: "projectionList",
115
+ query: JobQueries.list,
116
+ columns: [
117
+ { field: "jobName", label: "jobs.runs.col.job" },
118
+ { field: "status", label: "jobs.runs.col.status" },
119
+ { field: "startedAt", label: "jobs.runs.col.started", renderer: { format: "timestamp" } },
120
+ { field: "duration", label: "jobs.runs.col.duration" },
121
+ ],
122
+ defaultSort: { field: "startedAt", dir: "desc" },
123
+ facets: [
124
+ {
125
+ field: "status",
126
+ type: "select",
127
+ label: "jobs.runs.filter.status",
128
+ options: [
129
+ { value: "queued", label: "jobs.runs.filter.queued" },
130
+ { value: "running", label: "jobs.runs.filter.running" },
131
+ { value: "completed", label: "jobs.runs.filter.completed" },
132
+ { value: "failed", label: "jobs.runs.filter.failed" },
133
+ ],
134
+ },
135
+ ],
136
+ rowActions: [
137
+ {
138
+ kind: "navigate",
139
+ id: "open",
140
+ label: "jobs.runs.open",
141
+ screen: JOB_RUN_DETAIL_SCREEN_ID,
142
+ entityId: "id",
143
+ rowClick: true,
144
+ },
145
+ ],
146
+ toolbarActions: [
147
+ {
148
+ kind: "drawer",
149
+ id: "trigger",
150
+ label: "jobs.trigger.title",
151
+ screen: "job-trigger",
152
+ style: "primary",
153
+ },
154
+ ],
155
+ pagination: "infinite",
119
156
  access: systemAdminAccess,
120
157
  });
121
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
158
+ r.screen({
159
+ id: "job-trigger",
160
+ type: "actionForm",
161
+ handler: JobHandlers.trigger,
162
+ fields: {
163
+ jobName: { type: "text", required: true },
164
+ payload: { type: "longText", multiline: true, default: "{}" },
165
+ },
166
+ layout: { sections: [{ fields: ["jobName", "payload"] }] },
167
+ submitLabel: "jobs.trigger.submit",
168
+ access: systemAdminAccess,
169
+ description: "Manually trigger a job by name with an optional JSON object payload.",
170
+ });
122
171
  r.screen({
123
172
  id: JOB_RUN_DETAIL_SCREEN_ID,
124
- type: "custom",
125
- renderer: { react: { __component: "JobRunDetailScreen" } },
173
+ type: "projectionDetail",
174
+ query: JobQueries.details,
175
+ idParam: "runId",
176
+ fieldLabels: {
177
+ jobName: "jobs.detail.field.job",
178
+ status: "jobs.detail.field.status",
179
+ id: "jobs.detail.field.id",
180
+ startedAt: "jobs.detail.field.started",
181
+ finishedAt: "jobs.detail.field.finished",
182
+ duration: "jobs.detail.field.duration",
183
+ error: "jobs.detail.field.error",
184
+ logs: "jobs.detail.logs",
185
+ },
186
+ layout: {
187
+ sections: [
188
+ {
189
+ fields: [
190
+ "jobName",
191
+ "status",
192
+ "id",
193
+ { field: "startedAt", renderer: { format: "timestamp" } },
194
+ { field: "finishedAt", renderer: { format: "timestamp" } },
195
+ "duration",
196
+ "error",
197
+ ],
198
+ },
199
+ // No section `title` here (fw#2312 label-dedup fix): the section
200
+ // holds exactly one field, so a title would repeat the field's own
201
+ // label (rendered by RenderField as the Field's heading) verbatim.
202
+ {
203
+ fields: [{ field: "logs", renderer: { format: "json" } }],
204
+ },
205
+ ],
206
+ },
207
+ actions: [
208
+ {
209
+ kind: "writeHandler",
210
+ id: "retry",
211
+ label: "jobs.detail.retry",
212
+ handler: JobHandlers.retry,
213
+ payload: { map: { runId: "id" } },
214
+ visible: { field: "status", eq: "failed" },
215
+ style: "primary",
216
+ },
217
+ ],
126
218
  description:
127
219
  "Detail view of one job run with status, timings, error and log lines, and a retry action for failed runs; reached from a row of the job-runs list.",
128
220
  listScreenId: JOB_RUNS_SCREEN_ID,
@@ -1,4 +1,4 @@
1
- import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { countWhere, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
@@ -28,7 +28,13 @@ export const listQuery = defineQueryHandler({
28
28
  schema: z.object({
29
29
  jobName: z.string().optional(),
30
30
  status: z.enum(["queued", "running", "completed", "failed"]).optional(),
31
+ filters: z
32
+ .array(z.object({ field: z.string(), op: z.literal("in"), value: z.array(z.string()) }))
33
+ .optional(),
34
+ sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
35
+ sortDirection: z.enum(["asc", "desc"]).optional(),
31
36
  limit: z.number().optional(),
37
+ totalCount: z.boolean().optional(),
32
38
  }),
33
39
  access: { roles: ["SystemAdmin"] },
34
40
  handler: async (query, ctx) => {
@@ -40,12 +46,23 @@ export const listQuery = defineQueryHandler({
40
46
  const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
41
47
  const where: WhereObject = {};
42
48
  if (query.payload.jobName) where["jobName"] = query.payload.jobName;
43
- if (query.payload.status) where["status"] = query.payload.status;
49
+ const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
50
+ const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
51
+ if (statuses.length === 1) where["status"] = statuses[0];
52
+ const sortColumn = query.payload.sort ?? "startedAt";
44
53
  const rows = await selectMany(db, jobRunsTable, where, {
45
- orderBy: { col: "id", direction: "desc" },
54
+ orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
46
55
  limit: query.payload.limit ?? 50,
47
56
  });
57
+ // countWhere reruns the SAME `where` without the limit — `rows.length` is
58
+ // capped at the page size and would silently undercount the total.
59
+ const total =
60
+ query.payload.totalCount === true ? await countWhere(db, jobRunsTable, where) : undefined;
48
61
  // payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
49
- return { rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow) };
62
+ return {
63
+ rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
64
+ nextCursor: null,
65
+ ...(total !== undefined && { total }),
66
+ };
50
67
  },
51
68
  });