claude-failover/cmd/ccl-delegate/main.go

268 lines
6.9 KiB
Go
Raw Normal View History

feat(phase2-E): multi-provider routing via secutools delegation 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.
2026-04-17 02:17:19 +00:00
// 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)
}
}