mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 20:50:54 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdba29f1a9 | ||
|
|
a59d430175 | ||
|
|
c2d1f557c3 | ||
|
|
e636408f66 |
@@ -29,6 +29,10 @@ jobs:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: gofmt
|
||||
run: |
|
||||
files=$(gofmt -l $(git ls-files '*.go'))
|
||||
if [ -n "$files" ]; then echo "gofmt required:"; echo "$files"; exit 1; fi
|
||||
- run: go build ./...
|
||||
- run: go vet ./...
|
||||
# Skipped tests, tracked in issue #9:
|
||||
|
||||
+4
-4
@@ -46,9 +46,9 @@ const stagnationMinRepeats = 3
|
||||
|
||||
func detectStagnation(done, findings []*db.Node) []map[string]any {
|
||||
type dir struct {
|
||||
firstID int64
|
||||
repeats int
|
||||
summary string
|
||||
firstID int64
|
||||
repeats int
|
||||
summary string
|
||||
}
|
||||
dirs := map[string]*dir{}
|
||||
order := []string{}
|
||||
@@ -551,7 +551,7 @@ func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
// with the data so no prompt-template change is needed on deployed installs.
|
||||
if stale := detectStagnation(recentDone, vulnNodes); len(stale) > 0 {
|
||||
out["stagnation_warning"] = map[string]any{
|
||||
"directions": stale,
|
||||
"directions": stale,
|
||||
"instruction": "these directions were dispatched repeatedly with zero findings produced since their first attempt — do NOT re-dispatch the same approach again; open a materially different direction, or judge the goal on the existing evidence instead of grinding",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import "time"
|
||||
// these match the *target asset*: an exact/fuzzy domain·ip·url, or a CIDR range.
|
||||
// This layer only stores rules; the matching/enforcement logic lives elsewhere.
|
||||
type AssetInterceptRule struct {
|
||||
ID int64 `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Kind string `json:"kind"` // exact_domain|exact_ip|exact_url|fuzzy_domain|fuzzy_ip|fuzzy_url|cidr
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
Builtin bool `json:"builtin"`
|
||||
ID int64 `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Kind string `json:"kind"` // exact_domain|exact_ip|exact_url|fuzzy_domain|fuzzy_ip|fuzzy_url|cidr
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
Builtin bool `json:"builtin"`
|
||||
// Action is only used by task-level rules: 'block'=intercept, 'allow'=allow (whitelist).
|
||||
// Global rules (asset_intercept_rules) do not have this column; it is always empty and treated as intercept.
|
||||
Action string `json:"action,omitempty"`
|
||||
|
||||
+6
-6
@@ -1168,11 +1168,11 @@ func TestQueryDSLInScopeMembership(t *testing.T) {
|
||||
}
|
||||
|
||||
stamp := time.Now().UnixNano()
|
||||
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task)
|
||||
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task
|
||||
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope
|
||||
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows
|
||||
foreignTask := stamp + 777 // asset produced by an unrelated task
|
||||
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task)
|
||||
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task
|
||||
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope
|
||||
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows
|
||||
foreignTask := stamp + 777 // asset produced by an unrelated task
|
||||
|
||||
// Scope: task owns root; source task owns srcRoot; task owns an IP /24.
|
||||
for _, sc := range []struct {
|
||||
@@ -1200,7 +1200,7 @@ func TestQueryDSLInScopeMembership(t *testing.T) {
|
||||
{"subdomain", "api." + root, root, "", "", "", marker}, // under task root
|
||||
{"service", "www." + root, root, "https://www." + root + "/", "", "", marker}, // service under task root
|
||||
{"endpoint", "www." + root, root, "https://www." + root + "/a?" + marker + "=1", "GET", "", ""},
|
||||
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
|
||||
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
|
||||
{"endpoint", "198.51.100.9", "198.51.100.9", "http://198.51.100.9:8080/" + marker, "GET", "", ""}, // IP-literal host, ip col empty
|
||||
{"subdomain", "x." + out, out, "", "", "", marker}, // out of scope
|
||||
}
|
||||
|
||||
+50
-26
@@ -1486,11 +1486,13 @@ type TaskListMetrics struct {
|
||||
Findings FindingSeverityCounts // this task's vulnerability count in the findings table (bucketed by severity)
|
||||
}
|
||||
|
||||
// TaskListMetricsAll returns list aggregates for every live task in one query.
|
||||
// The lateral lookups use the per-exploration indexes instead of grouping the
|
||||
// complete activity history on every poll.
|
||||
func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
|
||||
rows, err := d.Query(`
|
||||
// taskListMetricsQuery is the select shared by TaskListMetricsAll and
|
||||
// TaskListMetrics; each appends its own WHERE on task. The lateral lookups use
|
||||
// the per-exploration indexes instead of grouping the complete activity history
|
||||
// on every poll. latest_activity orders by the column activity.created_at, so
|
||||
// idx_act_latest (exploration_id, created_at DESC) serves the LIMIT 1; ordering
|
||||
// by the output alias would sort every activity row of the task.
|
||||
const taskListMetricsQuery = `
|
||||
SELECT task.exploration_id,
|
||||
COALESCE(token_metrics.input_tokens,0),
|
||||
COALESCE(token_metrics.output_tokens,0),
|
||||
@@ -1514,10 +1516,10 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
|
||||
WHERE exploration_id=task.exploration_id AND kind='result'
|
||||
) token_metrics ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT EXTRACT(EPOCH FROM created_at)::bigint AS created_at
|
||||
SELECT EXTRACT(EPOCH FROM activity.created_at)::bigint AS created_at
|
||||
FROM activity
|
||||
WHERE exploration_id=task.exploration_id
|
||||
ORDER BY created_at DESC
|
||||
WHERE activity.exploration_id=task.exploration_id
|
||||
ORDER BY activity.created_at DESC
|
||||
LIMIT 1
|
||||
) latest_activity ON true
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -1539,30 +1541,41 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
|
||||
FROM findings
|
||||
WHERE task_id=task.id
|
||||
) finding_metrics ON true
|
||||
WHERE task.deleted_at IS NULL`)
|
||||
WHERE task.deleted_at IS NULL`
|
||||
|
||||
// scanTaskListMetrics reads one row of taskListMetricsQuery.
|
||||
func scanTaskListMetrics(row interface{ Scan(...any) error }) (int64, TaskListMetrics, error) {
|
||||
var explorationID int64
|
||||
var metrics TaskListMetrics
|
||||
err := row.Scan(
|
||||
&explorationID,
|
||||
&metrics.Tokens.InputTokens,
|
||||
&metrics.Tokens.OutputTokens,
|
||||
&metrics.Tokens.CacheReadTokens,
|
||||
&metrics.Tokens.CacheWriteTokens,
|
||||
&metrics.LastActivity,
|
||||
&metrics.Goals.Total,
|
||||
&metrics.Goals.Met,
|
||||
&metrics.RunningIntents,
|
||||
&metrics.Findings.Critical,
|
||||
&metrics.Findings.High,
|
||||
&metrics.Findings.Medium,
|
||||
&metrics.Findings.Low,
|
||||
)
|
||||
return explorationID, metrics, err
|
||||
}
|
||||
|
||||
// TaskListMetricsAll returns list aggregates for every live task in one query.
|
||||
func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
|
||||
rows, err := d.Query(taskListMetricsQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64]TaskListMetrics{}
|
||||
for rows.Next() {
|
||||
var explorationID int64
|
||||
var metrics TaskListMetrics
|
||||
if err := rows.Scan(
|
||||
&explorationID,
|
||||
&metrics.Tokens.InputTokens,
|
||||
&metrics.Tokens.OutputTokens,
|
||||
&metrics.Tokens.CacheReadTokens,
|
||||
&metrics.Tokens.CacheWriteTokens,
|
||||
&metrics.LastActivity,
|
||||
&metrics.Goals.Total,
|
||||
&metrics.Goals.Met,
|
||||
&metrics.RunningIntents,
|
||||
&metrics.Findings.Critical,
|
||||
&metrics.Findings.High,
|
||||
&metrics.Findings.Medium,
|
||||
&metrics.Findings.Low,
|
||||
); err != nil {
|
||||
explorationID, metrics, err := scanTaskListMetrics(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[explorationID] = metrics
|
||||
@@ -1570,6 +1583,17 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TaskListMetrics returns the same aggregates for the one live task of
|
||||
// exploration expID, with the same laterals as TaskListMetricsAll. A missing or
|
||||
// deleted task gives zero metrics, as a missing key of TaskListMetricsAll does.
|
||||
func (d *DB) TaskListMetrics(expID int64) (TaskListMetrics, error) {
|
||||
_, metrics, err := scanTaskListMetrics(d.QueryRow(taskListMetricsQuery+` AND task.exploration_id=$1`, expID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TaskListMetrics{}, nil
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
// GoalCountsAll returns goal totals (total / met) per exploration id, one query
|
||||
// for all tasks — used by listTasks so the task list shows progress for every task.
|
||||
func (d *DB) GoalCountsAll() (map[int64]GoalCounts, error) {
|
||||
|
||||
@@ -117,3 +117,133 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExplorationID); er
|
||||
t.Fatalf("unexpected goal metrics: %+v", metrics.Goals)
|
||||
}
|
||||
}
|
||||
|
||||
// TaskListMetrics(expID) is exactly the row TaskListMetricsAll returns for that
|
||||
// exploration, on every counter, and TaskListMetricsAll still lists every task.
|
||||
func TestTaskListMetricsMatchesAll(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
newest := time.Now().Add(-time.Minute).Truncate(time.Second)
|
||||
seed := func(name string, n int) *Task {
|
||||
t.Helper()
|
||||
task, err := d.CreateTask(name, "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, err := d.DeleteTaskCascade(task.ID, false, true)
|
||||
cleanupErr(t, "task "+name, err)
|
||||
})
|
||||
exp := task.ExplorationID
|
||||
// n result rows with tokens; the newest activity first, then an older row
|
||||
// with a higher id, so ordering by id instead of created_at would fail.
|
||||
for _, q := range []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`INSERT INTO activity(exploration_id,kind,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,created_at)
|
||||
SELECT $1,'result',10,5,3,1,$3::timestamptz - interval '1 hour' FROM generate_series(1,$2::int)`, []any{exp, n, newest}},
|
||||
{`INSERT INTO activity(exploration_id,kind,created_at) VALUES ($1,'text',$2::timestamptz)`, []any{exp, newest}},
|
||||
{`INSERT INTO activity(exploration_id,kind,created_at) SELECT $1,'text',$3::timestamptz - interval '2 hours' FROM generate_series(1,$2::int)`, []any{exp, n, newest}},
|
||||
{`INSERT INTO exploration_nodes(exploration_id,kind,payload,state)
|
||||
SELECT $1,'goal','{}',CASE WHEN g<=$2::int THEN 'met' ELSE 'open' END FROM generate_series(1,$2::int+1) g`, []any{exp, n}},
|
||||
{`INSERT INTO exploration_nodes(exploration_id,kind,payload,state)
|
||||
SELECT $1,'intent','{}',CASE WHEN g<=$2::int THEN 'running' ELSE 'done' END FROM generate_series(1,$2::int+2) g`, []any{exp, n}},
|
||||
} {
|
||||
if _, err := d.Exec(q.sql, q.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`INSERT INTO findings(task_id,severity)
|
||||
SELECT $1, s FROM unnest(ARRAY['critical','high','high','medium','low','info']) s, generate_series(1,$2)`, task.ID, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
a := seed("task-list-metrics-a", 2)
|
||||
b := seed("task-list-metrics-b", 3)
|
||||
|
||||
all, err := d.TaskListMetricsAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
task *Task
|
||||
n int
|
||||
}{{a, 2}, {b, 3}} {
|
||||
row, ok := all[c.task.ExplorationID]
|
||||
if !ok {
|
||||
t.Fatalf("TaskListMetricsAll misses task %d", c.task.ID)
|
||||
}
|
||||
one, err := d.TaskListMetrics(c.task.ExplorationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one != row {
|
||||
t.Fatalf("task %d: TaskListMetrics=%+v, TaskListMetricsAll=%+v", c.task.ID, one, row)
|
||||
}
|
||||
n := c.n
|
||||
want := TaskListMetrics{
|
||||
Tokens: TokenUsage{InputTokens: 10 * n, OutputTokens: 5 * n, CacheReadTokens: 3 * n, CacheWriteTokens: n},
|
||||
LastActivity: newest.Unix(),
|
||||
Goals: GoalCounts{Total: n + 1, Met: n},
|
||||
RunningIntents: n,
|
||||
Findings: FindingSeverityCounts{Critical: n, High: 2 * n, Medium: n, Low: n},
|
||||
}
|
||||
if one != want {
|
||||
t.Fatalf("task %d metrics=%+v, want %+v", c.task.ID, one, want)
|
||||
}
|
||||
}
|
||||
if none, err := d.TaskListMetrics(-1); err != nil || none != (TaskListMetrics{}) {
|
||||
t.Fatalf("missing exploration: %+v, %v; want zero metrics", none, err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkTaskListMetrics compares the per-exploration metrics query with the
|
||||
// all-tasks one on three tasks of 40,000 activity rows each. No time assertion.
|
||||
func BenchmarkTaskListMetrics(b *testing.B) {
|
||||
dsn, _, err := DSN()
|
||||
if err != nil {
|
||||
b.Skipf("no database config (%v) - skipping", err)
|
||||
}
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
b.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
b.Cleanup(func() { d.Close() })
|
||||
var exp int64
|
||||
for i := range 3 {
|
||||
task, err := d.CreateTask(fmt.Sprintf("bench-task-list-metrics-%d", i), "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Cleanup(func() { _, _ = d.DeleteTaskCascade(task.ID, false, true) })
|
||||
if _, err := d.Exec(`INSERT INTO activity(exploration_id,kind,input_tokens,created_at)
|
||||
SELECT $1, CASE WHEN g%20=0 THEN 'result' ELSE 'tool_use' END, 100, now() - (g||' seconds')::interval
|
||||
FROM generate_series(1,40000) g`, task.ExplorationID); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
exp = task.ExplorationID
|
||||
}
|
||||
if _, err := d.Exec(`ANALYZE activity`); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Run("one", func(b *testing.B) {
|
||||
for range b.N {
|
||||
if _, err := d.TaskListMetrics(exp); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("all", func(b *testing.B) {
|
||||
for range b.N {
|
||||
if _, err := d.TaskListMetricsAll(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import "testing"
|
||||
// ip/cidr scope path so a target like "10.0.188.136:3000" no longer 404s.
|
||||
func TestStripHostPort(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"10.0.188.136:3000", "10.0.188.136"}, // the reported IP case
|
||||
{"10.0.188.136", "10.0.188.136"}, // bare IPv4 unchanged
|
||||
{"[2001:db8::1]:8080", "2001:db8::1"}, // bracketed IPv6 + port
|
||||
{"2001:db8::1", "2001:db8::1"}, // bare IPv6 unchanged (has colons)
|
||||
{" 1.2.3.4:80 ", "1.2.3.4"}, // trims surrounding space
|
||||
{"example.com:443", "example.com"}, // domain + port → bare host
|
||||
{"api.example.com:8080", "api.example.com"}, // subdomain + port
|
||||
{"example.com", "example.com"}, // bare domain unchanged
|
||||
{"10.0.188.136:3000", "10.0.188.136"}, // the reported IP case
|
||||
{"10.0.188.136", "10.0.188.136"}, // bare IPv4 unchanged
|
||||
{"[2001:db8::1]:8080", "2001:db8::1"}, // bracketed IPv6 + port
|
||||
{"2001:db8::1", "2001:db8::1"}, // bare IPv6 unchanged (has colons)
|
||||
{" 1.2.3.4:80 ", "1.2.3.4"}, // trims surrounding space
|
||||
{"example.com:443", "example.com"}, // domain + port → bare host
|
||||
{"api.example.com:8080", "api.example.com"}, // subdomain + port
|
||||
{"example.com", "example.com"}, // bare domain unchanged
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripHostPort(c.in); got != c.want {
|
||||
|
||||
+11
-11
@@ -28,19 +28,19 @@ type Task struct {
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
|
||||
// Task-level ordered LLM chain. LLMProfileID remains the compatibility alias
|
||||
// for ActiveLLMProfileID while older API clients still send one profile id.
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"`
|
||||
ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
|
||||
LLMChainRevision int64 `json:"-"`
|
||||
LLMFailoverState string `json:"llm_failover_state,omitempty"`
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"`
|
||||
ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
|
||||
LLMChainRevision int64 `json:"-"`
|
||||
LLMFailoverState string `json:"llm_failover_state,omitempty"`
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
// LLMRotate: per-intent chain-head rotation switch (#85); false = classic
|
||||
// ordered failover from the active head.
|
||||
LLMRotate bool `json:"llm_rotate"`
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
ParentRef string `json:"parent_ref,omitempty"` // parent task id (recorded by orchestration spawn; empty=top level)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // when it entered a terminal state (done/failed/timeout); nil for non-terminal
|
||||
LLMRotate bool `json:"llm_rotate"`
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
ParentRef string `json:"parent_ref,omitempty"` // parent task id (recorded by orchestration spawn; empty=top level)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // when it entered a terminal state (done/failed/timeout); nil for non-terminal
|
||||
// Task-level timeout (see the task-level timeout and wrap-up design doc).
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // 0=no time limit
|
||||
FirstRunAt *time.Time `json:"first_run_at,omitempty"` // when it first actually started running (not created_at); nil=not yet run
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// hasSkillTool reports whether the packed tool set contains the Skill meta-tool.
|
||||
|
||||
@@ -7,19 +7,19 @@ import (
|
||||
|
||||
func TestGoalGateCheck(t *testing.T) {
|
||||
good := map[string]string{
|
||||
"url target": "Audit " + strings.Repeat("x ", 60) + " at https://example.com/app every module for injection issues and record one finding per issue with evidence.",
|
||||
"bare domain": strings.Repeat("probe ", 25) + "the api at target.example.org for unauthenticated data exposure, one record proof, then stop.",
|
||||
"ipv4": strings.Repeat("recon ", 25) + "10.0.0.5 and map every listening service before any test.",
|
||||
"localhost port": strings.Repeat("verify ", 25) + "localhost:3000 health endpoints respond identically before and after config reload.",
|
||||
"path": strings.Repeat("review ", 25) + "the module at /app/data/core for hardcoded secrets and report each hit.",
|
||||
"targetless opt-out": "[targetless] " + strings.Repeat("summarize ", 20) + "the attached material into a prioritized hypothesis list.",
|
||||
"url target": "Audit " + strings.Repeat("x ", 60) + " at https://example.com/app every module for injection issues and record one finding per issue with evidence.",
|
||||
"bare domain": strings.Repeat("probe ", 25) + "the api at target.example.org for unauthenticated data exposure, one record proof, then stop.",
|
||||
"ipv4": strings.Repeat("recon ", 25) + "10.0.0.5 and map every listening service before any test.",
|
||||
"localhost port": strings.Repeat("verify ", 25) + "localhost:3000 health endpoints respond identically before and after config reload.",
|
||||
"path": strings.Repeat("review ", 25) + "the module at /app/data/core for hardcoded secrets and report each hit.",
|
||||
"targetless opt-out": "[targetless] " + strings.Repeat("summarize ", 20) + "the attached material into a prioritized hypothesis list.",
|
||||
}
|
||||
good["targetless short but marked"] = "[targetless] analyze nothing"
|
||||
|
||||
bad := map[string]string{
|
||||
"no target long": strings.Repeat("do a thorough security review of everything and report anything interesting that comes up during the exploration phase of this audit, thanks. ", 2),
|
||||
"too short": "audit the site",
|
||||
"short and bare": "find bugs",
|
||||
"no target long": strings.Repeat("do a thorough security review of everything and report anything interesting that comes up during the exploration phase of this audit, thanks. ", 2),
|
||||
"too short": "audit the site",
|
||||
"short and bare": "find bugs",
|
||||
}
|
||||
for name, g := range good {
|
||||
if name == "targetless short+:url" {
|
||||
|
||||
+7
-7
@@ -55,13 +55,13 @@ type Task struct {
|
||||
LLMChainRevision int64 `json:"-"`
|
||||
LLMFailoverState string `json:"llm_failover_state,omitempty"`
|
||||
// LLMRotate (#85): per-intent round-robin over the task's LLM chain heads.
|
||||
LLMRotate bool `json:"llm_rotate,omitempty"`
|
||||
rotateMu sync.Mutex
|
||||
rotateNext int
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout are terminal; empty/other is derived from runtime state)
|
||||
LLMRotate bool `json:"llm_rotate,omitempty"`
|
||||
rotateMu sync.Mutex
|
||||
rotateNext int
|
||||
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout are terminal; empty/other is derived from runtime state)
|
||||
// Task-level timeout (see docs/task-timeout-and-settle-design.md). DeadlineAt/FirstRunAt are unix seconds, 0 = unset/not yet run.
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat trigger interval (seconds)
|
||||
|
||||
+7
-7
@@ -1609,12 +1609,12 @@ type createTaskReq struct {
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // the LLM profile to run this task on; omitted/null = use the active profile
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // ordered task-level profile chain; the first entry takes effect initially
|
||||
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // direct, read-only inherited source tasks
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // the LLM profile to run this task on; omitted/null = use the active profile
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // ordered task-level profile chain; the first entry takes effect initially
|
||||
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // direct, read-only inherited source tasks
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
LLMRotate *bool `json:"llm_rotate"` // task-level timeout (seconds); 0/omitted = no limit
|
||||
LLMRotate *bool `json:"llm_rotate"` // task-level timeout (seconds); 0/omitted = no limit
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat interval (seconds); 0/omitted = default 600 (10min); floor = default = 600, lower values are raised to 600
|
||||
PlanQuietMode *bool `json:"plan_quiet_mode,omitempty"` // planner quiet-mode switch (#86); omitted = default on; false = edge rounds always plan
|
||||
TargetGate *bool `json:"target_gate,omitempty"` // approval gate for asset targets, opt-in; omitted/null = off
|
||||
@@ -1927,11 +1927,11 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
dto := taskDTO(t, s.resolvedTaskStatus(t))
|
||||
metrics, err := s.m.PG().TaskListMetricsAll()
|
||||
metrics, err := s.m.PG().TaskListMetrics(t.ExpID)
|
||||
if err != nil {
|
||||
log.Printf("[tasks] detail metrics query failed for %s — metrics zeroed: %v", t.ID, err)
|
||||
}
|
||||
s.applyTaskMetrics(&dto, t, metrics[t.ExpID])
|
||||
s.applyTaskMetrics(&dto, t, metrics)
|
||||
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
||||
applyTaskArchiveBlocker(&dto, archiveBlockers)
|
||||
writeJSON(w, 200, dto)
|
||||
|
||||
@@ -38,6 +38,21 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A second task, so the detail must pick its own row and the list must keep
|
||||
// serving every task.
|
||||
other, err := m.CreateTask("detail metrics other", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherID, _ := strconv.ParseInt(other.ID, 10, 64)
|
||||
defer func() { _, _ = m.pg.DeleteTaskCascade(otherID, false, true) }()
|
||||
defer func() { _, _ = m.pg.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID) }()
|
||||
if _, err := m.pg.Exec(`
|
||||
INSERT INTO findings(task_id, severity) VALUES ($1,'critical'), ($1,'high'), ($1,'high'), ($1,'low'), ($2,'medium')`,
|
||||
taskID, otherID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
@@ -45,7 +60,19 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// After New: restoring the task resets running intents to open.
|
||||
if _, err := m.pg.Exec(`
|
||||
INSERT INTO exploration_nodes(exploration_id, kind, payload, state)
|
||||
VALUES ($1,'intent','{}','running'), ($1,'intent','{}','done')`, task.ExpID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type findingCounts struct {
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
}
|
||||
var probe struct {
|
||||
Tokens struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -53,8 +80,10 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
} `json:"tokens"`
|
||||
GoalsMet int `json:"goals_met"`
|
||||
GoalsTotal int `json:"goals_total"`
|
||||
GoalsMet int `json:"goals_met"`
|
||||
GoalsTotal int `json:"goals_total"`
|
||||
InFlight int `json:"in_flight"`
|
||||
Findings findingCounts `json:"findings"`
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks/"+task.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -73,6 +102,9 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
if probe.GoalsMet != 1 || probe.GoalsTotal != 2 {
|
||||
t.Fatalf("detail goal progress not filled from metrics: %d/%d", probe.GoalsMet, probe.GoalsTotal)
|
||||
}
|
||||
if probe.InFlight != 1 || probe.Findings != (findingCounts{Critical: 1, High: 2, Low: 1}) {
|
||||
t.Fatalf("detail in-flight/findings not filled from this task's metrics: %d %+v", probe.InFlight, probe.Findings)
|
||||
}
|
||||
|
||||
// The list endpoint must report the same numbers for the same task.
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||
@@ -89,27 +121,32 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"tokens"`
|
||||
GoalsMet int `json:"goals_met"`
|
||||
GoalsTotal int `json:"goals_total"`
|
||||
GoalsMet int `json:"goals_met"`
|
||||
GoalsTotal int `json:"goals_total"`
|
||||
InFlight int `json:"in_flight"`
|
||||
Findings findingCounts `json:"findings"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
found, foundOther := false, false
|
||||
for _, lt := range listed.Tasks {
|
||||
if lt.ID == other.ID {
|
||||
foundOther = lt.Findings == (findingCounts{Medium: 1})
|
||||
}
|
||||
if lt.ID != task.ID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if lt.Tokens.InputTokens != probe.Tokens.InputTokens ||
|
||||
lt.Tokens.OutputTokens != probe.Tokens.OutputTokens ||
|
||||
lt.GoalsMet != probe.GoalsMet || lt.GoalsTotal != probe.GoalsTotal {
|
||||
t.Fatalf("list and detail metrics disagree: list=%+v/%d detail=%+v/%d",
|
||||
lt.Tokens, lt.GoalsMet, probe.Tokens, probe.GoalsTotal)
|
||||
lt.GoalsMet != probe.GoalsMet || lt.GoalsTotal != probe.GoalsTotal ||
|
||||
lt.InFlight != probe.InFlight || lt.Findings != probe.Findings {
|
||||
t.Fatalf("list and detail metrics disagree: list=%+v detail=%+v", lt, probe)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("task %s missing from list response", task.ID)
|
||||
if !found || !foundOther {
|
||||
t.Fatalf("list misses a task or its metrics: task %s found=%v, task %s with its findings=%v", task.ID, found, other.ID, foundOther)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,4 +77,3 @@ func TestEscalationAdvancesAfterConsecutiveFailures(t *testing.T) {
|
||||
t.Fatalf("exhaust calls=%d want 2", exhaustCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func names(tools []actool.CoreTool) map[string]actool.CoreTool {
|
||||
|
||||
Reference in New Issue
Block a user