@cosmicdrift/kumiko-samples 0.390.1 → 0.391.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 (70) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/event-classification.ts +5 -0
  5. package/packages/bundled-features/src/audit/handlers/audit-events.ts +15 -1
  6. package/packages/bundled-features/src/audit/handlers/details.query.ts +11 -4
  7. package/packages/bundled-features/src/audit/handlers/list.query.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +11 -2
  9. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  10. package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
  11. package/packages/bundled-features/src/auth-mfa/screens.ts +3 -0
  12. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  13. package/packages/bundled-features/src/file-foundation/feature.ts +6 -0
  14. package/packages/bundled-features/src/file-foundation/i18n.ts +13 -0
  15. package/packages/bundled-features/src/file-foundation/index.ts +4 -0
  16. package/packages/bundled-features/src/file-foundation/probe.ts +68 -0
  17. package/packages/bundled-features/src/{user-data-rights/lib → file-foundation}/storage-provider-resolver.ts +2 -2
  18. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  19. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -0
  20. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +2 -0
  21. package/packages/bundled-features/src/inbound-mail-foundation/probe-keys.ts +17 -0
  22. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +109 -0
  23. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +3 -0
  24. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -11
  25. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +16 -0
  26. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  27. package/packages/bundled-features/src/mail-foundation/feature.ts +2 -0
  28. package/packages/bundled-features/src/mail-foundation/i18n.ts +27 -0
  29. package/packages/bundled-features/src/mail-foundation/index.ts +4 -0
  30. package/packages/bundled-features/src/{user-data-rights/lib → mail-foundation}/mail-transport-resolver.ts +2 -2
  31. package/packages/bundled-features/src/mail-foundation/probe.ts +81 -0
  32. package/packages/bundled-features/src/readiness/built-in-probes.ts +58 -0
  33. package/packages/bundled-features/src/readiness/changes.json +7 -0
  34. package/packages/bundled-features/src/readiness/collect-probes.ts +47 -0
  35. package/packages/bundled-features/src/readiness/constants.ts +25 -0
  36. package/packages/bundled-features/src/readiness/execute-probes.ts +116 -0
  37. package/packages/bundled-features/src/readiness/feature.ts +91 -3
  38. package/packages/bundled-features/src/readiness/handlers/latest-results.query.ts +15 -0
  39. package/packages/bundled-features/src/readiness/handlers/latest-summary.query.ts +53 -0
  40. package/packages/bundled-features/src/readiness/handlers/run-detail.query.ts +31 -0
  41. package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +48 -0
  42. package/packages/bundled-features/src/readiness/handlers/run-probes.write.ts +98 -0
  43. package/packages/bundled-features/src/readiness/handlers/run-results.query.ts +18 -0
  44. package/packages/bundled-features/src/readiness/handlers/run-tone.ts +6 -0
  45. package/packages/bundled-features/src/readiness/i18n.ts +55 -0
  46. package/packages/bundled-features/src/readiness/index.ts +1 -1
  47. package/packages/bundled-features/src/readiness/probe-boot-check.ts +53 -0
  48. package/packages/bundled-features/src/readiness/probe-context.ts +57 -0
  49. package/packages/bundled-features/src/readiness/run-probes-job.ts +104 -0
  50. package/packages/bundled-features/src/readiness/run-view.ts +48 -0
  51. package/packages/bundled-features/src/readiness/schema/probe-run.ts +89 -0
  52. package/packages/bundled-features/src/readiness/screens.ts +191 -0
  53. package/packages/bundled-features/src/readiness/visible-results.ts +106 -0
  54. package/packages/bundled-features/src/tenant/changes.json +12 -0
  55. package/packages/bundled-features/src/tenant/membership-stream-history.ts +224 -0
  56. package/packages/bundled-features/src/tenant/membership-table.ts +3 -3
  57. package/packages/bundled-features/src/tenant/seeding.ts +8 -13
  58. package/packages/bundled-features/src/tenant/stream-tenant-backfill.ts +45 -1
  59. package/packages/bundled-features/src/user-data-rights/feature.ts +2 -2
  60. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  61. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  62. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  63. package/packages/bundled-features/src/user-profile/changes.json +6 -0
  64. package/packages/bundled-features/src/user-profile/feature.ts +20 -0
  65. package/packages/bundled-features/src/user-profile/web/constants.ts +5 -0
  66. package/packages/bundled-features/src/user-profile/web/i18n.ts +2 -0
  67. package/samples/apps/use-all-bundled/feature-manifest.json +23 -4
  68. package/samples/apps/use-all-bundled/kumiko/migrations/0054_readiness-probe-runs.rebuild.json +4 -0
  69. package/samples/apps/use-all-bundled/src/app/admin-nav-feature.ts +27 -0
  70. package/samples/apps/use-all-bundled/src/run-config.ts +3 -1
