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:
2026-09-26 23:13:48 -07:00
co-authored by ornith-1.5-35b-a3b
parent 484c33f986
commit be48ecb550
6 changed files with 317 additions and 3 deletions
+46
View File
@@ -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)
}
}
}
+200
View File
@@ -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)
}
+3
View File
@@ -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)