Merge pull request #45 from Autumn-27/feat/goal-management

feat(goals): 总览目标管理(增/删/改) + 通知 planner 并复活任务
This commit is contained in:
Autumn authored and GitHub committed 2026-08-23 23:42:43 +08:00
commit d8f34cc79d
9 files changed
+506 -2

No files matched your search

+8
View File
@@ -109,6 +109,8 @@ func renderPlannerTodos(items []actool.Todo) string {
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
// "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本).
// "goal_edited" — the human edited a goal from 总览的目标管理 (OldGoal→NewGoal 文本).
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
// stopped (not deleted) and the reason is attached to it as a fact.
type TriggerEvent struct {
@@ -116,6 +118,8 @@ type TriggerEvent struct {
IntentID int64
Detail string
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
}
// renderTriggers spells out the change(s) that fired this round: for a finished
@@ -136,6 +140,10 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
} else {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 个目标:%s —— 均为新的待达成目标,请逐一为尚无对应意图的目标补充探索方向。", len(ev.Goals), strings.Join(ev.Goals, ";")))
}
case "goal_deleted":
b.WriteString(fmt.Sprintf("\n- 人删除了该目标:%s —— 该目标已移除,请据此重判剩余目标/方向(不必再为它派意图)。", ev.Detail))
case "goal_edited":
b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal))
case "finding":
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
case "cancelled":
+34
View File
@@ -228,6 +228,40 @@ func (s *ExplorationStore) SetNodeState(id int64, state string) error {
return err
}
// UpdateGoalPayload rewrites a goal node's payload text (and optional vulnclass);
// scoped to kind='goal' so it can never mutate an intent/fact by id. Returns an
// error if no such goal exists in this exploration.
func (s *ExplorationStore) UpdateGoalPayload(id int64, text, vulnclass string) error {
payload := map[string]any{"text": text}
if vulnclass != "" {
payload["vulnclass"] = vulnclass
}
raw, _ := json.Marshal(payload)
res, err := s.db.Exec(`UPDATE exploration_nodes SET payload=$1 WHERE id=$2 AND exploration_id=$3 AND kind='goal'`, string(raw), id, s.expID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("目标不存在")
}
return nil
}
// DeleteGoal hard-deletes a goal node; scoped to kind='goal' so it can never drop
// an intent/fact by id. The edges (spawns) and anchors reference it with ON DELETE
// CASCADE, so they go with it; activity.node_id is ON DELETE SET NULL. Returns an
// error if no such goal exists in this exploration.
func (s *ExplorationStore) DeleteGoal(id int64) error {
res, err := s.db.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND exploration_id=$2 AND kind='goal'`, id, s.expID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("目标不存在")
}
return nil
}
// SetIntentState updates an intent node's state.
// ResetRunningIntents returns any intent left in 'running' back to 'open' so it
// is re-claimed. Called on startup: a 'running' intent with no live worker (a
+35
View File
@@ -173,6 +173,41 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
return d
}
// GoalDTO is a goal node with its payload unpacked into text/vulnclass — the shape
// the 总览「目标管理」UI works with (vs TaskNodeDTO which carries raw payload JSON).
type GoalDTO struct {
ID string `json:"id"`
Text string `json:"text"`
VulnClass string `json:"vulnclass,omitempty"`
State string `json:"state"`
Origin string `json:"origin,omitempty"`
TS string `json:"ts"`
}
func goalDTO(n *db.Node) GoalDTO {
var p struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
_ = json.Unmarshal(n.Payload, &p)
return GoalDTO{
ID: i64s(n.ID),
Text: p.Text,
VulnClass: p.VulnClass,
State: n.State,
Origin: n.Origin,
TS: rfc3339(n.CreatedAt),
}
}
func goalDTOs(in []*db.Node) []GoalDTO {
out := make([]GoalDTO, 0, len(in))
for _, n := range in {
out = append(out, goalDTO(n))
}
return out
}
func taskNodeDTOs(in []*db.Node) []TaskNodeDTO {
out := make([]TaskNodeDTO, 0, len(in))
for _, n := range in {
+172
View File
@@ -0,0 +1,172 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
)
// 总览「目标管理」的人工 CRUD 接口。与 agent 侧的 set_goals 工具写同一批 goal 节点,
// 但入口是人类在 UI 上直接增删改;新增/修改后复用「复活任务」逻辑(admitTask resume:
// 终态→running、解除暂停、必要时排队),删除不复活(按产品决策)。每个变更 handler 都走
// beginTaskOperation/decInflight,避免与任务删除竞态(与意图 CRUD 一致)。
// listGoals 返回本任务的全部目标(text/vulnclass/state 拆好),供目标管理卡片渲染。
func (s *Server) listGoals(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
goals, err := t.Store.ListByKind(db.KindGoal, 10000)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"goals": goalDTOs(goals)})
}
// addGoal 人工新增一个目标:落库(挂到任务根 spawns 下)→ 记一条「新增了目标」触发唤醒
// planner → 复活任务,让规划者据新目标重判是否达成。
func (s *Server) addGoal(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法新增目标")
return
}
defer s.engine.decInflight(t.ID)
var body struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "invalid JSON")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
writeErr(w, 400, "目标内容不能为空")
return
}
payload := map[string]any{"text": text}
if vc := strings.TrimSpace(body.VulnClass); vc != "" {
payload["vulnclass"] = vc
}
id, err := t.Store.AddGoal(payload, "human")
if err != nil {
writeErr(w, 500, err.Error())
return
}
if of, _ := t.Store.OriginFactID(); of > 0 && id > 0 {
_ = t.Store.Link(of, db.RelSpawns, id) // goal descends from the task root (origin fact)
}
t.NotifyGoal([]string{text}) // 记「人新增了目标:…」触发并唤醒 planner
s.reviveTask(t) // 把已完成/暂停的任务拉回运行态继续跑
node, _ := t.Store.GetNode(id)
if node == nil {
writeErr(w, 500, "目标写入后读取失败")
return
}
writeJSON(w, 200, goalDTO(node))
}
// editGoal 人工修改一个目标文本(及 vulnclass):改库 → 记「用户修改了目标由 old 变为 new」
// 触发唤醒 planner → 复活任务,让规划者据新目标调整方向。
func (s *Server) editGoal(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法修改目标")
return
}
defer s.engine.decInflight(t.ID)
gid, err := strconv.ParseInt(r.PathValue("gid"), 10, 64)
if err != nil || gid <= 0 {
writeErr(w, 400, "bad goal id")
return
}
var body struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "invalid JSON")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
writeErr(w, 400, "目标内容不能为空")
return
}
node, err := t.Store.GetNode(gid)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if node == nil || node.Kind != db.KindGoal {
writeErr(w, 404, "目标不存在")
return
}
oldText := goalDTO(node).Text
if err := t.Store.UpdateGoalPayload(gid, text, strings.TrimSpace(body.VulnClass)); err != nil {
writeErr(w, 500, err.Error())
return
}
t.NotifyGoalEdited(oldText, text) // 记「人修改了目标由 old 变为 new」触发并唤醒 planner
s.reviveTask(t) // 与新增一致:复活任务据新目标重判
updated, _ := t.Store.GetNode(gid)
if updated == nil {
writeErr(w, 500, "目标更新后读取失败")
return
}
writeJSON(w, 200, goalDTO(updated))
}
// deleteGoal 人工删除一个目标(硬删除,级联删边/锚点):删库 → 记「用户删除了该目标 X」
// 触发唤醒 planner 据此重判剩余目标。按产品决策,删除【不】复活任务。
func (s *Server) deleteGoal(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法删除目标")
return
}
defer s.engine.decInflight(t.ID)
gid, err := strconv.ParseInt(r.PathValue("gid"), 10, 64)
if err != nil || gid <= 0 {
writeErr(w, 400, "bad goal id")
return
}
node, err := t.Store.GetNode(gid)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if node == nil || node.Kind != db.KindGoal {
writeErr(w, 404, "目标不存在")
return
}
text := goalDTO(node).Text
if err := t.Store.DeleteGoal(gid); err != nil {
writeErr(w, 500, err.Error())
return
}
t.NotifyGoalDeleted(text) // 记「人删除了该目标:…」触发并唤醒 planner(不复活任务)
writeJSON(w, 200, map[string]bool{"ok": true})
}
+28
View File
@@ -1488,6 +1488,34 @@ func (t *Task) NotifyGoal(texts []string) {
t.Notify()
}
// NotifyGoalDeleted records that the human deleted a goal (via 总览的目标管理), then
// wakes the planner so the next round spells out which goal was removed. The event
// survives an early-returning terminal round (drain happens after the gate).
func (t *Task) NotifyGoalDeleted(text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal_deleted", Detail: text})
t.trigMu.Unlock()
t.Notify()
}
// NotifyGoalEdited records that the human edited a goal (via 总览的目标管理), then wakes
// the planner so the next round spells out the old→new change. The event survives an
// early-returning terminal round (drain happens after the gate).
func (t *Task) NotifyGoalEdited(oldText, newText string) {
oldText, newText = strings.TrimSpace(oldText), strings.TrimSpace(newText)
if newText == "" {
return
}
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal_edited", OldGoal: oldText, NewGoal: newText})
t.trigMu.Unlock()
t.Notify()
}
// NotifyCancelled records that the human deleted intentID (reason = 删除原因), then
// wakes the planner so the next round spells out which intent was removed and why.
// The intent is stopped (not deleted); the reason is attached to it as a fact.
+4
View File
@@ -665,6 +665,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete)
mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标
mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务)
mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务)
mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler)
@@ -6,13 +6,17 @@ import {
ActivityIcon,
AlertTriangleIcon,
BugIcon,
CheckIcon,
ClockIcon,
CoinsIcon,
ListChecksIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
ShieldCheckIcon,
TargetIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
@@ -22,7 +26,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { api } from "@/lib/api";
import type { Finding, ModelTokenStat, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types";
import type { Finding, ModelTokenStat, Stats, Task, TaskGoal, TaskNode, TaskScopeRow } from "@/lib/types";
// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
function fmtTokens(n: number): string {
@@ -106,6 +110,15 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const [scopeErr, setScopeErr] = React.useState("");
// 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。
const [modelTokens, setModelTokens] = React.useState<ModelTokenStat[]>([]);
// 目标管理:目标列表 + 新增表单 + 行内编辑状态。
const [goals, setGoals] = React.useState<TaskGoal[]>([]);
const [goalText, setGoalText] = React.useState("");
const [goalVuln, setGoalVuln] = React.useState("");
const [goalBusy, setGoalBusy] = React.useState(false);
const [goalErr, setGoalErr] = React.useState("");
const [editingGoalId, setEditingGoalId] = React.useState<string | null>(null);
const [editText, setEditText] = React.useState("");
const [editVuln, setEditVuln] = React.useState("");
const loadTokens = React.useCallback(async () => {
try {
@@ -125,6 +138,69 @@ export function OverviewTab({ taskId }: { taskId: string }) {
}
}, [taskId]);
const loadGoals = React.useCallback(async () => {
try {
const resp = await api.taskGoals(taskId);
setGoals(resp.goals);
} catch {
// 忽略:瞬时错误,下次轮询重试
}
}, [taskId]);
const addGoal = async () => {
const text = goalText.trim();
if (!text) return;
setGoalBusy(true);
setGoalErr("");
try {
await api.addGoal(taskId, text, goalVuln.trim() || undefined);
setGoalText("");
setGoalVuln("");
await loadGoals();
} catch (e) {
setGoalErr(e instanceof Error ? e.message : "添加失败");
} finally {
setGoalBusy(false);
}
};
const startEditGoal = (g: TaskGoal) => {
setEditingGoalId(g.id);
setEditText(g.text);
setEditVuln(g.vulnclass ?? "");
};
const cancelEditGoal = () => {
setEditingGoalId(null);
setEditText("");
setEditVuln("");
};
const saveEditGoal = async (g: TaskGoal) => {
const text = editText.trim();
if (!text) return;
setGoalBusy(true);
setGoalErr("");
try {
await api.updateGoal(taskId, g.id, text, editVuln.trim() || undefined);
cancelEditGoal();
await loadGoals();
} catch (e) {
setGoalErr(e instanceof Error ? e.message : "保存失败");
} finally {
setGoalBusy(false);
}
};
const removeGoal = async (g: TaskGoal) => {
setGoals((prev) => prev.filter((x) => x.id !== g.id));
try {
await api.deleteGoal(taskId, g.id);
} catch {
await loadGoals(); // 删除失败:重新拉取还原
}
};
const addScope = async () => {
const value = scopeValueInput.trim();
if (!value) return;
@@ -216,15 +292,17 @@ export function OverviewTab({ taskId }: { taskId: string }) {
load();
void loadScope();
void loadTokens();
void loadGoals();
const timer = setInterval(() => {
void load();
void loadTokens();
void loadGoals();
}, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId, loadScope, loadTokens]);
}, [taskId, loadScope, loadTokens, loadGoals]);
const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open");
@@ -264,6 +342,128 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div>
</CardContent>
</Card>
{/* 目标管理:查看/新增/修改/删除本任务的探索目标。新增与修改会通知规划者并复活任务,
删除仅通知规划者(不复活)。目标 = 最终可交付/可核验的结果,不是攻击步骤或侦察动作。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ListChecksIcon className="size-4 text-primary" /> 目标管理
<span className="text-muted-foreground text-xs font-normal">
(最终可核验的目标,共 {goals.length} 条;新增/修改会通知规划者并复活任务)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-7 min-w-56 flex-1 text-sm"
placeholder="新增目标,如『拿到管理员账号的越权访问』"
value={goalText}
onChange={(e) => setGoalText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addGoal();
}}
disabled={goalBusy}
/>
<Input
className="h-7 w-32 text-sm"
placeholder="漏洞类(可选)"
value={goalVuln}
onChange={(e) => setGoalVuln(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addGoal();
}}
disabled={goalBusy}
/>
<Button size="sm" variant="outline" disabled={goalBusy || !goalText.trim()} onClick={() => void addGoal()}>
<PlusIcon className="size-3.5" /> 添加
</Button>
{goalErr && <span className="text-xs text-red-500">{goalErr}</span>}
</div>
{/* 目标列表 */}
{goals.length > 0 ? (
<div className="flex flex-col gap-1.5">
{goals.map((g) =>
editingGoalId === g.id ? (
<div key={g.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
<Input
className="h-7 min-w-56 flex-1 text-sm"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEditGoal(g);
if (e.key === "Escape") cancelEditGoal();
}}
disabled={goalBusy}
autoFocus
/>
<Input
className="h-7 w-32 text-sm"
placeholder="漏洞类(可选)"
value={editVuln}
onChange={(e) => setEditVuln(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEditGoal(g);
if (e.key === "Escape") cancelEditGoal();
}}
disabled={goalBusy}
/>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy || !editText.trim()}
onClick={() => void saveEditGoal(g)}
>
<CheckIcon className="size-3.5 text-emerald-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy}
onClick={cancelEditGoal}
>
<XIcon className="size-3.5" />
</Button>
</div>
) : (
<div key={g.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<StatusBadge domain="goal" value={g.state} />
<span className="min-w-0 flex-1 break-words">{g.text}</span>
{g.vulnclass && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{g.vulnclass}
</span>
)}
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy}
onClick={() => startEditGoal(g)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy}
onClick={() => void removeGoal(g)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
</div>
),
)}
</div>
) : (
<p className="text-muted-foreground text-sm">暂无目标,添加后规划者会据此派发探索意图并判定达成。</p>
)}
</CardContent>
</Card>
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
<Card>
<CardHeader>
+13
View File
@@ -56,6 +56,7 @@ import type {
SSTask,
Stats,
Task,
TaskGoal,
TaskLLMResolutions,
TaskNode,
TaskScopeRow,
@@ -256,6 +257,18 @@ export const api = {
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
// ---- 目标管理(总览)----
// 本任务全部目标(text/vulnclass/state)。
taskGoals: (id: string) => get<{ goals: TaskGoal[] }>(`/tasks/${id}/goals`),
// 人工新增目标:写入图谱并通知 planner、复活任务。
addGoal: (id: string, text: string, vulnclass?: string) =>
post<TaskGoal>(`/tasks/${id}/goals`, { text, vulnclass: vulnclass ?? "" }),
// 人工修改目标文本(及 vulnclass):通知 planner「由 old 变为 new」、复活任务。
updateGoal: (id: string, goalId: string, text: string, vulnclass?: string) =>
patch<TaskGoal>(`/tasks/${id}/goals/${goalId}`, { text, vulnclass: vulnclass ?? "" }),
// 人工删除目标(硬删除):通知 planner,删除不复活任务。
deleteGoal: (id: string, goalId: string) => del<{ ok: boolean }>(`/tasks/${id}/goals/${goalId}`),
// 本任务测试范围列表(含继承自来源任务的范围)。
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
+10
View File
@@ -299,6 +299,16 @@ export interface TaskNode {
inherited?: boolean;
}
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
export interface TaskGoal {
id: string;
text: string;
vulnclass?: string;
state: string; // GoalState
origin?: string;
ts: string;
}
// ---- Findings ----
export type Severity = "critical" | "high" | "medium" | "low";