@cosmicdrift/kumiko-samples 0.390.0 → 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +3 -3
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/event-classification.ts +5 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +15 -1
- package/packages/bundled-features/src/audit/handlers/details.query.ts +11 -4
- package/packages/bundled-features/src/audit/handlers/list.query.ts +1 -0
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +11 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +3 -0
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/feature.ts +6 -0
- package/packages/bundled-features/src/file-foundation/i18n.ts +13 -0
- package/packages/bundled-features/src/file-foundation/index.ts +4 -0
- package/packages/bundled-features/src/file-foundation/probe.ts +68 -0
- package/packages/bundled-features/src/{user-data-rights/lib → file-foundation}/storage-provider-resolver.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe-keys.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +109 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +3 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -11
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +16 -0
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +2 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +27 -0
- package/packages/bundled-features/src/mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/{user-data-rights/lib → mail-foundation}/mail-transport-resolver.ts +2 -2
- package/packages/bundled-features/src/mail-foundation/probe.ts +81 -0
- package/packages/bundled-features/src/readiness/built-in-probes.ts +58 -0
- package/packages/bundled-features/src/readiness/changes.json +7 -0
- package/packages/bundled-features/src/readiness/collect-probes.ts +47 -0
- package/packages/bundled-features/src/readiness/constants.ts +25 -0
- package/packages/bundled-features/src/readiness/execute-probes.ts +116 -0
- package/packages/bundled-features/src/readiness/feature.ts +91 -3
- package/packages/bundled-features/src/readiness/handlers/latest-results.query.ts +15 -0
- package/packages/bundled-features/src/readiness/handlers/latest-summary.query.ts +53 -0
- package/packages/bundled-features/src/readiness/handlers/run-detail.query.ts +31 -0
- package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +48 -0
- package/packages/bundled-features/src/readiness/handlers/run-probes.write.ts +98 -0
- package/packages/bundled-features/src/readiness/handlers/run-results.query.ts +18 -0
- package/packages/bundled-features/src/readiness/handlers/run-tone.ts +6 -0
- package/packages/bundled-features/src/readiness/i18n.ts +55 -0
- package/packages/bundled-features/src/readiness/index.ts +1 -1
- package/packages/bundled-features/src/readiness/probe-boot-check.ts +53 -0
- package/packages/bundled-features/src/readiness/probe-context.ts +57 -0
- package/packages/bundled-features/src/readiness/run-probes-job.ts +104 -0
- package/packages/bundled-features/src/readiness/run-view.ts +48 -0
- package/packages/bundled-features/src/readiness/schema/probe-run.ts +89 -0
- package/packages/bundled-features/src/readiness/screens.ts +191 -0
- package/packages/bundled-features/src/readiness/visible-results.ts +106 -0
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/membership-stream-history.ts +224 -0
- package/packages/bundled-features/src/tenant/membership-table.ts +3 -3
- package/packages/bundled-features/src/tenant/seeding.ts +8 -13
- package/packages/bundled-features/src/tenant/stream-tenant-backfill.ts +45 -1
- package/packages/bundled-features/src/user-data-rights/feature.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-profile/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/feature.ts +20 -0
- package/packages/bundled-features/src/user-profile/web/constants.ts +5 -0
- package/packages/bundled-features/src/user-profile/web/i18n.ts +2 -0
- package/samples/apps/cap-billing-demo/package.json +1 -1
- package/samples/apps/ui-walkthrough/package.json +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +23 -4
- package/samples/apps/use-all-bundled/kumiko/migrations/0054_readiness-probe-runs.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -1
- package/samples/apps/use-all-bundled/src/app/admin-nav-feature.ts +27 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +3 -1
- package/samples/apps/user-data-rights-demo/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Starts a system-check run. The job on the worker lane (run-probes-job.ts) executes it after commit.
|
|
2
|
+
//
|
|
3
|
+
// Idempotency: an app-side pre-check returns the active run; the partial UNIQUE index
|
|
4
|
+
// (ACTIVE_PROBE_RUN_CONSTRAINT) catches a click that slips in between check and create (23505).
|
|
5
|
+
|
|
6
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
11
|
+
import * as z from "zod";
|
|
12
|
+
import { staleRunAfterMs } from "../collect-probes.ts";
|
|
13
|
+
import {
|
|
14
|
+
ACTIVE_PROBE_RUN_CONSTRAINT,
|
|
15
|
+
PROBE_RUN_STATUS,
|
|
16
|
+
probeRunEntity,
|
|
17
|
+
probeRunsTable,
|
|
18
|
+
} from "../schema/probe-run.ts";
|
|
19
|
+
|
|
20
|
+
const crud = createEventStoreExecutor(probeRunsTable, probeRunEntity, {
|
|
21
|
+
entityName: "probe-run",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ACTIVE_STATUSES = [PROBE_RUN_STATUS.Pending, PROBE_RUN_STATUS.Running];
|
|
25
|
+
|
|
26
|
+
type ActiveRunRow = {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly version: number;
|
|
29
|
+
readonly requestedAt: { readonly epochMilliseconds: number };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isActiveRunConflict(failure: WriteFailure): boolean {
|
|
33
|
+
return (
|
|
34
|
+
failure.error.code === "unique_violation" &&
|
|
35
|
+
propertyOf(failure.error.details, "constraintName") === ACTIVE_PROBE_RUN_CONSTRAINT
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const runProbesWrite = defineWriteHandler({
|
|
40
|
+
name: "run-probes",
|
|
41
|
+
schema: z.object({ includePaid: z.boolean().default(false) }),
|
|
42
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
43
|
+
description:
|
|
44
|
+
"Starts the system check: real calls against mail, storage, search, inbound mailboxes and PDF that show what works in this environment. Returns the run id (isExisting true while a run is still active); the worker fills the run, read the outcome via readiness:query:probe-run:latest-results. Paid probes only run with includePaid true.",
|
|
45
|
+
handler: async (event, ctx) => {
|
|
46
|
+
if (!ctx.services.jobRunner) {
|
|
47
|
+
throw new InternalError({
|
|
48
|
+
message: "readiness:run-probes needs a job runner; mount the jobs infrastructure",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const findActive = () =>
|
|
53
|
+
ctx.db.fetchOne<ActiveRunRow>(probeRunsTable, { status: ACTIVE_STATUSES });
|
|
54
|
+
|
|
55
|
+
const nowMs = ctx.tz.now().epochMilliseconds;
|
|
56
|
+
const active = await findActive();
|
|
57
|
+
if (active) {
|
|
58
|
+
if (nowMs - active.requestedAt.epochMilliseconds <= staleRunAfterMs(ctx.registry)) {
|
|
59
|
+
return { isSuccess: true as const, data: { runId: active.id, isExisting: true } };
|
|
60
|
+
}
|
|
61
|
+
const abandoned = await crud.update(
|
|
62
|
+
{
|
|
63
|
+
id: active.id,
|
|
64
|
+
version: active.version,
|
|
65
|
+
changes: { status: PROBE_RUN_STATUS.Abandoned, finishedAt: ctx.tz.now() },
|
|
66
|
+
},
|
|
67
|
+
event.user,
|
|
68
|
+
ctx.db,
|
|
69
|
+
);
|
|
70
|
+
if (!abandoned.isSuccess) return abandoned;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const created = await crud.create(
|
|
74
|
+
{
|
|
75
|
+
status: PROBE_RUN_STATUS.Pending,
|
|
76
|
+
includePaid: event.payload.includePaid,
|
|
77
|
+
includeSystemProbes: event.user.roles.includes("SystemAdmin"),
|
|
78
|
+
triggeredByUserId: event.user.id,
|
|
79
|
+
requestedAt: ctx.tz.now(),
|
|
80
|
+
results: { rows: [] },
|
|
81
|
+
tenantId: event.user.tenantId,
|
|
82
|
+
},
|
|
83
|
+
event.user,
|
|
84
|
+
ctx.db,
|
|
85
|
+
);
|
|
86
|
+
if (created.isSuccess) {
|
|
87
|
+
return {
|
|
88
|
+
isSuccess: true as const,
|
|
89
|
+
data: { runId: String(created.data.id), isExisting: false },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!isActiveRunConflict(created)) return created;
|
|
93
|
+
|
|
94
|
+
const winner = await findActive();
|
|
95
|
+
if (!winner) return created;
|
|
96
|
+
return { isSuccess: true as const, data: { runId: winner.id, isExisting: true } };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
|
|
7
|
+
export const runResultsQuery = defineQueryHandler({
|
|
8
|
+
name: "probe-run:results",
|
|
9
|
+
schema: z.object({ runId: z.uuid() }),
|
|
10
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
11
|
+
description:
|
|
12
|
+
"Returns the per-probe results of one system-check run of the caller's tenant, filtered to what the caller may see: system probes only for SystemAdmin, personal mailboxes only for their owner.",
|
|
13
|
+
handler: async (query, ctx) => {
|
|
14
|
+
const run = await ctx.db.fetchOne<RunRow>(probeRunsTable, { id: query.payload.runId });
|
|
15
|
+
if (!run) throw new NotFoundError("probe-run", query.payload.runId);
|
|
16
|
+
return { rows: viewRun(run, query.user).results, nextCursor: null };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
type LocalizedString = { readonly en: string };
|
|
2
|
+
|
|
3
|
+
export const READINESS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
|
+
"readiness.area.infrastructure": { en: "Infrastructure" },
|
|
5
|
+
"readiness.area.run": { en: "System check" },
|
|
6
|
+
"readiness.probe.run": { en: "Run of the system check" },
|
|
7
|
+
"readiness.probe.database.label": { en: "Database" },
|
|
8
|
+
"readiness.probe.database.ok": { en: "Database reachable" },
|
|
9
|
+
"readiness.probe.cache.label": { en: "Cache" },
|
|
10
|
+
"readiness.probe.cache.ok": { en: "Cache reachable" },
|
|
11
|
+
"readiness.probe.search.label": { en: "Search" },
|
|
12
|
+
"readiness.probe.search.ok": { en: "Search reachable" },
|
|
13
|
+
"readiness.probe.paid-not-requested": { en: "Not run, paid checks were not requested" },
|
|
14
|
+
"readiness.probe.no-targets": { en: "Nothing to check" },
|
|
15
|
+
"readiness.probe.timeout": { en: "No answer after {seconds} seconds" },
|
|
16
|
+
"readiness.probe.failed": { en: "The check failed" },
|
|
17
|
+
"readiness.probe.run-crashed": { en: "The check run stopped unexpectedly" },
|
|
18
|
+
"readiness.probe.not-configured": { en: "Not set up" },
|
|
19
|
+
"readiness.summary.ok-of-total": { en: "{ok} of {total} checks passed" },
|
|
20
|
+
"readiness.summary.running": { en: "The check is running" },
|
|
21
|
+
"readiness.summary.no-run": { en: "No check has run yet" },
|
|
22
|
+
"readiness:nav.systemCheck": { en: "System check" },
|
|
23
|
+
"screen:system-check.title": { en: "System check" },
|
|
24
|
+
"screen:system-check-run.title": { en: "System check run" },
|
|
25
|
+
"readiness.screen.action.run": { en: "Start check" },
|
|
26
|
+
"readiness.screen.action.run-paid": { en: "Test AI extraction · about 1 cent" },
|
|
27
|
+
"readiness.screen.latest.title": { en: "Latest run" },
|
|
28
|
+
"readiness.screen.latest.description": {
|
|
29
|
+
en: "Real calls against mail, storage, search and the other connected services. A failed row links to the screen that fixes it.",
|
|
30
|
+
},
|
|
31
|
+
"readiness.screen.latest.empty": { en: "No results yet. Start a check." },
|
|
32
|
+
"readiness.screen.history.title": { en: "History" },
|
|
33
|
+
"readiness.screen.history.description": {
|
|
34
|
+
en: "Earlier runs, newest first. Open one to see its results.",
|
|
35
|
+
},
|
|
36
|
+
"readiness.screen.history.empty": { en: "No check has run yet." },
|
|
37
|
+
"readiness.screen.run.results-title": { en: "Results" },
|
|
38
|
+
"readiness.screen.metric.ok": { en: "Passed" },
|
|
39
|
+
"readiness.screen.metric.failed": { en: "Failed" },
|
|
40
|
+
"readiness.screen.metric.skipped": { en: "Not run" },
|
|
41
|
+
"readiness.screen.column.area": { en: "Area" },
|
|
42
|
+
"readiness.screen.column.check": { en: "Check" },
|
|
43
|
+
"readiness.screen.column.status": { en: "Status" },
|
|
44
|
+
"readiness.screen.column.duration": { en: "Duration (ms)" },
|
|
45
|
+
"readiness.screen.column.result": { en: "Result" },
|
|
46
|
+
"readiness.screen.column.started": { en: "Started" },
|
|
47
|
+
"readiness.screen.column.started-by": { en: "By" },
|
|
48
|
+
"readiness.screen.column.outcome": { en: "Outcome" },
|
|
49
|
+
"readiness.result-status.ok": { en: "OK" },
|
|
50
|
+
"readiness.result-status.failed": { en: "Failed" },
|
|
51
|
+
"readiness.result-status.skipped": { en: "Not run" },
|
|
52
|
+
"readiness.outcome.ok": { en: "No failures" },
|
|
53
|
+
"readiness.outcome.failed": { en: "Failures" },
|
|
54
|
+
"readiness.outcome.running": { en: "Running" },
|
|
55
|
+
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { READINESS_FEATURE, ReadinessQueries } from "./constants.ts";
|
|
1
|
+
export { READINESS_FEATURE, ReadinessQueries, ReadinessWrites } from "./constants.ts";
|
|
2
2
|
export { createReadinessFeature } from "./feature.ts";
|
|
3
3
|
export type { ReadinessMissingSecret } from "./handlers/status.query.ts";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildConfigFeatureSchema,
|
|
3
|
+
createRegistry,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
SETTINGS_HUB_FEATURE,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// Settings screens come from config keys at schema build, not from r.screen, yet a probe may
|
|
9
|
+
// link a failed row to one (mail sending lives on config:screen:mail-foundation-tenant).
|
|
10
|
+
function mountedScreenQns(features: readonly FeatureDefinition[]): ReadonlySet<string> {
|
|
11
|
+
const generatedSettings = buildConfigFeatureSchema(createRegistry(features)).screens.map(
|
|
12
|
+
(screen) => `${SETTINGS_HUB_FEATURE}:screen:${screen.id}`,
|
|
13
|
+
);
|
|
14
|
+
return new Set([
|
|
15
|
+
...features.flatMap((feature) =>
|
|
16
|
+
Object.keys(feature.screens).map((screenId) => `${feature.name}:screen:${screenId}`),
|
|
17
|
+
),
|
|
18
|
+
...generatedSettings,
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A probe whose area or label has no translation would render a raw key in the result table,
|
|
23
|
+
// and one whose fixScreen names no mounted screen would link a failed row nowhere.
|
|
24
|
+
export function validateProbeTranslations(features: readonly FeatureDefinition[]): void {
|
|
25
|
+
const screenQns = mountedScreenQns(features);
|
|
26
|
+
const defined = new Set<string>();
|
|
27
|
+
for (const feature of features) {
|
|
28
|
+
for (const key of Object.keys(feature.translations)) {
|
|
29
|
+
defined.add(key);
|
|
30
|
+
const colon = key.indexOf(":");
|
|
31
|
+
if (colon > 0) defined.add(key.slice(colon + 1));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const feature of features) {
|
|
35
|
+
for (const probe of Object.values(feature.probes ?? {})) {
|
|
36
|
+
for (const [field, key] of [
|
|
37
|
+
["area", probe.area],
|
|
38
|
+
["label", probe.label],
|
|
39
|
+
] as const) {
|
|
40
|
+
if (!defined.has(key)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Probe "${probe.qualifiedName}": ${field} translation key "${key}" is not defined by any mounted feature. Add it via r.translations(...).`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (probe.fixScreen !== undefined && !screenQns.has(probe.fixScreen)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Probe "${probe.qualifiedName}": fixScreen "${probe.fixScreen}" is not a screen of any mounted feature. Use a "<feature>:screen:<id>" QN.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSystemUser,
|
|
3
|
+
type JobContext,
|
|
4
|
+
type ProbeContext,
|
|
5
|
+
type TenantId,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { decryptStoredUserEmail } from "../shared/index.ts";
|
|
9
|
+
import { UserQueries } from "../user/index.ts";
|
|
10
|
+
|
|
11
|
+
const triggeringUserRowSchema = z.object({
|
|
12
|
+
email: z.string().optional(),
|
|
13
|
+
isDeleted: z.boolean().nullish(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function resolveTriggeredByEmail(
|
|
17
|
+
ctx: JobContext,
|
|
18
|
+
tenantId: TenantId,
|
|
19
|
+
userId: string,
|
|
20
|
+
): Promise<string | undefined> {
|
|
21
|
+
if (!ctx.registry.features.has("user")) return undefined;
|
|
22
|
+
let found: unknown;
|
|
23
|
+
try {
|
|
24
|
+
found = await ctx.dispatch.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, {
|
|
25
|
+
id: userId,
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
// Probes that need the address skip themselves; the lookup must not take the whole run down.
|
|
29
|
+
ctx.obs.log.warn("readiness could not look up the run starter", {
|
|
30
|
+
error: error instanceof Error ? error.name : "unknown",
|
|
31
|
+
});
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const row = triggeringUserRowSchema.safeParse(found);
|
|
35
|
+
if (!row.success || row.data.email === undefined || row.data.isDeleted === true) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return (await decryptStoredUserEmail(row.data.email, "readiness:run-probes")) ?? undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function buildProbeContext(
|
|
42
|
+
ctx: JobContext,
|
|
43
|
+
run: { readonly id: string; readonly triggeredByUserId: string },
|
|
44
|
+
tenantId: TenantId,
|
|
45
|
+
): Promise<ProbeContext> {
|
|
46
|
+
return {
|
|
47
|
+
db: ctx.db,
|
|
48
|
+
registry: ctx.registry,
|
|
49
|
+
obs: ctx.obs,
|
|
50
|
+
tz: ctx.tz,
|
|
51
|
+
services: ctx.services,
|
|
52
|
+
probeRunId: run.id,
|
|
53
|
+
tenantId,
|
|
54
|
+
triggeredByUserId: run.triggeredByUserId,
|
|
55
|
+
triggeredByEmail: await resolveTriggeredByEmail(ctx, tenantId, run.triggeredByUserId),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createSystemUser, type JobContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { collectProbes } from "./collect-probes.ts";
|
|
4
|
+
import { ProbeTextKeys } from "./constants.ts";
|
|
5
|
+
import { executeProbes } from "./execute-probes.ts";
|
|
6
|
+
import { buildProbeContext } from "./probe-context.ts";
|
|
7
|
+
import {
|
|
8
|
+
PROBE_RUN_STATUS,
|
|
9
|
+
type ProbeResultRow,
|
|
10
|
+
probeRunEntity,
|
|
11
|
+
probeRunsTable,
|
|
12
|
+
} from "./schema/probe-run.ts";
|
|
13
|
+
|
|
14
|
+
const crud = createEventStoreExecutor(probeRunsTable, probeRunEntity, {
|
|
15
|
+
entityName: "probe-run",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type PendingRunRow = {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly version: number;
|
|
21
|
+
readonly includePaid: boolean;
|
|
22
|
+
readonly includeSystemProbes: boolean;
|
|
23
|
+
readonly triggeredByUserId: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function countStatus(rows: readonly ProbeResultRow[], status: ProbeResultRow["status"]): number {
|
|
27
|
+
return rows.filter((row) => row.status === status).length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Runs the pending run of the job's tenant; the write handler guarantees at most one active run per tenant.
|
|
31
|
+
export async function runProbesJob(_payload: Record<string, unknown>, ctx: JobContext) {
|
|
32
|
+
const tenantId = ctx.db.tenantId;
|
|
33
|
+
const run = await ctx.db.fetchOne<PendingRunRow>(probeRunsTable, {
|
|
34
|
+
status: PROBE_RUN_STATUS.Pending,
|
|
35
|
+
});
|
|
36
|
+
// skip: no pending run left, e.g. a second trigger after the first job already claimed it.
|
|
37
|
+
if (!run) return;
|
|
38
|
+
|
|
39
|
+
const executor = createSystemUser(tenantId);
|
|
40
|
+
const startedAt = ctx.tz.now();
|
|
41
|
+
const claimed = await crud.update(
|
|
42
|
+
{
|
|
43
|
+
id: run.id,
|
|
44
|
+
version: run.version,
|
|
45
|
+
changes: { status: PROBE_RUN_STATUS.Running, startedAt },
|
|
46
|
+
},
|
|
47
|
+
executor,
|
|
48
|
+
ctx.db,
|
|
49
|
+
);
|
|
50
|
+
// skip: a second job for the same run claimed it first
|
|
51
|
+
if (!claimed.isSuccess) return;
|
|
52
|
+
|
|
53
|
+
let rows: readonly ProbeResultRow[] = [];
|
|
54
|
+
try {
|
|
55
|
+
const probes = collectProbes(ctx.registry, {
|
|
56
|
+
includeSystemProbes: run.includeSystemProbes,
|
|
57
|
+
enabledFeatures: ctx.services.effectiveFeatures?.(tenantId),
|
|
58
|
+
});
|
|
59
|
+
const probeContext = await buildProbeContext(ctx, run, tenantId);
|
|
60
|
+
rows = await executeProbes(probeContext, probes, run.includePaid);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
ctx.obs.log.warn("readiness run crashed", {
|
|
63
|
+
runId: run.id,
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
});
|
|
66
|
+
rows = [
|
|
67
|
+
...rows,
|
|
68
|
+
{
|
|
69
|
+
probe: "readiness:probe:run",
|
|
70
|
+
scope: "tenant",
|
|
71
|
+
area: "readiness.area.run",
|
|
72
|
+
label: { key: "readiness.probe.run" },
|
|
73
|
+
status: "failed",
|
|
74
|
+
durationMs: null,
|
|
75
|
+
message: { key: ProbeTextKeys.runCrashed },
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
} finally {
|
|
79
|
+
const finishedAt = ctx.tz.now();
|
|
80
|
+
const done = await crud.update(
|
|
81
|
+
{
|
|
82
|
+
id: run.id,
|
|
83
|
+
version: run.version + 1,
|
|
84
|
+
changes: {
|
|
85
|
+
status: PROBE_RUN_STATUS.Done,
|
|
86
|
+
finishedAt,
|
|
87
|
+
durationMs: finishedAt.epochMilliseconds - startedAt.epochMilliseconds,
|
|
88
|
+
okCount: countStatus(rows, "ok"),
|
|
89
|
+
failedCount: countStatus(rows, "failed"),
|
|
90
|
+
skippedCount: countStatus(rows, "skipped"),
|
|
91
|
+
results: { rows },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
executor,
|
|
95
|
+
ctx.db,
|
|
96
|
+
);
|
|
97
|
+
if (!done.isSuccess) {
|
|
98
|
+
ctx.obs.log.warn("readiness run could not be finished", {
|
|
99
|
+
runId: run.id,
|
|
100
|
+
code: done.error.code,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { PROBE_RUN_STATUS, probeRunsTable } from "./schema/probe-run.ts";
|
|
4
|
+
import {
|
|
5
|
+
countResults,
|
|
6
|
+
outcomeOf,
|
|
7
|
+
type ResultCounts,
|
|
8
|
+
type RunOutcome,
|
|
9
|
+
type VisibleProbeResult,
|
|
10
|
+
visibleResults,
|
|
11
|
+
} from "./visible-results.ts";
|
|
12
|
+
|
|
13
|
+
type InstantLike = { readonly toString: () => string };
|
|
14
|
+
|
|
15
|
+
export type RunRow = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly status: string;
|
|
18
|
+
readonly requestedAt: InstantLike;
|
|
19
|
+
readonly startedAt: InstantLike | null;
|
|
20
|
+
readonly finishedAt: InstantLike | null;
|
|
21
|
+
readonly triggeredByUserId: string;
|
|
22
|
+
readonly durationMs: number | null;
|
|
23
|
+
readonly results: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RunView = {
|
|
27
|
+
readonly results: readonly VisibleProbeResult[];
|
|
28
|
+
readonly counts: ResultCounts;
|
|
29
|
+
readonly outcome: RunOutcome;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const HISTORY_LIMIT = 100;
|
|
33
|
+
|
|
34
|
+
export async function fetchLatestRun(db: TenantDb): Promise<RunRow | undefined> {
|
|
35
|
+
const [latest] = await db.selectMany<RunRow>(
|
|
36
|
+
probeRunsTable,
|
|
37
|
+
{ status: [PROBE_RUN_STATUS.Pending, PROBE_RUN_STATUS.Running, PROBE_RUN_STATUS.Done] },
|
|
38
|
+
{ limit: 1, orderBy: { col: "requestedAt", direction: "desc" } },
|
|
39
|
+
);
|
|
40
|
+
return latest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Counts come from the results the caller may see, so a TenantAdmin never learns about system failures.
|
|
44
|
+
export function viewRun(run: RunRow, user: SessionUser): RunView {
|
|
45
|
+
const results = visibleResults(run.results, user);
|
|
46
|
+
const counts = countResults(results);
|
|
47
|
+
return { results, counts, outcome: outcomeOf(run.status, counts) };
|
|
48
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { buildEntityTable, sql } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { ProbeScope, ProbeText } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import {
|
|
4
|
+
createBooleanField,
|
|
5
|
+
createEntity,
|
|
6
|
+
createJsonbField,
|
|
7
|
+
createNumberField,
|
|
8
|
+
createSelectField,
|
|
9
|
+
createTextField,
|
|
10
|
+
createTimestampField,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
|
|
13
|
+
export const PROBE_RUN_STATUS = {
|
|
14
|
+
Pending: "pending",
|
|
15
|
+
Running: "running",
|
|
16
|
+
Done: "done",
|
|
17
|
+
Abandoned: "abandoned",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type ProbeRunStatus = (typeof PROBE_RUN_STATUS)[keyof typeof PROBE_RUN_STATUS];
|
|
21
|
+
|
|
22
|
+
export const ACTIVE_PROBE_RUN_CONSTRAINT = "read_readiness_probe_runs_one_active_per_tenant";
|
|
23
|
+
|
|
24
|
+
const PROBE_RUN_STATUS_OPTIONS = [
|
|
25
|
+
PROBE_RUN_STATUS.Pending,
|
|
26
|
+
PROBE_RUN_STATUS.Running,
|
|
27
|
+
PROBE_RUN_STATUS.Done,
|
|
28
|
+
PROBE_RUN_STATUS.Abandoned,
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type ProbeResultStatus = "ok" | "failed" | "skipped";
|
|
32
|
+
|
|
33
|
+
// Texts are i18n keys plus short scalar params, never raw error messages or addresses.
|
|
34
|
+
export type ProbeResultRow = {
|
|
35
|
+
readonly probe: string;
|
|
36
|
+
readonly scope: ProbeScope;
|
|
37
|
+
readonly area: string;
|
|
38
|
+
readonly label: ProbeText;
|
|
39
|
+
readonly targetId?: string;
|
|
40
|
+
readonly ownerUserId?: string;
|
|
41
|
+
readonly status: ProbeResultStatus;
|
|
42
|
+
readonly durationMs: number | null;
|
|
43
|
+
readonly message: ProbeText;
|
|
44
|
+
readonly fixScreen?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// A jsonb field only accepts a plain object at the write boundary, so the rows sit under one key.
|
|
48
|
+
export type ProbeRunResults = { readonly rows: readonly ProbeResultRow[] };
|
|
49
|
+
|
|
50
|
+
export const probeRunEntity = createEntity({
|
|
51
|
+
table: "read_readiness_probe_runs",
|
|
52
|
+
idType: "uuid",
|
|
53
|
+
description:
|
|
54
|
+
"One system-check run of a tenant: who started it, whether paid and system probes were requested, its pending/running/done/abandoned status, the timing and the per-probe results with their translated texts and outcome counts.",
|
|
55
|
+
|
|
56
|
+
fields: {
|
|
57
|
+
status: createSelectField({
|
|
58
|
+
required: true,
|
|
59
|
+
default: PROBE_RUN_STATUS.Pending,
|
|
60
|
+
options: PROBE_RUN_STATUS_OPTIONS,
|
|
61
|
+
}),
|
|
62
|
+
includePaid: createBooleanField({}),
|
|
63
|
+
includeSystemProbes: createBooleanField({}),
|
|
64
|
+
triggeredByUserId: createTextField({
|
|
65
|
+
required: true,
|
|
66
|
+
personal: false,
|
|
67
|
+
reason: "pseudonymous_fk",
|
|
68
|
+
}),
|
|
69
|
+
requestedAt: createTimestampField({ required: true, sortable: true }),
|
|
70
|
+
startedAt: createTimestampField({}),
|
|
71
|
+
finishedAt: createTimestampField({}),
|
|
72
|
+
durationMs: createNumberField({ integer: true }),
|
|
73
|
+
okCount: createNumberField({ integer: true }),
|
|
74
|
+
failedCount: createNumberField({ integer: true }),
|
|
75
|
+
skippedCount: createNumberField({ integer: true }),
|
|
76
|
+
results: createJsonbField({ personal: false, reason: "is_business_data" }),
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
indexes: [
|
|
80
|
+
{
|
|
81
|
+
unique: true,
|
|
82
|
+
columns: ["tenantId"],
|
|
83
|
+
name: ACTIVE_PROBE_RUN_CONSTRAINT,
|
|
84
|
+
where: sql`status IN ('pending', 'running')`,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const probeRunsTable = buildEntityTable("probeRun", probeRunEntity);
|