@@ -0,0 +1,81 @@
1
+ import {
2
+ createSystemUser,
3
+ type FeatureRegistrar,
4
+ type ProbeContext,
5
+ ProbeFailure,
6
+ SETTINGS_HUB_FEATURE,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
9
+ import { HostResolutionError } from "@cosmicdrift/kumiko-framework/http";
10
+ import { createConfigAccessor } from "../config/index.ts";
11
+ import { tenantMailTransport } from "./feature.ts";
12
+ import { MAIL_PROBE_KEYS } from "./i18n.ts";
13
+
14
+ const PROBE_SUBJECT = "Kumiko system check";
15
+ const PROBE_TEXT = "This message was sent by the system check. No action is needed.";
16
+ const MAIL_SETTINGS_SCREEN_QN = `${SETTINGS_HUB_FEATURE}:screen:mail-foundation-tenant`;
17
+
18
+ // The raw error stays in the server log: it can name hosts, users or the recipient.
19
+ function probeFailure(ctx: ProbeContext, error: unknown, key: string): ProbeFailure {
20
+ ctx.obs.log.warn("mail probe failed", {
21
+ error: error instanceof Error ? error.message : String(error),
22
+ });
23
+ return new ProbeFailure({ key });
24
+ }
25
+
26
+ function transportFailureKey(error: unknown): string {
27
+ if (error instanceof HostResolutionError) return MAIL_PROBE_KEYS.hostNotFound;
28
+ return error instanceof UnconfiguredError
29
+ ? MAIL_PROBE_KEYS.setupIncomplete
30
+ : MAIL_PROBE_KEYS.setupFailed;
31
+ }
32
+
33
+ // The mail goes to whoever started the check; the address never appears in the stored result.
34
+ export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void {
35
+ r.probe({
36
+ name: "send-test-mail",
37
+ scope: "tenant",
38
+ cost: "free",
39
+ area: MAIL_PROBE_KEYS.area,
40
+ label: MAIL_PROBE_KEYS.label,
41
+ fixScreen: MAIL_SETTINGS_SCREEN_QN,
42
+ run: async (ctx) => {
43
+ const { configResolver, secrets } = ctx.services;
44
+ if (!configResolver) {
45
+ return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
46
+ }
47
+ const config = createConfigAccessor(
48
+ ctx.registry,
49
+ configResolver,
50
+ ctx.tenantId,
51
+ createSystemUser(ctx.tenantId).id,
52
+ ctx.db,
53
+ );
54
+ const transport = await tenantMailTransport({
55
+ tenantId: ctx.tenantId,
56
+ config,
57
+ registry: ctx.registry,
58
+ secrets,
59
+ }).catch((error: unknown) => {
60
+ throw probeFailure(ctx, error, transportFailureKey(error));
61
+ });
62
+ if (transport === null) {
63
+ return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
64
+ }
65
+ if (ctx.triggeredByEmail === undefined) {
66
+ return { status: "skipped", message: { key: MAIL_PROBE_KEYS.noRecipient } };
67
+ }
68
+ await transport
69
+ .send({
70
+ to: ctx.triggeredByEmail,
71
+ subject: PROBE_SUBJECT,
72
+ text: PROBE_TEXT,
73
+ html: `<p>${PROBE_TEXT}</p>`,
74
+ })
75
+ .catch((error: unknown) => {
76
+ throw probeFailure(ctx, error, MAIL_PROBE_KEYS.sendRejected);
77
+ });
78
+ return { status: "ok", message: { key: MAIL_PROBE_KEYS.accepted } };
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,58 @@
1
+ import type { FeatureRegistrar, ProbeContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { ProbeTextKeys } from "./constants.ts";
3
+ import { probeRunsTable } from "./schema/probe-run.ts";
4
+
5
+ export const BuiltInProbeKeys = {
6
+ areaInfrastructure: "readiness.area.infrastructure",
7
+ databaseLabel: "readiness.probe.database.label",
8
+ databaseOk: "readiness.probe.database.ok",
9
+ cacheLabel: "readiness.probe.cache.label",
10
+ cacheOk: "readiness.probe.cache.ok",
11
+ searchLabel: "readiness.probe.search.label",
12
+ searchOk: "readiness.probe.search.ok",
13
+ } as const;
14
+
15
+ export function registerBuiltInProbes(r: FeatureRegistrar<"readiness">): void {
16
+ r.probe({
17
+ name: "database",
18
+ scope: "system",
19
+ cost: "free",
20
+ area: BuiltInProbeKeys.areaInfrastructure,
21
+ label: BuiltInProbeKeys.databaseLabel,
22
+ run: async (ctx: ProbeContext) => {
23
+ await ctx.db.fetchOne(probeRunsTable, { id: ctx.probeRunId });
24
+ return { status: "ok", message: { key: BuiltInProbeKeys.databaseOk } };
25
+ },
26
+ });
27
+
28
+ r.probe({
29
+ name: "cache",
30
+ scope: "system",
31
+ cost: "free",
32
+ area: BuiltInProbeKeys.areaInfrastructure,
33
+ label: BuiltInProbeKeys.cacheLabel,
34
+ run: async (ctx: ProbeContext) => {
35
+ const { redis } = ctx.services;
36
+ if (!redis) return { status: "skipped", message: { key: ProbeTextKeys.notConfigured } };
37
+ const reply = await redis.ping();
38
+ if (reply !== "PONG") throw new Error(`unexpected PING reply: ${reply}`);
39
+ return { status: "ok", message: { key: BuiltInProbeKeys.cacheOk } };
40
+ },
41
+ });
42
+
43
+ r.probe({
44
+ name: "search",
45
+ scope: "system",
46
+ cost: "free",
47
+ area: BuiltInProbeKeys.areaInfrastructure,
48
+ label: BuiltInProbeKeys.searchLabel,
49
+ run: async (ctx: ProbeContext) => {
50
+ const { searchAdapter } = ctx.services;
51
+ if (!searchAdapter) {
52
+ return { status: "skipped", message: { key: ProbeTextKeys.notConfigured } };
53
+ }
54
+ await searchAdapter.search(ctx.tenantId, "readiness-probe", { limit: 1 });
55
+ return { status: "ok", message: { key: BuiltInProbeKeys.searchOk } };
56
+ },
57
+ });
58
+ }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.391.0",
4
+ "type": "improvement",
5
+ "title": "Probe runs and the system-check screen",
6
+ "detail": "The new readiness:write:run-probes starts a run as a worker job; includePaid also runs paid probes. A run only writes its own probe-run events, the probes read through adapters and fixtures and never write domain data. New queries return the latest run with its summary, the run history with the starter's name, and one run with its results. Probe texts arrive as { i18nKey, i18nParams }, and failed rows carry the QN of the screen that fixes the problem; the boot validator checks that QN against the feature screens and the generated settings screens. A probe that reports itself skipped shows no duration. createReadinessFeature({ nav: { order?, parent?, workspaces? } }) adds the system-check screen to the nav (workspace apps list the workspaces, a parent's membership does not carry over): start buttons (the paid one only when paid probes exist), the latest results with a fix link on failed rows, and the history that opens each run. Standard probes cover database, cache, search, mail sending, file storage, inbound mailboxes and the PDF service.",
7
+ "migration": "New table read_readiness_probe_runs. Apps that mount readiness run `kumiko schema generate` and apply the migration before deploying. Without a nav option the screen stays dormant; pass createReadinessFeature({ nav: {} }) or a parent nav QN to show it."
8
+ },
2
9
  {
3
10
  "version": "0.370.0",
4
11
  "type": "breaking",
@@ -0,0 +1,47 @@
1
+ import type { ProbeDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ DEFAULT_PROBE_TIMEOUT_MS,
4
+ MIN_STALE_RUN_AFTER_MS,
5
+ STALE_RUN_TIMEOUT_FACTOR,
6
+ } from "./constants.ts";
7
+
8
+ export type CollectProbesOptions = {
9
+ readonly includeSystemProbes: boolean;
10
+ // Feature names enabled for the tenant; undefined = every mounted feature.
11
+ readonly enabledFeatures: ReadonlySet<string> | undefined;
12
+ };
13
+
14
+ export function collectProbes(
15
+ registry: Registry,
16
+ options: CollectProbesOptions,
17
+ ): readonly ProbeDefinition[] {
18
+ const collected: ProbeDefinition[] = [];
19
+ for (const feature of registry.features.values()) {
20
+ if (options.enabledFeatures !== undefined && !options.enabledFeatures.has(feature.name)) {
21
+ continue;
22
+ }
23
+ for (const probe of Object.values(feature.probes ?? {})) {
24
+ if (probe.scope === "system" && !options.includeSystemProbes) continue;
25
+ collected.push(probe);
26
+ }
27
+ }
28
+ return collected;
29
+ }
30
+
31
+ export function hasPaidProbes(registry: Registry, options: CollectProbesOptions): boolean {
32
+ return collectProbes(registry, options).some((probe) => probe.cost === "paid");
33
+ }
34
+
35
+ export function probeTimeoutMs(probe: ProbeDefinition): number {
36
+ return probe.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
37
+ }
38
+
39
+ export function staleRunAfterMs(registry: Registry): number {
40
+ let longestTimeoutMs = 0;
41
+ for (const feature of registry.features.values()) {
42
+ for (const probe of Object.values(feature.probes ?? {})) {
43
+ longestTimeoutMs = Math.max(longestTimeoutMs, probeTimeoutMs(probe));
44
+ }
45
+ }
46
+ return Math.max(MIN_STALE_RUN_AFTER_MS, longestTimeoutMs * STALE_RUN_TIMEOUT_FACTOR);
47
+ }
@@ -4,4 +4,29 @@ export const READINESS_FEATURE = "readiness" as const;
4
4
  // Qualified query handler names (QN format: scope:type:name)
5
5
  export const ReadinessQueries = {
6
6
  status: "readiness:query:status",
7
+ latestResults: "readiness:query:probe-run:latest-results",
8
+ latestSummary: "readiness:query:probe-run:latest-summary",
9
+ runList: "readiness:query:probe-run:list",
10
+ runDetail: "readiness:query:probe-run:detail",
11
+ runResults: "readiness:query:probe-run:results",
12
+ } as const;
13
+
14
+ export const ReadinessWrites = {
15
+ runProbes: "readiness:write:run-probes",
16
+ } as const;
17
+
18
+ export const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
19
+
20
+ // An active run older than this is treated as dead (worker crash) and may be replaced.
21
+ export const MIN_STALE_RUN_AFTER_MS = 10 * 60_000;
22
+ export const STALE_RUN_TIMEOUT_FACTOR = 3;
23
+
24
+ // Translation keys of the runner's own result texts.
25
+ export const ProbeTextKeys = {
26
+ paidNotRequested: "readiness.probe.paid-not-requested",
27
+ noTargets: "readiness.probe.no-targets",
28
+ timeout: "readiness.probe.timeout",
29
+ failed: "readiness.probe.failed",
30
+ runCrashed: "readiness.probe.run-crashed",
31
+ notConfigured: "readiness.probe.not-configured",
7
32
  } as const;
@@ -0,0 +1,116 @@
1
+ import { runWithTimeout } from "@cosmicdrift/kumiko-framework/api";
2
+ import {
3
+ type ProbeContext,
4
+ type ProbeDefinition,
5
+ ProbeFailure,
6
+ type ProbeTarget,
7
+ type ProbeText,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { probeTimeoutMs } from "./collect-probes.ts";
10
+ import { ProbeTextKeys } from "./constants.ts";
11
+ import type { ProbeResultRow } from "./schema/probe-run.ts";
12
+
13
+ type RunOutcome = Pick<ProbeResultRow, "status" | "durationMs" | "message">;
14
+
15
+ function baseRow(probe: ProbeDefinition, target: ProbeTarget | undefined) {
16
+ return {
17
+ probe: probe.qualifiedName,
18
+ scope: probe.scope,
19
+ area: probe.area,
20
+ label: target ? target.label : { key: probe.label },
21
+ ...(target && { targetId: target.id }),
22
+ ...(target?.ownerUserId !== undefined && { ownerUserId: target.ownerUserId }),
23
+ ...(probe.fixScreen !== undefined && { fixScreen: probe.fixScreen }),
24
+ };
25
+ }
26
+
27
+ function rowOf(
28
+ probe: ProbeDefinition,
29
+ target: ProbeTarget | undefined,
30
+ outcome: RunOutcome,
31
+ ): ProbeResultRow {
32
+ return { ...baseRow(probe, target), ...outcome };
33
+ }
34
+
35
+ // Raw error messages can carry secrets or addresses, so only ProbeFailure texts reach the run.
36
+ function failureText(
37
+ ctx: ProbeContext,
38
+ probe: ProbeDefinition,
39
+ failure: { readonly error: unknown; readonly timedOut: boolean },
40
+ ): ProbeText {
41
+ if (failure.timedOut) {
42
+ return { key: ProbeTextKeys.timeout, params: { seconds: probeTimeoutMs(probe) / 1000 } };
43
+ }
44
+ if (failure.error instanceof ProbeFailure) return failure.error.text;
45
+ ctx.obs.log.warn("readiness probe failed", {
46
+ probe: probe.qualifiedName,
47
+ error: failure.error instanceof Error ? failure.error.message : String(failure.error),
48
+ });
49
+ return { key: ProbeTextKeys.failed };
50
+ }
51
+
52
+ async function runOne(
53
+ ctx: ProbeContext,
54
+ probe: ProbeDefinition,
55
+ target: ProbeTarget | undefined,
56
+ ): Promise<ProbeResultRow> {
57
+ const result = await runWithTimeout(() => probe.run(ctx, target), probeTimeoutMs(probe));
58
+ if (result.ok) {
59
+ // A skipped probe did not run, so its row shows no duration like the runner's own skips.
60
+ return rowOf(probe, target, {
61
+ status: result.value.status,
62
+ durationMs: result.value.status === "skipped" ? null : result.latencyMs,
63
+ message: result.value.message,
64
+ });
65
+ }
66
+ return rowOf(probe, target, {
67
+ status: "failed",
68
+ durationMs: result.latencyMs,
69
+ message: failureText(ctx, probe, result),
70
+ });
71
+ }
72
+
73
+ function skippedRow(probe: ProbeDefinition, messageKey: string): ProbeResultRow {
74
+ return rowOf(probe, undefined, {
75
+ status: "skipped",
76
+ durationMs: null,
77
+ message: { key: messageKey },
78
+ });
79
+ }
80
+
81
+ async function runProbe(
82
+ ctx: ProbeContext,
83
+ probe: ProbeDefinition,
84
+ includePaid: boolean,
85
+ ): Promise<readonly ProbeResultRow[]> {
86
+ if (probe.cost === "paid" && !includePaid) {
87
+ return [skippedRow(probe, ProbeTextKeys.paidNotRequested)];
88
+ }
89
+ const { targets } = probe;
90
+ if (targets === undefined) return [await runOne(ctx, probe, undefined)];
91
+
92
+ const resolved = await runWithTimeout(() => targets(ctx), probeTimeoutMs(probe));
93
+ if (!resolved.ok) {
94
+ return [
95
+ rowOf(probe, undefined, {
96
+ status: "failed",
97
+ durationMs: resolved.latencyMs,
98
+ message: failureText(ctx, probe, resolved),
99
+ }),
100
+ ];
101
+ }
102
+ const ownTargets = resolved.value.filter(
103
+ (target) => target.ownerUserId === undefined || target.ownerUserId === ctx.triggeredByUserId,
104
+ );
105
+ if (ownTargets.length === 0) return [skippedRow(probe, ProbeTextKeys.noTargets)];
106
+ return Promise.all(ownTargets.map((target) => runOne(ctx, probe, target)));
107
+ }
108
+
109
+ export async function executeProbes(
110
+ ctx: ProbeContext,
111
+ probes: readonly ProbeDefinition[],
112
+ includePaid: boolean,
113
+ ): Promise<readonly ProbeResultRow[]> {
114
+ const perProbe = await Promise.all(probes.map((probe) => runProbe(ctx, probe, includePaid)));
115
+ return perProbe.flat();
116
+ }
@@ -9,14 +9,48 @@
9
9
  // `ready` boolean — the settings-checklist call for admin UIs.
10
10
 
11
11
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { registerBuiltInProbes } from "./built-in-probes.ts";
13
+ import { ReadinessWrites } from "./constants.ts";
14
+ import { latestResultsQuery } from "./handlers/latest-results.query.ts";
15
+ import { latestSummaryQuery } from "./handlers/latest-summary.query.ts";
16
+ import { runDetailQuery } from "./handlers/run-detail.query.ts";
17
+ import { runListQuery } from "./handlers/run-list.query.ts";
18
+ import { runProbesWrite } from "./handlers/run-probes.write.ts";
19
+ import { runResultsQuery } from "./handlers/run-results.query.ts";
12
20
  import { statusQuery } from "./handlers/status.query.ts";
21
+ import { READINESS_I18N } from "./i18n.ts";
22
+ import { validateProbeTranslations } from "./probe-boot-check.ts";
23
+ import { runProbesJob } from "./run-probes-job.ts";
24
+ import { probeRunEntity } from "./schema/probe-run.ts";
25
+ import {
26
+ SYSTEM_CHECK_NAV_LABEL,
27
+ SYSTEM_CHECK_SCREEN_ID,
28
+ SYSTEM_CHECK_SCREEN_QN,
29
+ systemCheckRunScreen,
30
+ systemCheckScreen,
31
+ } from "./screens.ts";
13
32
 
14
- export function createReadinessFeature() {
33
+ export interface ReadinessNavOptions {
34
+ readonly order?: number;
35
+ readonly parent?: string;
36
+ // Workspace apps need it: a nav belongs only to the workspaces it names, a parent's do not carry over.
37
+ readonly workspaces?: readonly string[];
38
+ }
39
+
40
+ export interface ReadinessFeatureOptions {
41
+ // Without a nav entry the system-check screen stays dormant; apps link it themselves.
42
+ readonly nav?: ReadinessNavOptions;
43
+ }
44
+
45
+ const DEFAULT_NAV_ORDER = 90;
46
+
47
+ export function createReadinessFeature(options: ReadinessFeatureOptions = {}) {
48
+ const { nav } = options;
15
49
  return defineFeature(
16
50
  "readiness",
17
51
  (r) => {
18
52
  r.describe(
19
- "One-call tenant-onboarding probe: `readiness:query:status` rolls up every config key and secret declared `required: true` across all mounted features and reports which still lack a usable value for the calling tenant, plus a single `ready` boolean. Provider-features under an `r.extensionSelector`-declared extension point count only while their provider is the selected one — a tenant on the inmemory mail transport is not blocked by unset SMTP keys. Mount it (together with `config` and `secrets`) when an admin UI needs a settings checklist before the first mail-send or file-write; the per-concern lists stay available via `config:query:readiness` and `secrets:query:list`.",
53
+ "One-call tenant-onboarding probe: `readiness:query:status` rolls up every config key and secret declared `required: true` across all mounted features and reports which still lack a usable value for the calling tenant, plus a single `ready` boolean. Provider-features under an `r.extensionSelector`-declared extension point count only while their provider is the selected one — a tenant on the inmemory mail transport is not blocked by unset SMTP keys. Mount it (together with `config` and `secrets`) when an admin UI needs a settings checklist before the first mail-send or file-write; the per-concern lists stay available via `config:query:readiness` and `secrets:query:list`. `readiness:write:run-probes` runs the live probes features register with `r.probe` (mail, files, mailboxes, PDF service and more) as a job; the system-check screen shows the latest run and the history once `nav` is passed.",
20
54
  );
21
55
  r.uiHints({
22
56
  displayLabel: "Readiness · Onboarding Probe",
@@ -25,13 +59,67 @@ export function createReadinessFeature() {
25
59
  });
26
60
  r.requires({ features: ["config"] });
27
61
  r.requires({ features: ["secrets"] });
62
+ r.optionalRequires("user");
63
+ r.adoptsBundledLists();
64
+
65
+ r.translations({ keys: READINESS_I18N });
66
+ r.entity({ name: "probe-run", ...probeRunEntity });
67
+ r.writeHandler(runProbesWrite);
68
+ r.job({
69
+ name: "run-probes",
70
+ trigger: { on: ReadinessWrites.runProbes },
71
+ runIn: "worker",
72
+ retries: 0,
73
+ escapeHatch: {
74
+ reason: "looks up the run starter's address via the system-only user auth lookup",
75
+ grants: ["systemIdentity"],
76
+ },
77
+ // Inline so the feature-AST reader can parse the job; the logic lives in run-probes-job.ts.
78
+ handler: (payload, ctx) => runProbesJob(payload, ctx),
79
+ });
80
+ registerBuiltInProbes(r);
81
+ r.bootCheck(({ features }) => validateProbeTranslations(features));
82
+
83
+ r.screen(systemCheckScreen(nav === undefined));
84
+ r.screen(systemCheckRunScreen);
85
+ if (nav !== undefined) {
86
+ // Explicit property assignments: the feature-AST parser reads neither `??`, shorthand
87
+ // properties nor a conditional spread, and the boot validator treats `parent: undefined`
88
+ // as a top-level entry.
89
+ const {
90
+ order: navOrder = DEFAULT_NAV_ORDER,
91
+ parent: navParent,
92
+ workspaces: navWorkspaces,
93
+ } = nav;
94
+ r.nav({
95
+ id: SYSTEM_CHECK_SCREEN_ID,
96
+ label: SYSTEM_CHECK_NAV_LABEL,
97
+ icon: "shield-check",
98
+ screen: SYSTEM_CHECK_SCREEN_QN,
99
+ order: navOrder,
100
+ parent: navParent,
101
+ workspaces: navWorkspaces,
102
+ });
103
+ }
28
104
 
29
105
  const queries = {
30
106
  status: r.queryHandler(statusQuery),
107
+ latestResults: r.queryHandler(latestResultsQuery),
108
+ latestSummary: r.queryHandler(latestSummaryQuery),
109
+ runList: r.queryHandler(runListQuery),
110
+ runDetail: r.queryHandler(runDetailQuery),
111
+ runResults: r.queryHandler(runResultsQuery),
31
112
  };
32
113
 
33
114
  return { queries };
34
115
  },
35
- { dedupeOptions: {} },
116
+ {
117
+ dedupeOptions: {
118
+ hasNav: nav !== undefined,
119
+ navOrder: nav?.order ?? DEFAULT_NAV_ORDER,
120
+ navParent: nav?.parent ?? "",
121
+ navWorkspaces: nav?.workspaces?.join(",") ?? "",
122
+ },
123
+ },
36
124
  );
37
125
  }
@@ -0,0 +1,15 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import { fetchLatestRun, viewRun } from "../run-view.ts";
4
+
5
+ export const latestResultsQuery = defineQueryHandler({
6
+ name: "probe-run:latest-results",
7
+ schema: z.object({}),
8
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
9
+ description:
10
+ "Returns the per-probe results of the tenant's latest system-check run (finished or still running) that the caller may see: system probes only for SystemAdmin, personal mailboxes only for their owner. Empty rows while the run is still working or when no run exists.",
11
+ handler: async (query, ctx) => {
12
+ const latest = await fetchLatestRun(ctx.db);
13
+ return { rows: latest ? viewRun(latest, query.user).results : [], nextCursor: null };
14
+ },
15
+ });
@@ -0,0 +1,53 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import { hasPaidProbes } from "../collect-probes.ts";
4
+ import { fetchLatestRun, viewRun } from "../run-view.ts";
5
+ import { countResults } from "../visible-results.ts";
6
+ import { toneOf } from "./run-tone.ts";
7
+
8
+ export const SUMMARY_TEXT_KEY = "readiness.summary.ok-of-total";
9
+ export const SUMMARY_RUNNING_TEXT_KEY = "readiness.summary.running";
10
+ export const SUMMARY_NO_RUN_TEXT_KEY = "readiness.summary.no-run";
11
+
12
+ export const latestSummaryQuery = defineQueryHandler({
13
+ name: "probe-run:latest-summary",
14
+ schema: z.object({}),
15
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
16
+ description:
17
+ "Returns the one-line summary of the tenant's latest system-check run (when it started, how many of the checks the caller may see passed, a tone and a ready-made translatable text) and whether the caller's check has paid probes. Zero counts and runId null when no run exists yet.",
18
+ handler: async (query, ctx) => {
19
+ const paid = hasPaidProbes(ctx.registry, {
20
+ includeSystemProbes: query.user.roles.includes("SystemAdmin"),
21
+ enabledFeatures: ctx.services.effectiveFeatures?.(query.user.tenantId),
22
+ });
23
+ const latest = await fetchLatestRun(ctx.db);
24
+ if (!latest) {
25
+ return {
26
+ // The system-check screen's record id only gates its sections; their queries ignore it.
27
+ id: query.user.tenantId,
28
+ runId: null,
29
+ hasPaidProbes: paid,
30
+ ...countResults([]),
31
+ text: { i18nKey: SUMMARY_NO_RUN_TEXT_KEY },
32
+ };
33
+ }
34
+ const { counts, outcome } = viewRun(latest, query.user);
35
+ return {
36
+ id: latest.id,
37
+ runId: latest.id,
38
+ hasPaidProbes: paid,
39
+ requestedAt: latest.requestedAt.toString(),
40
+ status: latest.status,
41
+ ...counts,
42
+ outcome,
43
+ tone: toneOf(outcome),
44
+ text:
45
+ outcome === "running"
46
+ ? { i18nKey: SUMMARY_RUNNING_TEXT_KEY }
47
+ : {
48
+ i18nKey: SUMMARY_TEXT_KEY,
49
+ i18nParams: { ok: counts.okCount, total: counts.totalCount },
50
+ },
51
+ };
52
+ },
53
+ });
@@ -0,0 +1,31 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { type RunRow, viewRun } from "../run-view.ts";
5
+ import { probeRunsTable } from "../schema/probe-run.ts";
6
+ import { toneOf } from "./run-tone.ts";
7
+
8
+ export const runDetailQuery = defineQueryHandler({
9
+ name: "probe-run:detail",
10
+ schema: z.object({ runId: z.uuid() }),
11
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
12
+ description:
13
+ "Returns the header of one system-check run of the caller's tenant: status, timing, starter and the outcome counts of the results the caller may see.",
14
+ handler: async (query, ctx) => {
15
+ const run = await ctx.db.fetchOne<RunRow>(probeRunsTable, { id: query.payload.runId });
16
+ if (!run) throw new NotFoundError("probe-run", query.payload.runId);
17
+ const { counts, outcome } = viewRun(run, query.user);
18
+ return {
19
+ id: run.id,
20
+ status: run.status,
21
+ requestedAt: run.requestedAt.toString(),
22
+ startedAt: run.startedAt?.toString() ?? null,
23
+ finishedAt: run.finishedAt?.toString() ?? null,
24
+ triggeredByUserId: run.triggeredByUserId,
25
+ durationMs: run.durationMs,
26
+ ...counts,
27
+ outcome,
28
+ tone: toneOf(outcome),
29
+ };
30
+ },
31
+ });
@@ -0,0 +1,48 @@
1
+ import {
2
+ defineQueryHandler,
3
+ pagedListSchema,
4
+ pageRows,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { resolveUserDisplayNames, USER_FEATURE } from "../../user/index.ts";
7
+ import { HISTORY_LIMIT, type RunRow, viewRun } from "../run-view.ts";
8
+ import { probeRunsTable } from "../schema/probe-run.ts";
9
+
10
+ const SORT_FIELDS = ["requestedAt"] as const;
11
+
12
+ export const runListQuery = defineQueryHandler({
13
+ name: "probe-run:list",
14
+ schema: pagedListSchema(SORT_FIELDS),
15
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
16
+ description: `Lists the tenant's most recent system-check runs (up to ${HISTORY_LIMIT}), newest first, with starter (display name when the user feature is mounted), status, duration and the outcome counts of the results the caller may see.`,
17
+ handler: async (query, ctx) => {
18
+ const runs = await ctx.db.selectMany<RunRow>(
19
+ probeRunsTable,
20
+ {},
21
+ { limit: HISTORY_LIMIT, orderBy: { col: "requestedAt", direction: "desc" } },
22
+ );
23
+ const starterIds = [...new Set(runs.map((run) => run.triggeredByUserId))];
24
+ const starterNames =
25
+ ctx.registry.getFeature(USER_FEATURE) === undefined
26
+ ? new Map<string, string>()
27
+ : await resolveUserDisplayNames(ctx.db, starterIds);
28
+ const rows = runs.map((run) => {
29
+ const { counts, outcome } = viewRun(run, query.user);
30
+ return {
31
+ id: run.id,
32
+ requestedAt: run.requestedAt.toString(),
33
+ triggeredByUserId: run.triggeredByUserId,
34
+ triggeredByName: starterNames.get(run.triggeredByUserId) ?? run.triggeredByUserId,
35
+ status: run.status,
36
+ okCount: counts.okCount,
37
+ failedCount: counts.failedCount,
38
+ durationMs: run.durationMs,
39
+ outcome,
40
+ };
41
+ });
42
+ return pageRows(
43
+ rows,
44
+ { ...query.payload, sortDirection: query.payload.sortDirection ?? "desc" },
45
+ { searchFields: ["requestedAt"], defaultSort: "requestedAt" },
46
+ );
47
+ },
48
+ });