Guide module installer lifecycle

This commit is contained in:
2026-08-03 08:44:51 +02:00
parent fef4e10afd
commit 6dbccd5e08
7 changed files with 434 additions and 6 deletions
+9
View File
@@ -48,6 +48,15 @@ admin UI records operator intent and queues or renders commands for the trusted
installer process described in installer process described in
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
The WebUI presents the lifecycle as five derived stages: plan, preflight,
installer request, daemon execution, and run evidence. The projection resets
when the saved plan changes and associates evidence only with an installer
request created at or after the current plan revision. It therefore cannot make
an old successful run look like evidence for a new plan. The earliest queue
blocker is shown through Core's actionable blocker pattern with the required
action, responsible operator or administrator, and destination. Contextual help
uses the stable `admin.module-lifecycle-workflow` documentation topic.
## Package Surfaces ## Package Surfaces
The admin UI intentionally exposes two different package concepts: The admin UI intentionally exposes two different package concepts:
+21
View File
@@ -42,6 +42,27 @@ manifest = ModuleManifest(
related_modules=("access", "audit", "ops"), related_modules=("access", "audit", "ops"),
metadata={"kind": "reference"}, metadata={"kind": "reference"},
), ),
DocumentationTopic(
id="admin.module-lifecycle-workflow",
title="Plan and supervise module lifecycle changes",
summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.",
body=(
"The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. "
"The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. "
"Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "operator", "module_admin"),
related_modules=("ops", "audit"),
metadata={
"kind": "workflow",
"context_ids": [
"admin.module-lifecycle",
"admin.module-lifecycle.queue-blocker",
],
},
),
), ),
frontend=FrontendModule( frontend=FrontendModule(
module_id="admin", module_id="admin",
+3
View File
@@ -12,6 +12,9 @@
"import": "./src/index.ts" "import": "./src/index.ts"
} }
}, },
"scripts": {
"test:installer-workflow": "node --experimental-strip-types --test tests/module-installer-workflow.test.ts"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.9", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, formatDateTime, MetricCard, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui"; import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui";
import { Check, Clock, FileText, Pencil, Send } from "lucide-react";
import { import {
cancelModuleInstallerRequest, cancelModuleInstallerRequest,
clearModuleInstallPlan, clearModuleInstallPlan,
@@ -33,6 +34,22 @@ import {
type ModulePackageCatalogResponse, type ModulePackageCatalogResponse,
type ModulePackageCatalogItem } from type ModulePackageCatalogItem } from
"../../api/admin"; "../../api/admin";
import {
installerRequestMatchesPlan,
moduleInstallerQueueBlock,
moduleInstallerWorkflowStages,
type ModuleInstallerQueueBlock,
type ModuleInstallerWorkflowStageId,
type ModuleInstallerWorkflowStageState
} from "./moduleInstallerWorkflow";
const MODULE_INSTALLER_I18N = {
queueUnavailable: "i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202",
operatorPlan: "i18n:govoplan-admin.operator_install_plan.b203aabc",
requiredAction: "i18n:govoplan-admin.required_action.f1a20203",
responsibleActor: "i18n:govoplan-admin.who_can_fix_it.f1a20204",
resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205"
} as const;
export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) { export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) {
const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null); const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null);
@@ -86,6 +103,34 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled); const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled);
const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items))); const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
const planValid = planValidationError(draftPlanItems) === ""; const planValid = planValidationError(draftPlanItems) === "";
const latestInstallerRequest = installerRequests?.requests[0] ?? null;
const currentInstallerRequest = latestInstallerRequest && installerRequestMatchesPlan(
installPlan?.updated_at,
latestInstallerRequest.created_at
) ? latestInstallerRequest : null;
const currentInstallerRun = currentInstallerRequest
? installerRuns?.runs.find((run) => run.request_id === currentInstallerRequest.request_id) ?? null
: null;
const installerWorkflowInput = {
planItemCount: draftPlanItems.length,
planDirty,
planValid,
preflightAllowed: installPlan?.preflight?.allowed ?? null,
maintenanceEnabled,
canWrite,
canAccessMaintenance,
requestStatus: currentInstallerRequest?.status,
runStatus: currentInstallerRun?.status
};
const installerStages = moduleInstallerWorkflowStages(installerWorkflowInput);
const installerQueueBlock = moduleInstallerQueueBlock(installerWorkflowInput);
const installerQueueBlockReason = installerQueueBlock
? moduleInstallerQueueBlockReason(
installerQueueBlock,
planValidationError(draftPlanItems),
installPlan?.preflight?.issues[0]?.message
)
: "";
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: dirty || planDirty, dirty: dirty || planDirty,
onSave: saveDirtyChanges, onSave: saveDirtyChanges,
@@ -319,7 +364,7 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={saveDisabled}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}> actions={<><DocumentationHelpLink reference={{ topicId: "admin.module-lifecycle-workflow", documentationType: "admin" }} /><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={saveDisabled}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}>
{catalog && <> {catalog && <>
<div className="metric-grid module-management-metrics"> <div className="metric-grid module-management-metrics">
@@ -330,6 +375,36 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<MetricCard label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} /> <MetricCard label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} />
</div> </div>
<StageRail
className="module-installer-stage-rail"
ariaLabel="i18n:govoplan-admin.module_lifecycle_progress.f1a20201"
items={installerStages.map((stage) => ({
id: stage.id,
label: moduleInstallerStageLabel(stage.id),
icon: stage.id === "plan"
? <Pencil size={15} aria-hidden="true" />
: stage.id === "preflight"
? <Check size={15} aria-hidden="true" />
: stage.id === "queue"
? <Send size={15} aria-hidden="true" />
: stage.id === "execute"
? <Clock size={15} aria-hidden="true" />
: <FileText size={15} aria-hidden="true" />,
tone: moduleInstallerStageTone(stage.state),
current: stage.current,
locked: stage.locked,
lockedLabel: "i18n:govoplan-admin.locked.a798882f",
statusLabel: stage.current
? moduleInstallerStageStatus(
stage.id,
installerQueueBlockReason,
installPlan?.preflight?.allowed ?? null,
currentInstallerRequest?.status,
currentInstallerRun?.status
)
: undefined
}))} />
{dirty && <div className="module-management-notes"> {dirty && <div className="module-management-notes">
<p className="alert warning">i18n:govoplan-admin.changing_enabled_modules_requires_a_dry_run_chan.10b6f5ed</p> <p className="alert warning">i18n:govoplan-admin.changing_enabled_modules_requires_a_dry_run_chan.10b6f5ed</p>
{moduleStateRequest && <p>i18n:govoplan-admin.change_request_ready.b17e8c57 <code>{moduleStateRequest.request.id}</code></p>} {moduleStateRequest && <p>i18n:govoplan-admin.change_request_ready.b17e8c57 <code>{moduleStateRequest.request.id}</code></p>}
@@ -554,13 +629,28 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
<h2>i18n:govoplan-admin.daemon_execution.cc0fad8d</h2> <h2>i18n:govoplan-admin.daemon_execution.cc0fad8d</h2>
<p>i18n:govoplan-admin.queue_the_saved_plan_for_a_separate_installer_da.92d4c96e</p> <p>i18n:govoplan-admin.queue_the_saved_plan_for_a_separate_installer_da.92d4c96e</p>
</div> </div>
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={!canWrite || !canAccessMaintenance || planBusy || planDirty || !maintenanceEnabled || !installPlan.preflight?.allowed}> <Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={Boolean(installerQueueBlock) || planBusy} disabledReason={installerQueueBlockReason || undefined}>
i18n:govoplan-admin.queue_supervised_run.a53f248c i18n:govoplan-admin.queue_supervised_run.a53f248c
</Button> </Button>
</div> </div>
{planDirty && <p className="alert warning">i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691</p>} {installerQueueBlock ? (
{!maintenanceEnabled && <p className="alert warning">i18n:govoplan-admin.maintenance_mode_must_be_enabled_before_queueing.d233a32f</p>} <ActionBlockerHint
{!canAccessMaintenance && <p className="alert warning">i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac</p>} reason={{
summary: MODULE_INSTALLER_I18N.queueUnavailable,
requiredAction: installerQueueBlockReason,
actor: moduleInstallerQueueBlockActor(installerQueueBlock),
target: MODULE_INSTALLER_I18N.operatorPlan,
technicalDetails: installerQueueBlock === "preflight"
? installPlan.preflight?.issues[0]?.message
: undefined
}}
labels={{
requiredAction: MODULE_INSTALLER_I18N.requiredAction,
actor: MODULE_INSTALLER_I18N.responsibleActor,
target: MODULE_INSTALLER_I18N.resolutionTarget
}}
documentation={{ topicId: "admin.module-lifecycle-workflow", documentationType: "admin" }} />
) : null}
<div className="module-installer-request-grid"> <div className="module-installer-request-grid">
<ToggleSwitch label="i18n:govoplan-admin.run_migrations.db6e0ce2" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} /> <ToggleSwitch label="i18n:govoplan-admin.run_migrations.db6e0ce2" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="i18n:govoplan-admin.build_webui.fe8ccad7" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} /> <ToggleSwitch label="i18n:govoplan-admin.build_webui.fe8ccad7" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} />
@@ -654,6 +744,62 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
} }
function moduleInstallerStageLabel(stage: ModuleInstallerWorkflowStageId): string {
if (stage === "plan") return "i18n:govoplan-admin.plan.ae2f98a0";
if (stage === "preflight") return "i18n:govoplan-admin.preflight.8016a487";
if (stage === "queue") return "i18n:govoplan-admin.installer_requests.b88db439";
if (stage === "execute") return "i18n:govoplan-admin.daemon_execution.cc0fad8d";
return "i18n:govoplan-admin.installer_runs.e9e344e1";
}
function moduleInstallerStageTone(
state: ModuleInstallerWorkflowStageState
): "success" | "active" | "warning" | "danger" | "neutral" {
if (state === "complete") return "success";
if (state === "current") return "active";
if (state === "failed") return "danger";
if (state === "blocked") return "warning";
return "neutral";
}
function moduleInstallerStageStatus(
stage: ModuleInstallerWorkflowStageId,
queueBlockReason: string,
preflightAllowed: boolean | null,
requestStatus?: string | null,
runStatus?: string | null
): string {
if (stage === "plan") return queueBlockReason || "i18n:govoplan-admin.plan.ae2f98a0";
if (stage === "preflight") {
if (preflightAllowed === true) return "i18n:govoplan-admin.installer_preflight_passed.6d5f8060";
if (preflightAllowed === false) return "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
return "i18n:govoplan-admin.pending.c515ec74";
}
if (stage === "queue") return queueBlockReason || requestStatus || "i18n:govoplan-admin.queue_supervised_run.a53f248c";
if (stage === "execute") return runStatus || requestStatus || "i18n:govoplan-admin.pending.c515ec74";
return runStatus || requestStatus || "i18n:govoplan-admin.installer_runs.e9e344e1";
}
function moduleInstallerQueueBlockReason(
block: ModuleInstallerQueueBlock,
validationError: string,
preflightIssue?: string | null
): string {
if (block === "write_access") return "i18n:govoplan-admin.module_write_access_is_required.f1a20208";
if (block === "empty_plan") return "i18n:govoplan-admin.no_package_changes_planned.f12d8b33";
if (block === "invalid_plan") return validationError || "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
if (block === "unsaved_plan") return "i18n:govoplan-admin.save_the_install_plan_before_queueing_a_daemon_r.3ba00691";
if (block === "preflight") return preflightIssue || "i18n:govoplan-admin.installer_preflight_blocked.cf83bf79";
if (block === "maintenance_mode") return "i18n:govoplan-admin.maintenance_mode_must_be_enabled_before_queueing.d233a32f";
return "i18n:govoplan-admin.queueing_installer_requests_requires_maintenance.ae81f9ac";
}
function moduleInstallerQueueBlockActor(block: ModuleInstallerQueueBlock): string {
return block === "write_access" || block === "maintenance_access"
? "i18n:govoplan-admin.system_administrator.f1a20207"
: "i18n:govoplan-admin.current_operator.f1a20206";
}
function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) { function LicenseStatus({ license }: {license: ModuleLicenseDiagnostics;}) {
const label = license.license_id ? `${license.license_id}${license.subject ? ` · ${license.subject}` : ""}` : license.configured ? "i18n:govoplan-admin.configured_license.3e73f8f5" : "i18n:govoplan-admin.no_license_configured.693cea1a"; const label = license.license_id ? `${license.license_id}${license.subject ? ` · ${license.subject}` : ""}` : license.configured ? "i18n:govoplan-admin.configured_license.3e73f8f5" : "i18n:govoplan-admin.no_license_configured.693cea1a";
return ( return (
@@ -0,0 +1,152 @@
export type ModuleInstallerWorkflowStageId =
| "plan"
| "preflight"
| "queue"
| "execute"
| "evidence";
export type ModuleInstallerWorkflowStageState =
| "complete"
| "current"
| "locked"
| "blocked"
| "failed";
export type ModuleInstallerWorkflowStage = {
id: ModuleInstallerWorkflowStageId;
state: ModuleInstallerWorkflowStageState;
current: boolean;
locked: boolean;
};
export type ModuleInstallerQueueBlock =
| "write_access"
| "empty_plan"
| "invalid_plan"
| "unsaved_plan"
| "preflight"
| "maintenance_mode"
| "maintenance_access";
export type ModuleInstallerWorkflowInput = {
planItemCount: number;
planDirty: boolean;
planValid: boolean;
preflightAllowed: boolean | null;
maintenanceEnabled: boolean;
canWrite: boolean;
canAccessMaintenance: boolean;
requestStatus?: string | null;
runStatus?: string | null;
};
const ACTIVE_STATUSES = new Set([
"queued",
"pending",
"claimed",
"starting",
"running",
"cancelling",
"rolling_back"
]);
const FAILED_STATUSES = new Set([
"blocked",
"cancelled",
"failed",
"rollback_failed"
]);
export function moduleInstallerQueueBlock(
input: ModuleInstallerWorkflowInput
): ModuleInstallerQueueBlock | null {
if (!input.canWrite) return "write_access";
if (input.planItemCount === 0) return "empty_plan";
if (!input.planValid) return "invalid_plan";
if (input.planDirty) return "unsaved_plan";
if (input.preflightAllowed !== true) return "preflight";
if (!input.canAccessMaintenance) return "maintenance_access";
if (!input.maintenanceEnabled) return "maintenance_mode";
return null;
}
export function moduleInstallerWorkflowStages(
input: ModuleInstallerWorkflowInput
): ModuleInstallerWorkflowStage[] {
const queueBlock = moduleInstallerQueueBlock(input);
const planReady = input.planItemCount > 0 && input.planValid && !input.planDirty;
const preflightReady = planReady && input.preflightAllowed === true;
const requestStatus = normalizeStatus(input.requestStatus);
const runStatus = normalizeStatus(input.runStatus);
const hasRequest = Boolean(requestStatus);
const effectiveStatus = runStatus || requestStatus;
const active = ACTIVE_STATUSES.has(effectiveStatus);
const terminalStatus = hasRequest && !active ? effectiveStatus : "";
const terminal = Boolean(terminalStatus);
const failed = FAILED_STATUSES.has(terminalStatus);
if (!planReady) {
return stagesAt("plan", input.planValid || input.planItemCount === 0 ? "current" : "blocked");
}
if (!preflightReady) {
return stagesAt("preflight", input.preflightAllowed === false ? "blocked" : "current", ["plan"]);
}
if (!hasRequest) {
return stagesAt("queue", queueBlock ? "blocked" : "current", ["plan", "preflight"]);
}
if (!terminal) {
return stagesAt("execute", "current", ["plan", "preflight", "queue"]);
}
return stagesAt(
"evidence",
failed ? "failed" : "current",
["plan", "preflight", "queue", "execute"]
);
}
export function installerRequestMatchesPlan(
planUpdatedAt?: string | null,
requestCreatedAt?: string | null
): boolean {
if (!planUpdatedAt) return true;
if (!requestCreatedAt) return false;
const planTime = Date.parse(planUpdatedAt);
const requestTime = Date.parse(requestCreatedAt);
return Number.isFinite(planTime)
&& Number.isFinite(requestTime)
&& requestTime >= planTime;
}
function stagesAt(
currentId: ModuleInstallerWorkflowStageId,
currentState: Extract<ModuleInstallerWorkflowStageState, "current" | "blocked" | "failed">,
completed: ModuleInstallerWorkflowStageId[] = []
): ModuleInstallerWorkflowStage[] {
const ids: ModuleInstallerWorkflowStageId[] = [
"plan",
"preflight",
"queue",
"execute",
"evidence"
];
const currentIndex = ids.indexOf(currentId);
const completedIds = new Set(completed);
return ids.map((id, index) => {
const current = id === currentId;
const state: ModuleInstallerWorkflowStageState = current
? currentState
: completedIds.has(id)
? "complete"
: "locked";
return {
id,
state,
current,
locked: !current && index > currentIndex
};
});
}
function normalizeStatus(value?: string | null): string {
return value?.trim().toLowerCase().replaceAll("-", "_") ?? "";
}
+16
View File
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-admin.module_lifecycle_progress.f1a20201": "Module lifecycle progress",
"i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202": "Queueing a supervised run is unavailable",
"i18n:govoplan-admin.required_action.f1a20203": "Required action",
"i18n:govoplan-admin.who_can_fix_it.f1a20204": "Who can fix it",
"i18n:govoplan-admin.where_to_go.f1a20205": "Where to go",
"i18n:govoplan-admin.current_operator.f1a20206": "Current operator",
"i18n:govoplan-admin.system_administrator.f1a20207": "System administrator",
"i18n:govoplan-admin.module_write_access_is_required.f1a20208": "Module write access is required.",
"i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.", "i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.",
"i18n:govoplan-admin.action.97c89a4d": "Action", "i18n:govoplan-admin.action.97c89a4d": "Action",
"i18n:govoplan-admin.actions.c3cd636a": "Actions", "i18n:govoplan-admin.actions.c3cd636a": "Actions",
@@ -391,6 +399,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-admin.working.049ac820": "Working..." "i18n:govoplan-admin.working.049ac820": "Working..."
}, },
"de": { "de": {
"i18n:govoplan-admin.module_lifecycle_progress.f1a20201": "Fortschritt des Modullebenszyklus",
"i18n:govoplan-admin.queue_supervised_run_unavailable.f1a20202": "Ein überwachter Lauf kann nicht eingereiht werden",
"i18n:govoplan-admin.required_action.f1a20203": "Erforderliche Maßnahme",
"i18n:govoplan-admin.who_can_fix_it.f1a20204": "Zuständig",
"i18n:govoplan-admin.where_to_go.f1a20205": "Ziel",
"i18n:govoplan-admin.current_operator.f1a20206": "Aktuell ausführende Person",
"i18n:govoplan-admin.system_administrator.f1a20207": "Systemadministration",
"i18n:govoplan-admin.module_write_access_is_required.f1a20208": "Schreibzugriff auf Module ist erforderlich.",
"i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.", "i18n:govoplan-admin.access_configuration_exported.098f200d": "Access configuration exported.",
"i18n:govoplan-admin.action.97c89a4d": "Action", "i18n:govoplan-admin.action.97c89a4d": "Action",
"i18n:govoplan-admin.actions.c3cd636a": "Aktionen", "i18n:govoplan-admin.actions.c3cd636a": "Aktionen",
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
installerRequestMatchesPlan,
moduleInstallerQueueBlock,
moduleInstallerWorkflowStages,
type ModuleInstallerWorkflowInput
} from "../src/features/admin/moduleInstallerWorkflow.ts";
const ready: ModuleInstallerWorkflowInput = {
planItemCount: 1,
planDirty: false,
planValid: true,
preflightAllowed: true,
maintenanceEnabled: true,
canWrite: true,
canAccessMaintenance: true
};
test("keeps the operator on the earliest incomplete installer stage", () => {
assert.equal(moduleInstallerWorkflowStages({ ...ready, planItemCount: 0 })[0].current, true);
assert.equal(moduleInstallerWorkflowStages({ ...ready, planDirty: true })[0].current, true);
assert.equal(moduleInstallerWorkflowStages({ ...ready, preflightAllowed: false })[1].state, "blocked");
assert.equal(moduleInstallerWorkflowStages({ ...ready, maintenanceEnabled: false })[2].state, "blocked");
assert.equal(moduleInstallerWorkflowStages(ready)[2].state, "current");
});
test("moves queued work through execution to durable evidence", () => {
const queued = moduleInstallerWorkflowStages({ ...ready, requestStatus: "queued" });
assert.equal(queued[2].state, "complete");
assert.equal(queued[3].state, "current");
const completed = moduleInstallerWorkflowStages({
...ready,
requestStatus: "completed",
runStatus: "completed"
});
assert.deepEqual(completed.map((stage) => stage.state), [
"complete",
"complete",
"complete",
"complete",
"current"
]);
const failed = moduleInstallerWorkflowStages({
...ready,
requestStatus: "failed",
runStatus: "rollback_failed"
});
assert.equal(failed[4].state, "failed");
});
test("reports one actionable queue blocker in deterministic order", () => {
assert.equal(moduleInstallerQueueBlock({ ...ready, canWrite: false }), "write_access");
assert.equal(moduleInstallerQueueBlock({ ...ready, planItemCount: 0 }), "empty_plan");
assert.equal(moduleInstallerQueueBlock({ ...ready, planValid: false }), "invalid_plan");
assert.equal(moduleInstallerQueueBlock({ ...ready, planDirty: true }), "unsaved_plan");
assert.equal(moduleInstallerQueueBlock({ ...ready, preflightAllowed: false }), "preflight");
assert.equal(moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false }), "maintenance_mode");
assert.equal(moduleInstallerQueueBlock({ ...ready, canAccessMaintenance: false }), "maintenance_access");
assert.equal(
moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false, canAccessMaintenance: false }),
"maintenance_access"
);
assert.equal(moduleInstallerQueueBlock(ready), null);
});
test("does not present an older installer request as evidence for a newer plan", () => {
assert.equal(installerRequestMatchesPlan(null, "2026-08-03T10:00:00Z"), true);
assert.equal(
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T10:00:01Z"),
true
);
assert.equal(
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T09:59:59Z"),
false
);
assert.equal(installerRequestMatchesPlan("invalid", "2026-08-03T10:00:00Z"), false);
});