cli: sift batch submit|status|get|cancel; README and skill
Add `sift batch` so agents reach the batch endpoint through the CLI like
`fetch` and `search`. `batchcmd.go` has `parseBatchArgs` (verb plus the
unchanged args after it; submit takes one arg, status a job id optionally
preceded by `--results`, get a job id plus a non-negative-integer idx, cancel
a job id, else a usage error), `batchMain` dispatch, one small function per
verb, and `batchDo` (bearer header, decode the envelope, print it on stdout,
on non-2xx the error to stderr and `exitFor`). `submitMain` reads the file or
stdin and validates locally: a JSON object with a non-empty `targets` array,
or a bare array of targets or URL strings wrapped into `{"targets": [...]}`.
README gains a "Batch" section (endpoints, serial-per-job/one-worker/
round-robin model, caps, idempotency, lease/TTL/restart, summary-vs-results,
redaction, metrics); SKILL.md gains a "Batch" section.
Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBatchArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
verb string
|
||||
rest []string
|
||||
ok bool
|
||||
}{
|
||||
{[]string{"submit", "targets.json"}, "submit", []string{"targets.json"}, true},
|
||||
{[]string{"submit", "-"}, "submit", []string{"-"}, true},
|
||||
{[]string{"status", "job-123"}, "status", []string{"job-123"}, true},
|
||||
{[]string{"status", "--results", "job-123"}, "status", []string{"--results", "job-123"}, true},
|
||||
{[]string{"get", "job-123", "4"}, "get", []string{"job-123", "4"}, true},
|
||||
{[]string{"cancel", "job-123"}, "cancel", []string{"job-123"}, true},
|
||||
{[]string{}, "", nil, false},
|
||||
{[]string{"submit"}, "", nil, false},
|
||||
{[]string{"status"}, "", nil, false},
|
||||
{[]string{"status", "a", "b"}, "", nil, false},
|
||||
{[]string{"status", "--results"}, "", nil, false},
|
||||
{[]string{"get", "job-123"}, "", nil, false},
|
||||
{[]string{"get", "job-123", "x"}, "", nil, false},
|
||||
{[]string{"get", "job-123", "-1"}, "", nil, false},
|
||||
{[]string{"cancel"}, "", nil, false},
|
||||
{[]string{"bogus", "x"}, "", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
verb, rest, err := parseBatchArgs(c.args)
|
||||
if c.ok {
|
||||
if err != nil {
|
||||
t.Errorf("%v: unexpected error %v", c.args, err)
|
||||
continue
|
||||
}
|
||||
if verb != c.verb || !slices.Equal(rest, c.rest) {
|
||||
t.Errorf("%v: got verb=%q rest=%q", c.args, verb, rest)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Errorf("%v: want an error", c.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const batchUsage = `usage: sift batch submit|status|get|cancel
|
||||
sift batch submit <targets.json|->
|
||||
sift batch status [--results] <job_id>
|
||||
sift batch get <job_id> <idx>
|
||||
sift batch cancel <job_id>`
|
||||
|
||||
// parseBatchArgs validates a `sift batch` command line (without "batch"). verb is
|
||||
// the subcommand; rest is the arguments after it, unchanged.
|
||||
func parseBatchArgs(args []string) (verb string, rest []string, err error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
verb, rest = args[0], args[1:]
|
||||
switch verb {
|
||||
case "submit":
|
||||
if len(rest) != 1 {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
case "status":
|
||||
switch len(rest) {
|
||||
case 1:
|
||||
if rest[0] == "--results" {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
case 2:
|
||||
if rest[0] != "--results" {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
case "get":
|
||||
if len(rest) != 2 {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
idx, e := strconv.Atoi(rest[1])
|
||||
if e != nil || idx < 0 {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
case "cancel":
|
||||
if len(rest) != 1 {
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("%s", batchUsage)
|
||||
}
|
||||
return verb, rest, nil
|
||||
}
|
||||
|
||||
func batchMain() {
|
||||
verb, rest, err := parseBatchArgs(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch verb {
|
||||
case "submit":
|
||||
submitMain(rest)
|
||||
case "status":
|
||||
statusMain(rest)
|
||||
case "get":
|
||||
getMain(rest)
|
||||
case "cancel":
|
||||
cancelMain(rest)
|
||||
}
|
||||
}
|
||||
|
||||
// submitMain reads the targets file (or stdin when the argument is "-") and
|
||||
// validates it locally before calling the server: a JSON object with a
|
||||
// non-empty targets array, or a bare array of targets or URL strings wrapped
|
||||
// into {"targets": [...]}.
|
||||
func submitMain(rest []string) {
|
||||
var r io.Reader = os.Stdin
|
||||
if rest[0] != "-" {
|
||||
f, err := os.Open(rest[0])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
body, err := buildTargetsBody(raw)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
batchDo(http.MethodPost, "/v1/batch", bytes.NewReader(body))
|
||||
}
|
||||
|
||||
// buildTargetsBody accepts the three accepted shapes and returns the
|
||||
// {"targets": [...]} body to send. A bare array of URL strings gets each string
|
||||
// wrapped as {"url": s}; bare target objects pass through.
|
||||
func buildTargetsBody(raw []byte) ([]byte, error) {
|
||||
var probe any
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return nil, fmt.Errorf("targets: not valid JSON: %w", err)
|
||||
}
|
||||
switch p := probe.(type) {
|
||||
case map[string]any:
|
||||
arr, ok := p["targets"].([]any)
|
||||
if !ok || len(arr) == 0 {
|
||||
return nil, fmt.Errorf("targets: a JSON object needs a non-empty targets array")
|
||||
}
|
||||
return raw, nil
|
||||
case []any:
|
||||
if len(p) == 0 {
|
||||
return nil, fmt.Errorf("targets: an empty array")
|
||||
}
|
||||
out := make([]any, 0, len(p))
|
||||
for _, el := range p {
|
||||
switch e := el.(type) {
|
||||
case map[string]any:
|
||||
out = append(out, e)
|
||||
case string:
|
||||
out = append(out, map[string]any{"url": e})
|
||||
default:
|
||||
return nil, fmt.Errorf("targets: array elements must be URLs or objects")
|
||||
}
|
||||
}
|
||||
return json.Marshal(map[string]any{"targets": out})
|
||||
default:
|
||||
return nil, fmt.Errorf("targets: expected a JSON object or array")
|
||||
}
|
||||
}
|
||||
|
||||
func statusMain(rest []string) {
|
||||
id := rest[len(rest)-1]
|
||||
path := "/v1/batch/" + url.PathEscape(id)
|
||||
if len(rest) == 2 && rest[0] == "--results" {
|
||||
path += "?results=1"
|
||||
}
|
||||
batchDo(http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func getMain(rest []string) {
|
||||
path := "/v1/batch/" + url.PathEscape(rest[0]) + "/" + url.PathEscape(rest[1])
|
||||
batchDo(http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func cancelMain(rest []string) {
|
||||
batchDo(http.MethodDelete, "/v1/batch/"+url.PathEscape(rest[0]), nil)
|
||||
}
|
||||
|
||||
// batchDo calls base+path with the bearer header, decodes the JSON envelope,
|
||||
// and prints it on stdout. On a non-2xx it writes the error to stderr and exits
|
||||
// with exitFor, as fetch does.
|
||||
func batchDo(method, path string, body io.Reader) {
|
||||
base, key, err := loadClient()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
req, err := http.NewRequest(method, base+path, body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var env map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
msg, _ := env["error"].(string)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
os.Exit(exitFor(resp.StatusCode, msg))
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(env)
|
||||
}
|
||||
@@ -26,6 +26,8 @@ func main() {
|
||||
extractMain()
|
||||
case "search":
|
||||
searchMain()
|
||||
case "batch":
|
||||
batchMain()
|
||||
case "keys":
|
||||
keysMain()
|
||||
case "pdftext":
|
||||
@@ -49,6 +51,7 @@ Commands:
|
||||
sift fetch <url> fetch via the service
|
||||
sift extract <file.pdf> extract a local PDF via the service
|
||||
sift search "<query>" search via the service
|
||||
sift batch submit|status|get|cancel run or watch a batch of fetches
|
||||
sift keys add|list|revoke
|
||||
sift version
|
||||
`, version)
|
||||
|
||||
Reference in New Issue
Block a user