Adds optional delegation of agent-queue tasks to the SecuAAS secutools AI platform (GPU / Gemini / Claude API) instead of dispatching to a local Claude Code tmux session. Per-task opt-in via YAML frontmatter fields preferred_ai, allow_delegation, complexity_hint — absence keeps the Phase 1 behaviour exactly (zero breaking change). Go side: - internal/secutools: HTTP client with exponential-backoff retries (SubmitJob/GetJob/WaitForResult), DecideProvider map adapter for CLI use, table tests. - internal/router: struct-typed Decide() with strict precedence (needs_claude_code > preferred_ai=claude-code > allow_delegation=false > preferred_ai > fail-safe local on unknown). - internal/delegation: Manager submits jobs, writes .md.delegated markers for on-restart recovery, runs a periodic reaper that moves completed jobs into done/ with provider/cost footer and failed jobs into failed/. - internal/dispatcher: WithDelegation() opt-in, routeTask hook before findFreeSession, skips .md.delegated in assignNextTask. - internal/api: /api/delegated/status (active jobs + counters), /watchdog/status extended with delegation counters. - cmd/ccl-delegate: small CLI exposing submit/get/result/decide so the bash dispatcher can call the same contract without duplicating logic. - cmd/claude-failover: delegation wired opt-in via SECUTOOLS_API_KEY. Tests: - 29+ new unit tests across router, secutools, delegation, dispatcher, api packages. go test -race -count=1 clean. - tests/phase2-E-integration.sh: bash end-to-end against a Python stdlib mock HTTP server, exercising the dev-management scripts. Forward-compat with watchdog (Phase 1 B1 already ignores state=delegated_to_secutools) so delegated tasks aren't flagged stale.
267 lines
6.9 KiB
Go
267 lines
6.9 KiB
Go
// ccl-delegate is a thin CLI wrapper around internal/secutools. It exists
|
|
// because the dispatcher that scans agent-queue inboxes is written in
|
|
// bash (dev-management/agent-orchestrator/dispatcher.sh). Rather than
|
|
// duplicate the HTTP contract in bash + curl + jq, we shell out to this
|
|
// binary for every delegation action.
|
|
//
|
|
// Subcommands:
|
|
//
|
|
// ccl-delegate submit --prompt=... [--preferred-ai=gpu] [--priority=default]
|
|
// Prints JSON to stdout: {"job_id": "...", "status": "pending"}.
|
|
//
|
|
// ccl-delegate get --job-id=xyz
|
|
// Prints JSON to stdout: {"job_id":"...","status":"...","provider":"..."}
|
|
//
|
|
// ccl-delegate result --job-id=xyz [--timeout=5m]
|
|
// Waits for completion (timeout-bounded) and prints result JSON.
|
|
//
|
|
// Auth: reads CCL_SECUTOOLS_API_KEY first, then SECUTOOLS_API_KEY. URL
|
|
// override via CCL_SECUTOOLS_URL (default https://api.secutools.secuaas.ovh),
|
|
// or CCL_SECUTOOLS_MOCK_URL for tests.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/secutools"
|
|
)
|
|
|
|
const defaultURL = "https://api.secutools.secuaas.ovh"
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
sub := os.Args[1]
|
|
args := os.Args[2:]
|
|
|
|
switch sub {
|
|
case "submit":
|
|
runSubmit(args)
|
|
case "get":
|
|
runGet(args)
|
|
case "result":
|
|
runResult(args)
|
|
case "decide":
|
|
runDecide(args)
|
|
case "-h", "--help", "help":
|
|
usage()
|
|
os.Exit(0)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "ccl-delegate: unknown subcommand %q\n", sub)
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `ccl-delegate — submit/poll secutools jobs from bash
|
|
|
|
usage:
|
|
ccl-delegate submit --prompt=... [--preferred-ai=gpu] [--priority=default] [--source=...]
|
|
ccl-delegate get --job-id=xyz
|
|
ccl-delegate result --job-id=xyz [--timeout=5m]
|
|
ccl-delegate decide --frontmatter=path/to/task.md
|
|
|
|
env:
|
|
CCL_SECUTOOLS_API_KEY (preferred) or SECUTOOLS_API_KEY
|
|
CCL_SECUTOOLS_URL (default https://api.secutools.secuaas.ovh)
|
|
CCL_SECUTOOLS_MOCK_URL (overrides CCL_SECUTOOLS_URL, for tests)
|
|
`)
|
|
}
|
|
|
|
func newClient() *secutools.HTTPClient {
|
|
url := os.Getenv("CCL_SECUTOOLS_MOCK_URL")
|
|
if url == "" {
|
|
url = os.Getenv("CCL_SECUTOOLS_URL")
|
|
}
|
|
if url == "" {
|
|
url = defaultURL
|
|
}
|
|
key := os.Getenv("CCL_SECUTOOLS_API_KEY")
|
|
if key == "" {
|
|
key = os.Getenv("SECUTOOLS_API_KEY")
|
|
}
|
|
if key == "" {
|
|
fmt.Fprintln(os.Stderr, "ccl-delegate: missing CCL_SECUTOOLS_API_KEY / SECUTOOLS_API_KEY")
|
|
os.Exit(4)
|
|
}
|
|
return secutools.NewHTTPClient(url, key, nil)
|
|
}
|
|
|
|
func runSubmit(args []string) {
|
|
fs := flag.NewFlagSet("submit", flag.ExitOnError)
|
|
prompt := fs.String("prompt", "", "prompt text (required)")
|
|
preferred := fs.String("preferred-ai", "", "preferred provider (gpu|gemini|claude-api|claude-opus|claude-sonnet|claude-haiku|auto)")
|
|
priority := fs.String("priority", "default", "priority (critical|high|default|low)")
|
|
jobType := fs.String("type", string(secutools.TypeAnalyze), "job type (ai:analyze|ai:batch|ai:report|ai:correlate)")
|
|
source := fs.String("source", "ccl-delegate", "source identifier")
|
|
maxTokens := fs.Int("max-tokens", 0, "max tokens (0 = server default)")
|
|
_ = fs.Parse(args)
|
|
|
|
if *prompt == "" {
|
|
fmt.Fprintln(os.Stderr, "ccl-delegate submit: --prompt is required")
|
|
os.Exit(2)
|
|
}
|
|
c := newClient()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := c.SubmitJob(ctx, &secutools.JobRequest{
|
|
Type: secutools.JobType(*jobType),
|
|
Priority: secutools.Priority(*priority),
|
|
Prompt: *prompt,
|
|
PreferredAI: *preferred,
|
|
Source: *source,
|
|
MaxTokens: *maxTokens,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "submit: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
writeJSON(resp)
|
|
}
|
|
|
|
func runGet(args []string) {
|
|
fs := flag.NewFlagSet("get", flag.ExitOnError)
|
|
id := fs.String("job-id", "", "job id (required)")
|
|
_ = fs.Parse(args)
|
|
if *id == "" {
|
|
fmt.Fprintln(os.Stderr, "ccl-delegate get: --job-id required")
|
|
os.Exit(2)
|
|
}
|
|
c := newClient()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
st, err := c.GetJob(ctx, *id)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "get: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
writeJSON(st)
|
|
}
|
|
|
|
func runResult(args []string) {
|
|
fs := flag.NewFlagSet("result", flag.ExitOnError)
|
|
id := fs.String("job-id", "", "job id (required)")
|
|
timeout := fs.Duration("timeout", 5*time.Minute, "wait timeout")
|
|
_ = fs.Parse(args)
|
|
if *id == "" {
|
|
fmt.Fprintln(os.Stderr, "ccl-delegate result: --job-id required")
|
|
os.Exit(2)
|
|
}
|
|
c := newClient()
|
|
ctx := context.Background()
|
|
res, err := c.WaitForResult(ctx, *id, *timeout)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "result: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
writeJSON(res)
|
|
}
|
|
|
|
func runDecide(args []string) {
|
|
fs := flag.NewFlagSet("decide", flag.ExitOnError)
|
|
frontmatterPath := fs.String("frontmatter", "", "path to task .md with YAML frontmatter")
|
|
_ = fs.Parse(args)
|
|
if *frontmatterPath == "" {
|
|
fmt.Fprintln(os.Stderr, "ccl-delegate decide: --frontmatter required")
|
|
os.Exit(2)
|
|
}
|
|
data, err := os.ReadFile(*frontmatterPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "decide: read %s: %v\n", *frontmatterPath, err)
|
|
os.Exit(1)
|
|
}
|
|
fm := parseFrontmatterMap(data)
|
|
fmt.Println(secutools.DecideProvider(fm))
|
|
}
|
|
|
|
// parseFrontmatterMap extracts a flat key/value map from a YAML frontmatter
|
|
// block delimited by "---". Only top-level scalars; nested structures are
|
|
// ignored. Intentionally dependency-free (no yaml unmarshal) so this CLI
|
|
// stays tiny and predictable.
|
|
func parseFrontmatterMap(content []byte) map[string]any {
|
|
out := map[string]any{}
|
|
s := string(content)
|
|
if len(s) < 4 || s[:4] != "---\n" {
|
|
return out
|
|
}
|
|
end := -1
|
|
for i := 4; i < len(s)-3; i++ {
|
|
if s[i] == '\n' && s[i+1:i+4] == "---" && (i+4 == len(s) || s[i+4] == '\n') {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
if end < 0 {
|
|
return out
|
|
}
|
|
body := s[4:end]
|
|
start := 0
|
|
for i := 0; i <= len(body); i++ {
|
|
if i == len(body) || body[i] == '\n' {
|
|
line := body[start:i]
|
|
start = i + 1
|
|
// find "key: value"
|
|
colon := -1
|
|
for j := 0; j < len(line); j++ {
|
|
if line[j] == ':' {
|
|
colon = j
|
|
break
|
|
}
|
|
}
|
|
if colon <= 0 {
|
|
continue
|
|
}
|
|
key := trim(line[:colon])
|
|
val := trim(line[colon+1:])
|
|
if key == "" {
|
|
continue
|
|
}
|
|
// strip surrounding quotes
|
|
if len(val) >= 2 &&
|
|
((val[0] == '"' && val[len(val)-1] == '"') ||
|
|
(val[0] == '\'' && val[len(val)-1] == '\'')) {
|
|
val = val[1 : len(val)-1]
|
|
}
|
|
// bool coercion
|
|
switch val {
|
|
case "true":
|
|
out[key] = true
|
|
case "false":
|
|
out[key] = false
|
|
default:
|
|
out[key] = val
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func trim(s string) string {
|
|
start := 0
|
|
for start < len(s) && (s[start] == ' ' || s[start] == '\t') {
|
|
start++
|
|
}
|
|
end := len(s)
|
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
|
|
end--
|
|
}
|
|
return s[start:end]
|
|
}
|
|
|
|
func writeJSON(v any) {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(v); err != nil {
|
|
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|