create-safest-tools 0.6.1 → 0.7.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/README.md +1 -1
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/template/README.md +1 -1
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +12 -6
- package/template/console/command/controller.ts +13 -9
- package/template/console/command/presentation.ts +1 -6
- package/template/console/command/store.ts +1 -0
- package/template/console/command/types.ts +1 -0
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +34 -4
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +109 -67
- package/template/console/reports/ReportWorkspace.tsx +11 -12
- package/template/console/reports/controller.ts +96 -49
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +41 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +22 -8
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +25 -17
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +18701 -11800
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +329 -10
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +3 -2
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/database-readiness.ts +8 -3
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +178 -18
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-delivery.ts +136 -129
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-operations.ts +16 -63
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +181 -274
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +18 -7
- package/template/src/workflow-effects.ts +95 -18
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +9 -4
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { JsonObject, JsonValue } from "../../src/workflow-platform-types";
|
|
2
|
+
import type { PlaybookGuide } from "../../shared/playbooks";
|
|
3
|
+
import type { PlaybookResources } from "../../shared/playbook-resources";
|
|
4
|
+
import { validateJsonSchemaValue } from "../../src/workflow-expressions";
|
|
5
|
+
import { readable } from "../lib/format";
|
|
6
|
+
import { noticeTemplateVariables } from "../../shared/notice-inputs";
|
|
7
|
+
|
|
8
|
+
export const outcomeChoices = [
|
|
9
|
+
{ code: "no_violation_found", label: "No policy violation established" },
|
|
10
|
+
{ code: "violation_found", label: "Policy violation confirmed" },
|
|
11
|
+
{ code: "insufficient_information", label: "Insufficient information" },
|
|
12
|
+
{ code: "out_of_scope", label: "Outside this policy" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function ChoiceList({ label, value, choices, onChange, empty }: {
|
|
16
|
+
label: string; value: string[]; choices: { code: string; label: string }[]; onChange: (values: string[]) => void; empty?: string;
|
|
17
|
+
}) {
|
|
18
|
+
const options = [...choices, ...value.filter(code => !choices.some(choice => choice.code === code)).map(code => ({ code, label: `${readable(code)} (saved choice)` }))];
|
|
19
|
+
return <fieldset className="playbook-choice-list"><legend>{label}</legend>{empty ? <p className="form-help">{value.length ? "Applies to the selected choices." : empty}</p> : null}{options.map(option => <label className="playbook-check" key={option.code}><input type="checkbox" checked={value.includes(option.code)} onChange={event => onChange(event.currentTarget.checked ? [...value, option.code] : value.filter(code => code !== option.code))} /><span>{option.label}</span></label>)}</fieldset>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function record(value: JsonValue | undefined): JsonObject {
|
|
23
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Values are literal action inputs. Case data is mapped separately for read capabilities. */
|
|
27
|
+
export function SchemaValueField({ label, schema, value, onChange, required = false }: {
|
|
28
|
+
label: string; schema: JsonObject; value?: JsonValue; onChange: (value: JsonValue | undefined) => void; required?: boolean;
|
|
29
|
+
}) {
|
|
30
|
+
const description = typeof schema.description === "string" ? schema.description : "";
|
|
31
|
+
const title = typeof schema.title === "string" ? schema.title : label;
|
|
32
|
+
if (schema.type === "object" && schema.properties) {
|
|
33
|
+
const properties = record(schema.properties), current = record(value);
|
|
34
|
+
return <fieldset className="playbook-choice-list"><legend>{title}{required ? " (required)" : ""}</legend>{description ? <p>{description}</p> : null}{Object.entries(properties).map(([key, child]) => <SchemaValueField key={key} label={`${title} / ${readable(key)}`} schema={record(child)} value={current[key]} required={Array.isArray(schema.required) && schema.required.includes(key)} onChange={next => { const changed = { ...current }; if (next === undefined) delete changed[key]; else changed[key] = next; onChange(changed); }} />)}</fieldset>;
|
|
35
|
+
}
|
|
36
|
+
const enumValues = Array.isArray(schema.enum) ? schema.enum.filter(item => ["string", "number", "boolean"].includes(typeof item)) : null;
|
|
37
|
+
let field;
|
|
38
|
+
if (enumValues || schema.type === "boolean") {
|
|
39
|
+
const options = enumValues ?? [true, false];
|
|
40
|
+
field = <select required={required} value={value === undefined ? "" : JSON.stringify(value)} onChange={event => onChange(event.currentTarget.value ? JSON.parse(event.currentTarget.value) as JsonValue : undefined)}><option value="">Choose a value</option>{options.map(item => <option key={JSON.stringify(item)} value={JSON.stringify(item)}>{typeof item === "boolean" ? item ? "Yes" : "No" : readable(String(item))}</option>)}</select>;
|
|
41
|
+
} else if (schema.type === "number" || schema.type === "integer") {
|
|
42
|
+
field = <input type="number" required={required} step={schema.type === "integer" ? 1 : "any"} min={typeof schema.minimum === "number" ? schema.minimum : undefined} max={typeof schema.maximum === "number" ? schema.maximum : undefined} value={typeof value === "number" ? value : ""} onChange={event => onChange(event.currentTarget.value === "" ? undefined : Number(event.currentTarget.value))} />;
|
|
43
|
+
} else if (schema.type === "string" || !schema.type) {
|
|
44
|
+
field = <textarea required={required} rows={2} maxLength={typeof schema.maxLength === "number" ? schema.maxLength : 10000} value={typeof value === "string" ? value : ""} onChange={event => onChange(event.currentTarget.value === "" && !required ? undefined : event.currentTarget.value)} />;
|
|
45
|
+
} else {
|
|
46
|
+
field = <textarea required={required} className="code-input" spellCheck={false} value={value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value, null, 2)} onChange={event => { try { onChange(JSON.parse(event.currentTarget.value) as JsonValue); } catch { onChange(event.currentTarget.value); } }} />;
|
|
47
|
+
}
|
|
48
|
+
return <label>{title}{required ? " (required)" : ""}{field}{description ? <small>{description}</small> : null}{schema.type === "array" || schema.type === "object" ? <small>Enter JSON matching this capability. Values are checked before saving.</small> : null}</label>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateGuideInputs(guide: PlaybookGuide, resources: PlaybookResources | null): void {
|
|
52
|
+
if (guide.rollout === "automate") throw new Error("Choose Observe or Assist in Rollout. The current investigator requires human review for every decision.");
|
|
53
|
+
for (const notice of guide.outcomes.notices) {
|
|
54
|
+
const template = resources?.notices.find(item => item.id === notice.template_version_id);
|
|
55
|
+
if (!template) continue;
|
|
56
|
+
for (const name of noticeTemplateVariables(template.body)) {
|
|
57
|
+
const value = notice.input_mapping?.[name];
|
|
58
|
+
if (value === undefined || value === "" || (value && typeof value === "object" && !Array.isArray(value) && value.$literal === "")) throw new Error(`Add ${readable(name)} for the ${notice.audience === "reporter" ? "reporter" : "affected person"} notice in Outcomes.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const action of guide.outcomes.actions) {
|
|
62
|
+
const capability = resources?.capabilities.find(item => item.versionId === action.component_version_id);
|
|
63
|
+
if (!capability) continue; // Published dependency checks still enforce availability.
|
|
64
|
+
const issues = validateJsonSchemaValue(action.payload, capability.inputSchema);
|
|
65
|
+
if (issues.length) throw new Error(`${capability.name}: check ${issues[0]!.split(":")[0]!.replace(/^\$\.?/u, "") || "action inputs"}. The value must match the capability’s required type and choices in Outcomes.`);
|
|
66
|
+
}
|
|
67
|
+
for (const evidence of guide.evidence) {
|
|
68
|
+
const capability = resources?.capabilities.find(item => item.versionId === evidence.component_version_id);
|
|
69
|
+
const required = capability && Array.isArray(capability.inputSchema.required) ? capability.inputSchema.required : [];
|
|
70
|
+
for (const field of required) if (typeof field === "string" && !(field in evidence.input_mapping)) throw new Error(`${evidence.label}: choose a value for ${readable(field)} in Evidence.`);
|
|
71
|
+
for (const [field, mapping] of Object.entries(evidence.input_mapping)) {
|
|
72
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) || !("$literal" in mapping)) continue;
|
|
73
|
+
const schema = record(record(capability?.inputSchema.properties)[field]);
|
|
74
|
+
if (validateJsonSchemaValue(mapping.$literal!, schema).length) throw new Error(`${evidence.label}: check the fixed value for ${readable(field)} in Evidence.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function NoticeInputs({ body, mapping, onChange }: { body: string; mapping: JsonObject; onChange: (mapping: JsonObject) => void }) {
|
|
80
|
+
const sources = [{ path: "$.report.public_reference", label: "Case reference" }, { path: "$.steps.decision.decisionCode", label: "Recorded decision code" }];
|
|
81
|
+
const preview = body.replace(/\{\{\s*([A-Za-z0-9_-]{1,80})\s*\}\}/gu, (_match, name: string) => {
|
|
82
|
+
const value = mapping[name];
|
|
83
|
+
if (value && typeof value === "object" && !Array.isArray(value) && "$literal" in value) return String(value.$literal ?? "");
|
|
84
|
+
return `[${sources.find(source => source.path === value)?.label ?? (value === undefined ? `Add ${readable(name)}` : "Saved expression")}]`;
|
|
85
|
+
});
|
|
86
|
+
return <div>{noticeTemplateVariables(body).map(name => {
|
|
87
|
+
const value = mapping[name];
|
|
88
|
+
const literal = value && typeof value === "object" && !Array.isArray(value) && "$literal" in value;
|
|
89
|
+
const saved = value !== undefined && !literal && !sources.some(source => source.path === value);
|
|
90
|
+
const update = (next: JsonValue) => onChange({ ...mapping, [name]: next });
|
|
91
|
+
return <div className="playbook-input-field" key={name}><label>Notice {readable(name)}<select required value={literal ? "fixed" : saved ? "saved" : typeof value === "string" ? value : ""} onChange={event => update(event.currentTarget.value === "fixed" ? { $literal: "" } : event.currentTarget.value)}><option value="">Choose notice content</option><option value="fixed">Write participant-facing text</option>{sources.map(source => <option key={source.path} value={source.path}>{source.label}</option>)}{saved ? <option value="saved">Saved expression</option> : null}</select></label>{literal ? <label>Text for {readable(name)}<textarea required rows={3} maxLength={4000} value={String(record(value).$literal ?? "")} onChange={event => update({ $literal: event.currentTarget.value })} /></label> : saved ? <pre tabIndex={0}>{JSON.stringify(value, null, 2)}</pre> : null}</div>;
|
|
92
|
+
})}<p className="form-help">Write for this participant. Internal review rationale and other participants’ identities are not inserted automatically.</p><strong>Notice preview</strong><blockquote className="playbook-notice-preview">{preview || "Choose a published template to preview its notice."}</blockquote></div>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function CaseInputField({ name, schema, value, required, capability, resources, onChange }: {
|
|
96
|
+
name: string; schema: JsonObject; value?: JsonValue; required: boolean;
|
|
97
|
+
capability: PlaybookResources["capabilities"][number]; resources: PlaybookResources | null;
|
|
98
|
+
onChange: (value: JsonValue | undefined) => void;
|
|
99
|
+
}) {
|
|
100
|
+
const allowed = capability.allowedFields.map(path => path.replace(/^\$\./u, ""));
|
|
101
|
+
const sources = [...(resources?.scope.fields ?? [])];
|
|
102
|
+
for (const path of allowed) if (!sources.some(source => source.path === path)) sources.push({ path, label: readable(path.replaceAll(".", " / ")) });
|
|
103
|
+
const options = sources.filter(source => allowed.some(path => path === source.path || source.path.startsWith(`${path}.`)));
|
|
104
|
+
const literal = Boolean(value && typeof value === "object" && !Array.isArray(value) && "$literal" in value);
|
|
105
|
+
const advanced = value !== undefined && !literal && !(typeof value === "string" && value.startsWith("$."));
|
|
106
|
+
const selected = literal ? "fixed" : advanced ? "advanced" : typeof value === "string" ? value : "";
|
|
107
|
+
const label = typeof schema.title === "string" ? schema.title : readable(name);
|
|
108
|
+
return <div className="playbook-input-field"><label>Value for {label}{required ? " (required)" : ""}<select required={required} value={selected} onChange={event => onChange(event.currentTarget.value === "fixed" ? { $literal: schema.type === "boolean" ? false : schema.type === "number" || schema.type === "integer" ? 0 : "" } : event.currentTarget.value || undefined)}><option value="">Choose case data</option>{options.map(source => <option key={source.path} value={`$.${source.path}`}>{source.label}</option>)}{typeof value === "string" && value.startsWith("$.") && !options.some(source => `$.${source.path}` === value) ? <option value={value}>Saved source · {value}</option> : null}<option value="fixed">A fixed value for every case</option>{advanced ? <option value="advanced">Saved advanced expression</option> : null}</select></label>{literal ? <SchemaValueField label={`Fixed ${label}`} schema={schema} required={required} value={record(value).$literal} onChange={next => onChange(next === undefined ? undefined : { $literal: next })} /> : advanced ? <pre tabIndex={0} aria-label={`Saved ${label} expression`}>{JSON.stringify(value, null, 2)}</pre> : null}{advanced ? <small>The saved expression is preserved. Use Workflow for advanced changes, or deliberately choose a replacement above.</small> : null}</div>;
|
|
109
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { JsonObject } from "../../src/workflow-platform-types";
|
|
3
|
+
import type { PlaybookGuide } from "../../shared/playbooks";
|
|
4
|
+
import type { PlaybookResources } from "../../shared/playbook-resources";
|
|
5
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
6
|
+
import { readable } from "../lib/format";
|
|
7
|
+
import { CaseInputField, ChoiceList, NoticeInputs, outcomeChoices, SchemaValueField } from "./PlaybookFields";
|
|
8
|
+
|
|
9
|
+
export const guideSteps = ["Situation", "Evidence", "Investigation", "Review", "Outcomes", "Rollout"];
|
|
10
|
+
|
|
11
|
+
export function usePlaybookResources(enabled: boolean) {
|
|
12
|
+
const [resources, setResources] = useState<PlaybookResources | null>(null);
|
|
13
|
+
const [error, setError] = useState("");
|
|
14
|
+
const [attempt, setAttempt] = useState(0);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!enabled) return;
|
|
17
|
+
let active = true;
|
|
18
|
+
setError("");
|
|
19
|
+
void requestJson<PlaybookResources>("/v1/admin/playbook-resources").then(value => { if (active) setResources(value); }).catch(cause => { if (active) setError(errorMessage(cause, "The available policies and connections could not be loaded.")); });
|
|
20
|
+
return () => { active = false; };
|
|
21
|
+
}, [enabled, attempt]);
|
|
22
|
+
return { resources, error, reload: () => setAttempt(value => value + 1) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ListInput({ label, value, onChange, hint }: { label: string; value: string[]; onChange: (value: string[]) => void; hint?: string }) {
|
|
26
|
+
const [draft, setDraft] = useState(value.join(", "));
|
|
27
|
+
const serialized = value.join(", ");
|
|
28
|
+
const parse = (text: string) => [...new Set(text.split(",").map(value => value.trim()).filter(Boolean))];
|
|
29
|
+
useEffect(() => { if (parse(draft).join(", ") !== serialized) setDraft(serialized); }, [serialized]);
|
|
30
|
+
return <label>{label}<input value={draft} maxLength={2000} onChange={event => { setDraft(event.currentTarget.value); onChange(parse(event.currentTarget.value)); }} /><small>{hint || "Separate each choice with a comma."}</small></label>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function PlaybookGuideEditor({ value, onChange, step, resources, disabled = false }: {
|
|
34
|
+
value: PlaybookGuide; onChange: (guide: PlaybookGuide) => void; step: number; resources: PlaybookResources | null; disabled?: boolean;
|
|
35
|
+
}) {
|
|
36
|
+
const update = (change: (guide: PlaybookGuide) => void) => { const next = structuredClone(value); change(next); onChange(next); };
|
|
37
|
+
const reads = resources?.capabilities.filter(capability => capability.effect === "read_only") ?? [];
|
|
38
|
+
const actions = resources?.capabilities.filter(capability => capability.effect === "side_effecting") ?? [];
|
|
39
|
+
return <fieldset className="playbook-guide-fields" disabled={disabled}>
|
|
40
|
+
<legend className="sr-only">{guideSteps[step]}</legend>
|
|
41
|
+
{step === 0 ? <>
|
|
42
|
+
<div className="playbook-step-intro"><span className="eyebrow">01 / Situation</span><h3>What should Safest help resolve?</h3><p>Describe the situation, then connect it to the policy your team has published.</p></div>
|
|
43
|
+
<label>Situation<textarea required maxLength={2000} value={value.summary} onChange={event => update(guide => { guide.summary = event.currentTarget.value; })} /></label>
|
|
44
|
+
<div className="form-grid">
|
|
45
|
+
<ChoiceList label="Reported concerns" value={value.scope.reason_codes} choices={resources?.scope.reasons ?? []} onChange={codes => update(guide => { guide.scope.reason_codes = codes; })} empty="Every report reason is included until you select specific concerns." />
|
|
46
|
+
<ChoiceList label="What can be reported?" value={value.scope.target_types} choices={(resources?.scope.targetTypes ?? []).map(code => ({ code, label: readable(code) }))} onChange={codes => update(guide => { guide.scope.target_types = codes; })} empty="Every target type is included until you select specific types." />
|
|
47
|
+
<label>Published policy<select required value={value.policy_version_id} onChange={event => update(guide => {
|
|
48
|
+
guide.policy_version_id = event.currentTarget.value;
|
|
49
|
+
const policy = resources?.policies.find(policy => policy.id === guide.policy_version_id);
|
|
50
|
+
if (policy?.rules[0]) guide.policy_code = policy.rules[0].code;
|
|
51
|
+
})}><option value="">Choose a policy</option>{!resources?.policies.some(policy => policy.id === value.policy_version_id) && value.policy_version_id ? <option value={value.policy_version_id}>Saved policy · {value.policy_version_id}</option> : null}{resources?.policies.map(policy => <option key={policy.id} value={policy.id}>{policy.name} · v{policy.version}</option>)}</select></label>
|
|
52
|
+
<label>Policy rule<select required value={value.policy_code} onChange={event => update(guide => { guide.policy_code = event.currentTarget.value; })}>
|
|
53
|
+
<option value="">Choose a rule</option>{!resources?.policies.find(policy => policy.id === value.policy_version_id)?.rules.some(rule => rule.code === value.policy_code) && value.policy_code ? <option value={value.policy_code}>{value.policy_code}</option> : null}
|
|
54
|
+
{resources?.policies.find(policy => policy.id === value.policy_version_id)?.rules.map(rule => <option value={rule.code} key={rule.code}>{rule.title}</option>)}
|
|
55
|
+
</select></label>
|
|
56
|
+
</div>
|
|
57
|
+
<label>Policy guidance or imported policy<textarea required rows={7} maxLength={20000} value={value.policy_text} onChange={event => update(guide => { guide.policy_text = event.currentTarget.value; })} /><small>Paste the relevant policy and exceptions. This guidance accompanies the pinned policy; review it before publication.</small></label>
|
|
58
|
+
{resources?.policies.find(policy => policy.id === value.policy_version_id)?.rules.find(rule => rule.code === value.policy_code)?.description ? <blockquote className="playbook-notice-preview">{resources.policies.find(policy => policy.id === value.policy_version_id)?.rules.find(rule => rule.code === value.policy_code)?.description}</blockquote> : null}
|
|
59
|
+
<details><summary>Custom intake identifiers</summary><p>Use these only when your customer integration supplies values outside the published forms.</p><ListInput label="Report reason codes" value={value.scope.reason_codes} onChange={codes => update(guide => { guide.scope.reason_codes = codes; })} /><ListInput label="Target type codes" value={value.scope.target_types} onChange={codes => update(guide => { guide.scope.target_types = codes; })} /></details>
|
|
60
|
+
</> : null}
|
|
61
|
+
{step === 1 ? <>
|
|
62
|
+
<div className="playbook-step-intro"><span className="eyebrow">02 / Evidence</span><h3>Give Safest the context it needs.</h3><p>Case answers and verified intake context are already available. Select additional read capabilities from your connected product.</p></div>
|
|
63
|
+
{!reads.length ? <div className="playbook-callout"><strong>No additional read capabilities are available yet.</strong><p>You can start with report evidence and human review. Add a read capability in Connections when your product can supply more context.</p></div> : null}
|
|
64
|
+
{reads.map(capability => {
|
|
65
|
+
const selected = value.evidence.find(evidence => evidence.component_version_id === capability.versionId);
|
|
66
|
+
const fields = capability.inputSchema.properties && typeof capability.inputSchema.properties === "object" && !Array.isArray(capability.inputSchema.properties) ? Object.keys(capability.inputSchema.properties) : [];
|
|
67
|
+
return <article className={`playbook-capability${selected ? " selected" : ""}`} key={capability.id}>
|
|
68
|
+
<label className="playbook-check"><input type="checkbox" checked={Boolean(selected)} disabled={!capability.available && !selected} onChange={event => update(guide => {
|
|
69
|
+
if (!event.currentTarget.checked) guide.evidence = guide.evidence.filter(evidence => evidence.component_version_id !== capability.versionId);
|
|
70
|
+
else guide.evidence.push({ id: `read-${guide.evidence.length}-${capability.id.replace(/[^a-z0-9]/giu, "").slice(0, 36)}`, label: capability.name, component_version_id: capability.versionId, required: true, input_mapping: {} });
|
|
71
|
+
})} /><span><strong>{capability.name}</strong><small>{capability.description || "Read additional case context."}</small></span><span className="badge">{capability.available ? "Read only" : "Needs setup"}</span></label>
|
|
72
|
+
<p className="playbook-source">Source: {capability.connectionName || "Workspace capability"}</p>
|
|
73
|
+
{selected ? <div className="playbook-capability-options"><label className="playbook-check"><input type="checkbox" checked={selected.required} onChange={event => update(guide => { guide.evidence.find(evidence => evidence.id === selected.id)!.required = event.currentTarget.checked; })} />Required before reaching a decision</label>
|
|
74
|
+
<p className="muted">{selected.required ? "If unavailable, the case returns to the review team without an outcome." : "Safest can continue with the missing context recorded."}</p>
|
|
75
|
+
{fields.map(field => <CaseInputField key={field} name={field} schema={(capability.inputSchema.properties as JsonObject)[field] as JsonObject} required={Array.isArray(capability.inputSchema.required) && capability.inputSchema.required.includes(field)} value={selected.input_mapping[field]} capability={capability} resources={resources} onChange={next => update(guide => {
|
|
76
|
+
const mapping = guide.evidence.find(evidence => evidence.id === selected.id)!.input_mapping;
|
|
77
|
+
if (next === undefined) delete mapping[field]; else mapping[field] = next;
|
|
78
|
+
})} />)}
|
|
79
|
+
</div> : null}
|
|
80
|
+
</article>;
|
|
81
|
+
})}
|
|
82
|
+
{value.evidence.filter(evidence => !reads.some(capability => capability.versionId === evidence.component_version_id)).map(evidence => <div className="playbook-callout" key={evidence.id}><strong>{evidence.label} · saved version unavailable in this catalogue</strong><p>The pinned version is preserved. Check its connection or remove it deliberately.</p><button className="secondary" type="button" onClick={() => update(guide => { guide.evidence = guide.evidence.filter(item => item.id !== evidence.id); })}>Remove {evidence.label}</button></div>)}
|
|
83
|
+
</> : null}
|
|
84
|
+
{step === 2 ? <>
|
|
85
|
+
<div className="playbook-step-intro"><span className="eyebrow">03 / Investigation</span><h3>Define how Safest should investigate.</h3><p>The investigator gathers evidence and prepares an explained recommendation. The playbook controls its authority.</p></div>
|
|
86
|
+
<label className="playbook-check"><input type="checkbox" checked={value.investigation.enabled} onChange={event => update(guide => { guide.investigation.enabled = event.currentTarget.checked; })} />Use a case investigator</label>
|
|
87
|
+
{value.investigation.enabled ? <>
|
|
88
|
+
<label>Investigator<select required value={value.investigation.agent_version_id} onChange={event => update(guide => { guide.investigation.agent_version_id = event.currentTarget.value; })}><option value="">Choose an approved investigator</option>{value.investigation.agent_version_id && !resources?.agents.some(agent => agent.versionId === value.investigation.agent_version_id) ? <option value={value.investigation.agent_version_id}>Saved investigator · {value.investigation.agent_version_id}</option> : null}{resources?.agents.map(agent => <option key={agent.versionId} value={agent.versionId}>{agent.name} · Workers AI</option>)}</select></label>
|
|
89
|
+
{!resources?.agents.length ? <p className="playbook-callout">An administrator needs to configure an investigator with an approved Workers AI model before this can be published with investigation enabled.</p> : null}
|
|
90
|
+
<p className="form-help">The investigator can read approved evidence tools and prepare a cited proposal. Your team reviews the result before a decision or action.</p><label>Investigation instructions<textarea rows={6} maxLength={8000} value={value.investigation.instructions} onChange={event => update(guide => { guide.investigation.instructions = event.currentTarget.value; })} /></label>
|
|
91
|
+
<label>Maximum investigation steps<input type="number" required min={2} max={20} value={value.investigation.max_steps} onChange={event => update(guide => { guide.investigation.max_steps = Number(event.currentTarget.value); })} /><small>Unresolved questions remain visible when the investigator reaches this limit.</small></label>
|
|
92
|
+
</> : <div className="playbook-callout"><strong>Your team leads the investigation.</strong><p>Safest gathers the selected evidence and brings it to a reviewer.</p></div>}
|
|
93
|
+
</> : null}
|
|
94
|
+
{step === 3 ? <>
|
|
95
|
+
<div className="playbook-step-intro"><span className="eyebrow">04 / Review</span><h3>Make human responsibility explicit.</h3><p>Assist sends every decision to a person. This team also receives missing evidence and failed execution.</p></div>
|
|
96
|
+
<label>Review team<select required value={value.review.queue_id} onChange={event => update(guide => { guide.review.queue_id = event.currentTarget.value; })}><option value="">Choose a team queue</option>{value.review.queue_id && !resources?.queues.some(queue => queue.id === value.review.queue_id) ? <option value={value.review.queue_id}>Saved queue · {value.review.queue_id}</option> : null}{resources?.queues.map(queue => <option value={queue.id} key={queue.id}>{queue.name}</option>)}</select></label>
|
|
97
|
+
<label>Reviewer instructions<textarea rows={6} maxLength={8000} value={value.review.instructions} onChange={event => update(guide => { guide.review.instructions = event.currentTarget.value; })} /></label>
|
|
98
|
+
<label>Escalate an unanswered review after (hours)<input required type="number" min={1} max={720} value={value.review.timeout_seconds / 3600} onChange={event => update(guide => { guide.review.timeout_seconds = Number(event.currentTarget.value) * 3600; })} /><small>A missed deadline returns the case to the team. It never becomes an approved decision.</small></label>
|
|
99
|
+
</> : null}
|
|
100
|
+
{step === 4 ? <>
|
|
101
|
+
<div className="playbook-step-intro"><span className="eyebrow">05 / Outcomes</span><h3>Connect a decision to what happens next.</h3><p>Choose permitted outcomes, customer actions and separate participant notices. Safest waits for action confirmation before sending these notices.</p></div>
|
|
102
|
+
<ChoiceList label="Permitted outcomes" value={value.outcomes.decision_codes} choices={outcomeChoices} onChange={codes => update(guide => { guide.outcomes.decision_codes = codes; guide.outcomes.delegated_decision_codes = guide.outcomes.delegated_decision_codes.filter(code => codes.includes(code)); })} />
|
|
103
|
+
<details><summary>Custom decision identifiers</summary><ListInput label="Permitted decision codes" value={value.outcomes.decision_codes} onChange={codes => update(guide => { guide.outcomes.decision_codes = codes; })} hint="Use stable codes from your customer integration for additional outcomes." /></details>
|
|
104
|
+
{value.outcomes.actions.map((action, index) => <article className="playbook-capability" key={index}><div className="form-grid">
|
|
105
|
+
<label>When the decision is<select required value={action.decision_code} onChange={event => update(guide => { guide.outcomes.actions[index]!.decision_code = event.currentTarget.value; })}>{value.outcomes.decision_codes.map(code => <option key={code} value={code}>{outcomeChoices.find(choice => choice.code === code)?.label ?? readable(code)}</option>)}</select></label>
|
|
106
|
+
<label>Customer action<select required value={action.component_version_id} onChange={event => update(guide => { const capability = actions.find(capability => capability.versionId === event.currentTarget.value); guide.outcomes.actions[index]!.component_version_id = event.currentTarget.value; guide.outcomes.actions[index]!.approval = capability?.approval ?? "human"; })}><option value="">Choose an action capability</option>{actions.map(capability => <option key={capability.versionId} value={capability.versionId} disabled={!capability.available}>{capability.name}{!capability.available ? " · Needs setup" : ""}</option>)}</select></label>
|
|
107
|
+
<label>Action name in your product<input required maxLength={100} value={action.action_code} onChange={event => update(guide => { guide.outcomes.actions[index]!.action_code = event.currentTarget.value; })} /></label>
|
|
108
|
+
</div><p className="form-help">Use the action name accepted by your customer integration, for example hide_message. The target comes from this case; the values below are fixed for this action.</p>
|
|
109
|
+
{actions.find(capability => capability.versionId === action.component_version_id) ? <SchemaValueField label="Action inputs" schema={actions.find(capability => capability.versionId === action.component_version_id)!.inputSchema} value={action.payload} onChange={payload => update(guide => { guide.outcomes.actions[index]!.payload = (payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {}) as JsonObject; })} /> : null}
|
|
110
|
+
<p>{action.approval === "human" ? "A person must approve this customer action." : "This capability needs delegated authority, which is unavailable for new guided playbooks. Choose a capability with human approval."}</p><button className="text-button" type="button" onClick={() => update(guide => { guide.outcomes.actions.splice(index, 1); })}>Remove action</button></article>)}
|
|
111
|
+
<button className="secondary" type="button" disabled={!actions.length || !value.outcomes.decision_codes.length || value.outcomes.actions.length >= 8} onClick={() => update(guide => { guide.outcomes.actions.push({ decision_code: guide.outcomes.decision_codes[0]!, action_code: "", component_version_id: "", approval: "human", payload: {} }); })}>Add customer action</button>
|
|
112
|
+
{!actions.length ? <p className="muted">Connect an action capability when your product is ready to apply decisions.</p> : null}
|
|
113
|
+
<div className="form-grid">{(["reporter", "affected_user"] as const).map(audience => {
|
|
114
|
+
const selected = value.outcomes.notices.find(notice => notice.audience === audience);
|
|
115
|
+
const options = resources?.notices.filter(notice => notice.audience === audience) ?? [];
|
|
116
|
+
return <div key={audience}><label>{audience === "reporter" ? "Reporter outcome notice" : "Affected user notice"}<select value={selected?.template_version_id ?? ""} onChange={event => update(guide => { guide.outcomes.notices = guide.outcomes.notices.filter(notice => notice.audience !== audience); if (event.currentTarget.value) guide.outcomes.notices.push({ audience, template_version_id: event.currentTarget.value }); })}><option value="">No automatic notice</option>{options.map(notice => <option key={notice.id} value={notice.id}>{notice.name} · v{notice.version}</option>)}</select></label>{selected ? <><NoticeInputs body={options.find(notice => notice.id === selected.template_version_id)?.body ?? ""} mapping={selected.input_mapping ?? {}} onChange={mapping => update(guide => { guide.outcomes.notices.find(notice => notice.audience === audience)!.input_mapping = mapping; })} /></> : null}</div>;
|
|
117
|
+
})}</div>
|
|
118
|
+
</> : null}
|
|
119
|
+
{step === 5 ? <>
|
|
120
|
+
<div className="playbook-step-intro"><span className="eyebrow">06 / Rollout</span><h3>Choose how much responsibility to delegate.</h3><p>Run your examples before publishing. Activating a published version affects new cases; active cases keep their version.</p></div>
|
|
121
|
+
{([{ value: "observe", title: "Observe", detail: "Gather context and investigate without decisions, customer actions or participant notices." }, { value: "assist", title: "Assist", detail: "Prepare the case for a person. Decisions and customer actions require approval." }] as const).map(mode => <label className="template-choice" key={mode.value}><input type="radio" name="playbook-rollout" checked={value.rollout === mode.value} onChange={() => update(guide => { guide.rollout = mode.value; guide.outcomes.delegated_decision_codes = []; })} /><span><strong>{mode.title}</strong><small>{mode.detail}</small></span></label>)}
|
|
122
|
+
<div className="playbook-callout"><strong>Every investigator decision currently requires human review.</strong><p>The investigator uses Think for persistent investigation and your configured Workers AI model for reasoning. Delegated decisions are not available in new guided playbooks.</p>{value.rollout === "automate" ? <p>This saved definition contains delegated outcomes. They are preserved for inspection, but the current investigator always returns to a person. Select Assist before republishing a guided revision.</p> : null}</div>
|
|
123
|
+
</> : null}
|
|
124
|
+
</fieldset>;
|
|
125
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { readable } from "../lib/format";
|
|
3
|
+
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
4
|
+
import { dispatchOpenReport } from "../reports/events";
|
|
5
|
+
import { announceWorkflowChanged } from "./events";
|
|
6
|
+
import type { QueueListItem, WorkflowVersionResponse, WorkflowVersionSummary } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface PlaybookRun {
|
|
9
|
+
id: string; reportId: string; publicReference: string; version: number; state: string;
|
|
10
|
+
runKind: string; authorityMode: string; currentNodeId: string | null; currentNodeName: string | null; errorCode: string | null;
|
|
11
|
+
stepCount: number; openTaskCount: number; createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
export function PlaybookRuns({ workflowId, onOpenCase }: { workflowId: string; onOpenCase: () => void }) {
|
|
14
|
+
const [runs, setRuns] = useState<PlaybookRun[]>([]);
|
|
15
|
+
const [state, setState] = useState("");
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState("");
|
|
18
|
+
const [reload, setReload] = useState(0);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let active = true; setLoading(true); setError("");
|
|
21
|
+
void requestJson<{ runs: PlaybookRun[] }>(`/v1/admin/workflow-runs?workflow_id=${workflowId}&limit=100${state ? `&state=${state}` : ""}`).then(result => { if (active) setRuns(result.runs); }).catch(cause => { if (active) setError(errorMessage(cause, "Runs could not be loaded.")); }).finally(() => { if (active) setLoading(false); });
|
|
22
|
+
return () => { active = false; };
|
|
23
|
+
}, [workflowId, state, reload]);
|
|
24
|
+
return <section className="playbook-lifecycle-panel" aria-label="Playbook runs"><div className="playbook-panel-heading"><div><p className="eyebrow">Live case history</p><h3>See where cases need help.</h3><p>Up to 100 recent runs in queues you can access. Each case keeps the published version it started with.</p></div><button type="button" className="secondary" disabled={loading} onClick={() => setReload(value => value + 1)}>Refresh runs</button></div>
|
|
25
|
+
<label className="playbook-run-filter">Run state<select value={state} onChange={event => setState(event.currentTarget.value)}><option value="">All states</option>{["running", "waiting", "paused", "succeeded", "failed", "repair_required", "dispatch_pending", "terminated"].map(value => <option key={value} value={value}>{readable(value)}</option>)}</select></label>
|
|
26
|
+
{loading ? <p role="status">Loading case runs…</p> : error ? <p className="error" role="alert">{error}</p> : runs.length ? <div className="playbook-run-list">{runs.map(run => <article key={run.id}><div className="playbook-panel-heading"><div><strong>{run.publicReference || "Case"}</strong><p>Version {run.version} · {run.runKind === "shadow" ? "Observe" : readable(run.authorityMode)} · {new Date(run.createdAt).toLocaleString()}</p></div><span className="badge">{readable(run.state)}</span></div><p>{run.openTaskCount ? `${run.openTaskCount} task${run.openTaskCount === 1 ? "" : "s"} waiting for a person.` : `${run.stepCount} steps recorded.`}{run.currentNodeId ? ` Current step: ${run.currentNodeName ?? readable(run.currentNodeId)}.` : ""}</p>{run.errorCode ? <p className="error">Needs attention: {readable(run.errorCode)}.</p> : null}<button type="button" className="secondary" onClick={() => { onOpenCase(); window.location.hash = "reports"; requestAnimationFrame(() => dispatchOpenReport(run.reportId)); }}>Open case {run.publicReference || "details"}</button></article>)}</div> : <div className="playbook-callout"><strong>{state ? "No runs in this state" : "No cases have used this playbook yet"}</strong><p>{state ? "Choose another state or refresh after work progresses." : "Activate a published version for a queue, then submit a test case through your reporting channel. Simulated examples appear in Test."}</p></div>}
|
|
27
|
+
</section>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type VersionDetail = WorkflowVersionResponse["version"] & { testEvidence?: { example_test_run_ids?: string[]; test_run_id?: string; examples?: { test_run_id: string; name: string; revision: number }[] }; artifactStatus?: string };
|
|
31
|
+
type VersionDiff = { againstVersionId: string | null; nodes: { added: string[]; removed: string[]; changed: string[] }; guide: string[]; edgesChanged: boolean; authorityMode: { from: string | null; to: string } };
|
|
32
|
+
type Pin = { id: string; mode: "primary" | "shadow"; workflowVersionId: string; workflowName: string; version: number; deactivatedAt: string | null };
|
|
33
|
+
export function PlaybookVersions({ workflowId, epoch, queues, defaultQueueId, canActivate, disabled, onPending, onActivated }: {
|
|
34
|
+
workflowId: string; epoch: number; queues: QueueListItem[]; defaultQueueId: string; canActivate: boolean; disabled: boolean;
|
|
35
|
+
onPending: (pending: boolean) => void; onActivated: (message: string) => void;
|
|
36
|
+
}) {
|
|
37
|
+
const [versions, setVersions] = useState<WorkflowVersionSummary[]>([]);
|
|
38
|
+
const [selected, setSelected] = useState("");
|
|
39
|
+
const [detail, setDetail] = useState<VersionDetail | null>(null);
|
|
40
|
+
const [diff, setDiff] = useState<VersionDiff | null>(null);
|
|
41
|
+
const [queueId, setQueueId] = useState(defaultQueueId);
|
|
42
|
+
const [pins, setPins] = useState<Pin[] | null>(null);
|
|
43
|
+
const [loading, setLoading] = useState(true);
|
|
44
|
+
const [pending, setPending] = useState(false);
|
|
45
|
+
const [error, setError] = useState("");
|
|
46
|
+
const [pinError, setPinError] = useState("");
|
|
47
|
+
const [notice, setNotice] = useState("");
|
|
48
|
+
const [evidence, setEvidence] = useState<{ name: string; record: unknown } | null>(null);
|
|
49
|
+
const [evidenceLoading, setEvidenceLoading] = useState(false);
|
|
50
|
+
const evidenceGeneration = useRef(0);
|
|
51
|
+
const [reload, setReload] = useState(0);
|
|
52
|
+
useEffect(() => () => onPending(false), [onPending]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let active = true; setLoading(true); setError("");
|
|
55
|
+
void requestJson<{ versions: WorkflowVersionSummary[] }>(`/v1/admin/workflows/${workflowId}/versions`).then(result => {
|
|
56
|
+
if (active) { setVersions(result.versions); setSelected(previous => result.versions.some(version => version.id === previous) ? previous : result.versions[0]?.id ?? ""); }
|
|
57
|
+
}).catch(cause => { if (active) setError(errorMessage(cause, "Version history could not be loaded.")); }).finally(() => { if (active) setLoading(false); });
|
|
58
|
+
return () => { active = false; };
|
|
59
|
+
}, [workflowId, epoch, reload]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
setDetail(null); setDiff(null); setEvidence(null); setEvidenceLoading(false); evidenceGeneration.current += 1; if (!selected) return;
|
|
62
|
+
let active = true; setError("");
|
|
63
|
+
void Promise.all([
|
|
64
|
+
requestJson<WorkflowVersionResponse>(`/v1/admin/workflows/${workflowId}/versions/${selected}`),
|
|
65
|
+
requestJson<{ diff: VersionDiff }>(`/v1/admin/workflows/${workflowId}/versions/${selected}/diff`),
|
|
66
|
+
]).then(([version, comparison]) => { if (active) { setDetail(version.version); setDiff(comparison.diff); } }).catch(cause => { if (active) setError(errorMessage(cause, "This version could not be opened.")); });
|
|
67
|
+
return () => { active = false; };
|
|
68
|
+
}, [workflowId, selected, reload]);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setPins(null); setPinError(""); if (!queueId || !canActivate) return;
|
|
71
|
+
let active = true;
|
|
72
|
+
void requestJson<{ pins: Pin[] }>(`/v1/admin/queues/${queueId}/workflow-pin`).then(result => { if (active) setPins(result.pins); }).catch(cause => { if (active) setPinError(errorMessage(cause, "The current activation could not be checked.")); });
|
|
73
|
+
return () => { active = false; };
|
|
74
|
+
}, [queueId, canActivate, reload, epoch]);
|
|
75
|
+
const mode = detail?.authorityMode === "shadow" ? "shadow" : "primary";
|
|
76
|
+
const activePin = pins?.find(pin => !pin.deactivatedAt && pin.mode === mode);
|
|
77
|
+
const alreadyActive = activePin?.workflowVersionId === selected;
|
|
78
|
+
const activate = async (event: FormEvent): Promise<void> => {
|
|
79
|
+
event.preventDefault(); if (!detail || !canActivate || !queueId || !pins || alreadyActive || disabled || pending) return;
|
|
80
|
+
setPending(true); onPending(true); setError(""); setNotice("");
|
|
81
|
+
try {
|
|
82
|
+
await requestJson(`/v1/admin/queues/${queueId}/workflow-pin`, { method: "PUT", headers: mutationHeaders(), body: JSON.stringify({ workflow_version_id: selected, mode, expected_pin_id: activePin?.id ?? null, ...(activePin ? { rollback_of_pin_id: activePin.id } : {}) }) });
|
|
83
|
+
const message = `Version ${detail.version} is active for new cases in ${queues.find(queue => queue.id === queueId)?.name ?? "the selected queue"}. Existing cases keep their original version.`;
|
|
84
|
+
setNotice(message);
|
|
85
|
+
setReload(value => value + 1); announceWorkflowChanged(); onActivated(message);
|
|
86
|
+
} catch (cause) { setError(errorMessage(cause, "The activation could not be saved.")); }
|
|
87
|
+
finally { setPending(false); onPending(false); }
|
|
88
|
+
};
|
|
89
|
+
const guide = detail?.graph.playbook;
|
|
90
|
+
return <section className="playbook-lifecycle-panel" aria-label="Playbook versions"><div className="playbook-panel-heading"><div><p className="eyebrow">Published history</p><h3>Change the plan with confidence.</h3><p>Published versions are immutable. Activate a previous version for new cases without rewriting cases already in progress.</p></div><button type="button" className="secondary" disabled={disabled || pending || loading} onClick={() => setReload(value => value + 1)}>Refresh versions</button></div>
|
|
91
|
+
{loading ? <p role="status">Loading versions…</p> : versions.length ? <div className="playbook-examples-layout"><nav className="playbook-example-list" aria-label="Published versions">{versions.map(version => <button type="button" key={version.id} disabled={disabled || pending} aria-current={version.id === selected ? "true" : undefined} onClick={() => { setSelected(version.id); setNotice(""); }}><strong>Version {version.version}</strong><span>{version.authorityMode === "shadow" ? "Observe" : readable(version.authorityMode)}{version.retiredAt ? " · Retired" : ""}</span><small>{new Date(version.publishedAt).toLocaleString()}</small></button>)}</nav>
|
|
92
|
+
{detail ? <div className="playbook-version-detail"><h4>Version {detail.version}</h4>{guide ? <><p>{guide.summary}</p><dl className="playbook-result-facts"><div><dt>Rollout</dt><dd>{readable(guide.rollout)}</dd></div><div><dt>Evidence capabilities</dt><dd>{guide.evidence.length}</dd></div><div><dt>Investigation</dt><dd>{guide.investigation.enabled ? "Case investigator" : "Human review"}</dd></div><div><dt>Outcome choices</dt><dd>{guide.outcomes.decision_codes.map(readable).join(", ")}</dd></div></dl><details><summary>Published policy guidance</summary><p className="playbook-notice-preview">{guide.policy_text}</p></details></> : <p>Advanced workflow with {detail.graph.nodes.length} steps.</p>}
|
|
93
|
+
{diff ? <section className="playbook-callout"><strong>{diff.againstVersionId ? `Changes from version ${versions.find(version => version.id === diff.againstVersionId)?.version ?? "previous"}` : "First published version"}</strong><p>{diff.nodes.added.length} steps added · {diff.nodes.changed.length} changed · {diff.nodes.removed.length} removed{diff.edgesChanged ? " · Flow paths changed" : ""}</p>{diff.againstVersionId && diff.guide?.length ? <p>Guide sections: {diff.guide.filter(key => key !== "schema_version").map(key => ({ summary: "Situation", policy_text: "Policy guidance", policy_version_id: "Policy version", policy_code: "Policy rule", editing_mode: "Editing mode" }[key] ?? readable(key))).join(", ")}.</p> : null}{diff.authorityMode.from !== diff.authorityMode.to && diff.authorityMode.from ? <p>Authority changed from {readable(diff.authorityMode.from)} to {readable(diff.authorityMode.to)}.</p> : null}</section> : null}
|
|
94
|
+
<p>{detail.testEvidence?.example_test_run_ids?.length ?? (detail.testEvidence?.test_run_id ? 1 : 0)} passing example records preserved with this version.</p>
|
|
95
|
+
{detail.testEvidence?.examples?.length ? <details className="playbook-published-examples"><summary>Inspect the examples used for publication</summary><p>These records preserve the inputs and results from publication, even after an example is edited or deleted.</p>{detail.testEvidence.examples.map(example => <button className="text-button" type="button" key={example.test_run_id} disabled={evidenceLoading} onClick={() => {
|
|
96
|
+
setEvidenceLoading(true); setEvidence(null); const generation = evidenceGeneration.current;
|
|
97
|
+
void requestJson<{ test_run: unknown }>(`/v1/admin/workflows/${workflowId}/test-runs/${example.test_run_id}`).then(result => { if (generation === evidenceGeneration.current) setEvidence({ name: example.name, record: result.test_run }); }).catch(cause => { if (generation === evidenceGeneration.current) setError(errorMessage(cause, "This publication example could not be loaded.")); }).finally(() => { if (generation === evidenceGeneration.current) setEvidenceLoading(false); });
|
|
98
|
+
}}>{example.name} · revision {example.revision}</button>)}{evidenceLoading ? <p role="status">Loading the saved test record…</p> : evidence ? <div><h4>{evidence.name}</h4><pre className="playbook-json-record" tabIndex={0} aria-label="Published example record">{JSON.stringify(evidence.record, null, 2)}</pre></div> : null}</details> : null}
|
|
99
|
+
<details><summary>Published workflow and verification record</summary><pre className="playbook-json-record" tabIndex={0} aria-label="Published workflow record">{JSON.stringify({ graph: detail.graph, graphDigest: detail.graphDigest, bundleDigest: detail.bundleDigest, testEvidence: detail.testEvidence }, null, 2)}</pre></details>
|
|
100
|
+
{canActivate ? <form className="playbook-activation" onSubmit={event => void activate(event)}><fieldset className="playbook-guide-fields" disabled={disabled || pending}><legend>Activate this version</legend><h4>Activate version {detail.version}</h4><label>Queue for new cases<select required value={queueId} onChange={event => setQueueId(event.currentTarget.value)}><option value="">Choose a queue</option>{queues.map(queue => <option key={queue.id} value={queue.id}>{queue.name}</option>)}</select></label>
|
|
101
|
+
<div className="playbook-callout">{pins ? <><strong>{activePin ? `Currently active: ${activePin.workflowName}, version ${activePin.version}` : "No version active in this mode"}</strong><p>{mode === "shadow" ? "Observe runs alongside the primary process and cannot enforce an outcome." : "New eligible cases will use this version as their primary process."} Existing cases keep their original version.</p></> : <p>{pinError || (queueId ? "Checking current activation…" : "Choose a queue to check its current activation.")}</p>}</div>
|
|
102
|
+
<button type="submit" className="primary" disabled={!pins || !queueId || alreadyActive || Boolean(detail.retiredAt) || detail.authorityMode === "off"}>{pending ? "Activating…" : alreadyActive ? "Active for new cases" : `Activate version ${detail.version} for new cases`}</button></fieldset></form> : null}
|
|
103
|
+
</div> : <p role="status">Loading the published version…</p>}</div> : <div className="playbook-callout"><strong>No published versions yet</strong><p>Save your guide and pass the examples, then publish the draft. Publication alone does not activate it.</p></div>}
|
|
104
|
+
<p className="error" role="alert">{error}</p><p role="status">{notice}</p>
|
|
105
|
+
</section>;
|
|
106
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CreateWorkflowDialog } from "./CreateWorkflowDialog";
|
|
2
2
|
import { WorkflowStudio } from "./WorkflowStudio";
|
|
3
|
+
import { CreatePlaybookDialog } from "./CreatePlaybookDialog";
|
|
3
4
|
|
|
4
5
|
export function WorkflowDialogs() {
|
|
5
|
-
return <><CreateWorkflowDialog /><WorkflowStudio /></>;
|
|
6
|
+
return <><CreatePlaybookDialog /><CreateWorkflowDialog /><WorkflowStudio /></>;
|
|
6
7
|
}
|