create-safest-tools 0.5.2 → 0.6.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/template/console/registry/RegistryDetailsDialog.tsx +249 -0
- package/template/console/registry/RegistryDialog.tsx +116 -62
- package/template/console/registry/RegistryWorkspace.tsx +75 -55
- package/template/console/registry/types.ts +23 -1
- package/template/console/reports/ReportDrawer.tsx +1 -10
- package/template/console/shell/navigation.ts +1 -1
- package/template/package.json +2 -2
- package/template/public/console/auth-shell.js +8931 -8348
- package/template/public/styles.css +201 -7
- package/template/src/component-executor.ts +4 -1
- package/template/src/platform-registry.ts +19 -9
- package/template/src/report-email-inbound.ts +11 -4
- package/template/src/report-email.ts +12 -9
- package/template/src/report-repository.ts +6 -1
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import { PageHeader } from "../components/PageHeader";
|
|
3
3
|
import { readable } from "../lib/format";
|
|
4
|
-
import {
|
|
4
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
5
5
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
6
6
|
import { openRegistryEditor, registryEvents } from "./events";
|
|
7
|
-
import
|
|
7
|
+
import { RegistryDetailsDialog } from "./RegistryDetailsDialog";
|
|
8
|
+
import type { ComponentListResponse, ComponentSummary, ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
type RegistryItem = ComponentSummary | ConnectionSummary;
|
|
11
|
+
type ToolFilter = "" | "enrichment" | "action" | "ai" | "message";
|
|
12
|
+
|
|
13
|
+
function toolCategory(component: ComponentSummary): ToolFilter {
|
|
14
|
+
if (component.effectClass === "side_effecting") return "action";
|
|
15
|
+
if (["workers_ai", "specialist_agent"].includes(component.kind)) return "ai";
|
|
16
|
+
if (component.kind === "message") return "message";
|
|
17
|
+
return "enrichment";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function categoryLabel(component: ComponentSummary): string {
|
|
21
|
+
const category = toolCategory(component);
|
|
22
|
+
if (category === "action") return "Action";
|
|
23
|
+
if (category === "ai") return "AI analysis";
|
|
24
|
+
if (category === "message") return "Message";
|
|
25
|
+
return "Enrichment";
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
28
|
+
function lifecycleLabel(status: string): string {
|
|
29
|
+
if (status === "paused") return "Paused";
|
|
30
|
+
if (status === "deprecated") return "Retiring";
|
|
31
|
+
if (status === "archived") return "Archived";
|
|
32
|
+
return "Available";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function RegistryRow({ item, kind, onOpen }: { item: RegistryItem; kind: RegistryKind; onOpen: () => void }) {
|
|
24
36
|
const connection = kind === "connection" ? item as ConnectionSummary : null;
|
|
25
37
|
const component = kind === "component" ? item as ComponentSummary : null;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
return <article className="
|
|
33
|
-
<
|
|
34
|
-
<h3>{item.name}</h3><p>{item.description || "No description yet."}</p>
|
|
35
|
-
<div className="
|
|
36
|
-
<div className="
|
|
38
|
+
const category = component ? toolCategory(component) : "";
|
|
39
|
+
const iconClass = connection ? "connection" : category === "action" ? "action" : category === "ai" ? "agent" : "";
|
|
40
|
+
const icon = connection ? "↗" : category === "action" ? "⚡" : category === "ai" ? "✦" : category === "message" ? "✉" : "+";
|
|
41
|
+
const status = lifecycleLabel(item.status || "active");
|
|
42
|
+
const statusClass = item.status === "active" ? "available" : item.status === "archived" ? "archived" : "paused";
|
|
43
|
+
const destination = connection?.baseUrl || connection?.serviceBindingName || "Destination not configured";
|
|
44
|
+
return <article className="registry-resource-row">
|
|
45
|
+
<span className={`object-icon ${iconClass}`} aria-hidden="true">{icon}</span>
|
|
46
|
+
<div className="registry-resource-main"><div className="registry-resource-title"><h3>{item.name}</h3>{component?.ownerKind === "safest" ? <span className="registry-owner-badge">Safest built-in</span> : null}</div><p>{item.description || "No description yet."}</p><div className="registry-resource-tags"><span>{component ? categoryLabel(component) : readable(connection?.kind || "Connection")}</span><span>v{item.version || 1}</span>{component ? <span>{component.effectClass === "side_effecting" ? "Approval required" : "Read-only"}</span> : <span>{readable(connection?.credentialStrategy || "No authentication")}</span>}</div></div>
|
|
47
|
+
<div className="registry-resource-relationship"><span>{component ? "Used by" : "Used by tools"}</span><strong>{item.dependencyCount || 0}</strong><small>{component ? `${item.dependencyCount === 1 ? "workflow" : "workflows"}` : `${item.dependencyCount === 1 ? "tool version" : "tool versions"}`}</small></div>
|
|
48
|
+
<div className="registry-resource-state"><span className={`registry-status ${statusClass}`}><i aria-hidden="true" />{status}</span>{connection ? <small title={destination}>{destination}</small> : <small>{readable(component?.implementationKind || component?.kind || "Not configured")}</small>}</div>
|
|
49
|
+
<button className="secondary" type="button" onClick={onOpen}>View details</button>
|
|
37
50
|
</article>;
|
|
38
51
|
}
|
|
39
52
|
|
|
@@ -45,12 +58,13 @@ export function RegistryWorkspace() {
|
|
|
45
58
|
const canPublishConnections = shell.session?.permissions.publishConnections === true;
|
|
46
59
|
const [components, setComponents] = useState<ComponentSummary[]>([]);
|
|
47
60
|
const [connections, setConnections] = useState<ConnectionSummary[]>([]);
|
|
48
|
-
const [tab, setTab] = useState<"
|
|
61
|
+
const [tab, setTab] = useState<"tools" | "connections">("tools");
|
|
49
62
|
const [search, setSearch] = useState("");
|
|
50
|
-
const [
|
|
63
|
+
const [filter, setFilter] = useState<ToolFilter>("");
|
|
64
|
+
const [includeArchived, setIncludeArchived] = useState(false);
|
|
65
|
+
const [selected, setSelected] = useState<{ item: RegistryItem; kind: RegistryKind } | null>(null);
|
|
51
66
|
const [loading, setLoading] = useState(false);
|
|
52
67
|
const [refreshing, setRefreshing] = useState(false);
|
|
53
|
-
const [checking, setChecking] = useState("");
|
|
54
68
|
const [error, setError] = useState("");
|
|
55
69
|
const [notice, setNotice] = useState("");
|
|
56
70
|
|
|
@@ -58,16 +72,17 @@ export function RegistryWorkspace() {
|
|
|
58
72
|
if (!canManage) { setComponents([]); setConnections([]); return; }
|
|
59
73
|
if (refresh) setRefreshing(true); else setLoading(true);
|
|
60
74
|
setError("");
|
|
75
|
+
const query = includeArchived ? "?include_archived=true" : "";
|
|
61
76
|
try {
|
|
62
77
|
const [componentResult, connectionResult] = await Promise.all([
|
|
63
|
-
requestJson<ComponentListResponse>(
|
|
64
|
-
canManageConnections ? requestJson<ConnectionListResponse>(
|
|
78
|
+
requestJson<ComponentListResponse>(`/v1/admin/components${query}`),
|
|
79
|
+
canManageConnections ? requestJson<ConnectionListResponse>(`/v1/admin/connections${query}`) : Promise.resolve({ connections: [] }),
|
|
65
80
|
]);
|
|
66
81
|
setComponents(componentResult.components || []);
|
|
67
82
|
setConnections(connectionResult.connections || []);
|
|
68
|
-
} catch (cause) { setError(errorMessage(cause, "
|
|
83
|
+
} catch (cause) { setError(errorMessage(cause, "Tools and connections could not be loaded.")); }
|
|
69
84
|
finally { setLoading(false); setRefreshing(false); }
|
|
70
|
-
}, [canManage, canManageConnections]);
|
|
85
|
+
}, [canManage, canManageConnections, includeArchived]);
|
|
71
86
|
|
|
72
87
|
useEffect(() => { if (shell.currentView === "components") void load(); }, [shell.currentView, shell.session?.actor.id, load]);
|
|
73
88
|
useEffect(() => {
|
|
@@ -76,33 +91,38 @@ export function RegistryWorkspace() {
|
|
|
76
91
|
return () => window.removeEventListener(registryEvents.changed, changed);
|
|
77
92
|
}, [shell.currentView, load]);
|
|
78
93
|
|
|
79
|
-
const
|
|
94
|
+
const filteredComponents = useMemo(() => {
|
|
95
|
+
const term = search.trim().toLowerCase();
|
|
96
|
+
return components.filter((component) => (!filter || toolCategory(component) === filter) && (!term || `${component.name} ${component.description} ${component.kind} ${component.implementationKind}`.toLowerCase().includes(term)));
|
|
97
|
+
}, [components, filter, search]);
|
|
98
|
+
const filteredConnections = useMemo(() => {
|
|
80
99
|
const term = search.trim().toLowerCase();
|
|
81
|
-
return
|
|
82
|
-
}, [
|
|
100
|
+
return connections.filter((connection) => !term || `${connection.name} ${connection.description} ${connection.kind} ${connection.baseUrl} ${connection.serviceBindingName}`.toLowerCase().includes(term));
|
|
101
|
+
}, [connections, search]);
|
|
102
|
+
const items = tab === "tools" ? filteredComponents : filteredConnections;
|
|
103
|
+
const currentKind: RegistryKind = tab === "tools" ? "component" : "connection";
|
|
104
|
+
const canCreate = tab === "tools" ? canPublishComponents : canPublishConnections;
|
|
83
105
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const result = await requestJson<RegistryValidationResponse>(`/v1/admin/${kind === "connection" ? "connections" : "components"}/${encodeURIComponent(item.id)}/validate`, { method: "POST" });
|
|
88
|
-
setNotice(result.validation.valid ? `${item.name} is valid and ready.` : `${item.name} needs changes.`);
|
|
89
|
-
} catch (cause) { setError(validationMessage(cause, item.name)); }
|
|
90
|
-
finally { setChecking(""); }
|
|
106
|
+
const changed = (message: string): void => {
|
|
107
|
+
setNotice(message);
|
|
108
|
+
window.dispatchEvent(new Event(registryEvents.changed));
|
|
91
109
|
};
|
|
92
110
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<PageHeader titleId="components-title" title="Components" description="Create reusable checks, messages, and actions for workflows.">
|
|
97
|
-
<div className="surface-buttons">
|
|
98
|
-
<button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button>
|
|
99
|
-
{canPublishConnections ? <button className="secondary" type="button" onClick={() => openRegistryEditor("connection")}>New connection</button> : null}
|
|
100
|
-
{canPublishComponents ? <button className="primary" type="button" onClick={() => openRegistryEditor("component")}>New component</button> : null}
|
|
101
|
-
</div>
|
|
111
|
+
return <div className="page-stack registry-workspace" data-react-slice="registry-workspace">
|
|
112
|
+
<PageHeader titleId="components-title" title="Tools & connections" description="Control what workflows can do, which data they can use, and where requests may go.">
|
|
113
|
+
<div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button>{canCreate ? <button className="primary" type="button" onClick={() => openRegistryEditor(currentKind)}>{tab === "tools" ? "Create tool" : "Add connection"}</button> : null}</div>
|
|
102
114
|
</PageHeader>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
|
|
116
|
+
<section className="registry-model" aria-label="How workflows, tools, and connections work together">
|
|
117
|
+
<div><span>1</span><strong>Workflow</strong><small>Chooses when work runs</small></div><b aria-hidden="true">→</b><div><span>2</span><strong>Tool</strong><small>Reads context or takes an action</small></div><b aria-hidden="true">→</b><div><span>3</span><strong>Connection</strong><small>Secures access to a service</small></div><b aria-hidden="true">→</b><div><span>4</span><strong>Your service</strong><small>Returns data or applies a change</small></div>
|
|
118
|
+
</section>
|
|
119
|
+
|
|
120
|
+
<div className="registry-tabs-row"><div className="segmented-tabs" role="tablist" aria-label="Tools and connections"><button className={tab === "tools" ? "active" : ""} type="button" role="tab" aria-selected={tab === "tools"} onClick={() => { setTab("tools"); setSearch(""); }}>Tools <span>{components.filter((item) => item.status !== "archived").length}</span></button><button className={tab === "connections" ? "active" : ""} type="button" role="tab" aria-selected={tab === "connections"} onClick={() => { setTab("connections"); setSearch(""); }}>Connections <span>{connections.filter((item) => item.status !== "archived").length}</span></button></div><p>{tab === "tools" ? "Reusable capabilities placed in workflows. Enrichments are read-only; actions require approval." : "Reusable, allowlisted routes to APIs and Workers. Credential values stay in Worker secrets."}</p></div>
|
|
121
|
+
|
|
122
|
+
{(tab === "tools" ? components.length : connections.length) ? <div className="registry-toolbar"><label className="search-field"><span aria-hidden="true">⌕</span><input type="search" placeholder={`Search ${tab}`} aria-label={`Search ${tab}`} value={search} onChange={(event) => setSearch(event.currentTarget.value)} /></label>{tab === "tools" ? <div className="registry-filter-chips" role="group" aria-label="Filter tools by purpose">{[["", "All"], ["enrichment", "Enrichments"], ["action", "Actions"], ["ai", "AI analysis"], ["message", "Messages"]].map(([value, label]) => <button className={filter === value ? "active" : ""} type="button" aria-pressed={filter === value} onClick={() => setFilter(value as ToolFilter)} key={value}>{label}</button>)}</div> : null}<label className="registry-archived-toggle"><input type="checkbox" checked={includeArchived} onChange={(event) => setIncludeArchived(event.currentTarget.checked)} />Show archived</label></div> : null}
|
|
123
|
+
|
|
124
|
+
<div className="registry-resource-list" aria-busy={loading || refreshing}>{loading ? <><div className="skeleton-card" /><div className="skeleton-card" /></> : items.length ? items.map((item) => <RegistryRow item={item} kind={currentKind} onOpen={() => setSelected({ item, kind: currentKind })} key={item.id} />) : error ? null : <div className="empty-product"><span aria-hidden="true">{tab === "tools" ? "+" : "↗"}</span><h2>{search || filter ? `No ${tab} match` : tab === "tools" ? "Create your first tool" : "Add your first connection"}</h2><p>{search || filter ? "Change or clear your search and filters." : tab === "tools" ? "Start with an enrichment to add context, an action to change customer data, AI analysis, or a reusable message." : "Connect an HTTPS API or customer Worker, then choose it when creating a tool."}</p>{canCreate && !search && !filter ? <button className="primary" type="button" onClick={() => openRegistryEditor(currentKind)}>{tab === "tools" ? "Create tool" : "Add connection"}</button> : null}</div>}</div>
|
|
106
125
|
<p className="form-message" role="status">{notice}</p><p className="error surface-error" role="alert">{error}</p>
|
|
126
|
+
<RegistryDetailsDialog item={selected?.item || null} kind={selected?.kind || currentKind} canManage={selected?.kind === "connection" ? canManageConnections : canManage} onClose={() => setSelected(null)} onChanged={changed} />
|
|
107
127
|
</div>;
|
|
108
128
|
}
|
|
@@ -45,8 +45,30 @@ export interface RegistryValidationIssue {
|
|
|
45
45
|
|
|
46
46
|
export interface RegistryValidation {
|
|
47
47
|
valid: boolean;
|
|
48
|
-
errors?: RegistryValidationIssue
|
|
48
|
+
errors?: Array<RegistryValidationIssue | string>;
|
|
49
49
|
warnings?: RegistryValidationIssue[];
|
|
50
|
+
[key: string]: unknown;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export interface RegistryValidationResponse { validation: RegistryValidation }
|
|
54
|
+
|
|
55
|
+
export interface RegistryDetailResponse {
|
|
56
|
+
component?: {
|
|
57
|
+
definition: Record<string, unknown>;
|
|
58
|
+
versions: Array<Record<string, unknown>>;
|
|
59
|
+
fixtures: Array<Record<string, unknown>>;
|
|
60
|
+
testRuns: Array<Record<string, unknown>>;
|
|
61
|
+
};
|
|
62
|
+
connection?: {
|
|
63
|
+
definition: Record<string, unknown>;
|
|
64
|
+
versions: Array<Record<string, unknown>>;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RegistryDependenciesResponse {
|
|
69
|
+
dependencies: {
|
|
70
|
+
workflows?: Array<Record<string, unknown>>;
|
|
71
|
+
components?: Array<Record<string, unknown>>;
|
|
72
|
+
modelAliases?: Array<Record<string, unknown>>;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -95,15 +95,6 @@ function timelineTitle(event: TimelineEvent): string {
|
|
|
95
95
|
return readable(event.eventType || "Case activity");
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
function timelineMarker(eventType: string): string {
|
|
99
|
-
if (eventType === "finding") return "⬡";
|
|
100
|
-
if (eventType === "workflow_run") return "⌘";
|
|
101
|
-
if (eventType === "decision") return "✓";
|
|
102
|
-
if (eventType === "message") return "↗";
|
|
103
|
-
if (eventType === "action") return "!";
|
|
104
|
-
return "•";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
98
|
const decisionOptions = [
|
|
108
99
|
{ value: "violation_confirmed", label: "Violation confirmed" },
|
|
109
100
|
{ value: "no_violation_found", label: "No violation found" },
|
|
@@ -216,7 +207,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
216
207
|
</div></section>
|
|
217
208
|
</div>
|
|
218
209
|
|
|
219
|
-
{workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`}
|
|
210
|
+
{workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`} aria-hidden="true" /><div><strong>{timelineTitle(item)}</strong><span>{timelineDetail(item)}</span><small>{dateTime(item.createdAt)} · {relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div>{workflowRuns.length || technicalTimeline.length ? <details className="workflow-technical"><summary>Technical workflow details <span>{workflowRuns.length} workflow run{workflowRuns.length === 1 ? "" : "s"}</span></summary><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div>{technicalTimeline.length ? <p className="technical-event-count">{technicalTimeline.length} low-level execution event{technicalTimeline.length === 1 ? "" : "s"} recorded in the audit trail.</p> : null}</details> : null}</section> : null}
|
|
220
211
|
|
|
221
212
|
<section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
|
|
222
213
|
|
|
@@ -31,7 +31,7 @@ export const navigationGroups: NavigationGroup[] = [
|
|
|
31
31
|
label: "Build",
|
|
32
32
|
items: [
|
|
33
33
|
{ view: "workflows", label: "Workflows", icon: "i-workflow", visible: permission("manageWorkflows") },
|
|
34
|
-
{ view: "components", label: "
|
|
34
|
+
{ view: "components", label: "Tools & connections", icon: "i-component", visible: permission("manageComponents") },
|
|
35
35
|
{ view: "configuration", label: "Configuration", icon: "i-settings", visible: permission("manageConfiguration") },
|
|
36
36
|
],
|
|
37
37
|
},
|
package/template/package.json
CHANGED