From e018c7fcb017e65aeb93576ed0b902b5ef26eb03 Mon Sep 17 00:00:00 2001 From: Tony West <125916820+twest-bf@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:01:36 -0400 Subject: [PATCH] Surfaced run budget configuration settings in the UI --- internal/api/automation_wire.go | 20 +- internal/api/handlers_script.go | 166 +++++- internal/api/routes.go | 2 + internal/automation/store.go | 40 +- internal/capreg/caps_script.go | 81 ++- internal/jsautomation/manager.go | 71 ++- internal/jsautomation/manifest.go | 50 +- internal/jsautomation/store.go | 20 +- internal/jsruntime/jsruntime.go | 481 ++++++++++++++++-- internal/jsruntime/sdk.go | 11 +- internal/jsruntime/vm.go | 15 +- internal/jsruntime/worker.go | 6 +- web/src/components/AutomationSettings.tsx | 14 +- web/src/components/ScriptRuns.tsx | 11 +- web/src/components/automation/BudgetPanel.tsx | 326 ++++++++++++ web/src/components/automation/RunOutput.tsx | 9 +- .../components/automation/ScriptEditor.tsx | 108 +++- web/src/lib/api.ts | 72 ++- web/src/stores/automationStore.ts | 27 + 19 files changed, 1361 insertions(+), 169 deletions(-) create mode 100644 web/src/components/automation/BudgetPanel.tsx diff --git a/internal/api/automation_wire.go b/internal/api/automation_wire.go index b4f03fc..5a2271b 100644 --- a/internal/api/automation_wire.go +++ b/internal/api/automation_wire.go @@ -132,8 +132,18 @@ func (s *APIServer) newScriptManager() *jsautomation.Manager { // the project config, because that is engagement data rather than code. s.automationStorage = jsautomation.NewStorage() + pkgs := jsautomation.NewStore(filepath.Join(s.cfg.DataDir, "automations")) + // Install-time program-size check, which happens where a run's own copy of the + // limit is not in reach. + pkgs.MaxSourceBytes = func() int { + if s.autoStore == nil { + return 0 + } + return s.autoStore.ScriptBudget().Host.Resolved().SourceBytes + } + return jsautomation.New(jsautomation.Deps{ - Store: jsautomation.NewStore(filepath.Join(s.cfg.DataDir, "automations")), + Store: pkgs, Storage: s.automationStorage, // A getter, for the same reason Deps.BgCtx is one: the capability that starts // a run needs this manager, and this manager needs the sealed registry, so one @@ -146,6 +156,14 @@ func (s *APIServer) newScriptManager() *jsautomation.Manager { }, Runtime: jsruntime.NewWorkerRuntime(exe, "--script-worker"), Contexts: s.capContexts, + // The operator's run policy. A getter because it is edited at runtime, and + // because jsautomation must not import the token store. + Budget: func() jsruntime.BudgetPolicy { + if s.autoStore == nil { + return jsruntime.BudgetPolicy{} + } + return s.autoStore.ScriptBudget() + }, }) } diff --git a/internal/api/handlers_script.go b/internal/api/handlers_script.go index 4aeb9ee..e2574b0 100644 --- a/internal/api/handlers_script.go +++ b/internal/api/handlers_script.go @@ -13,15 +13,16 @@ import ( "time" "github.com/BishopFox/joro/internal/capability" + "github.com/BishopFox/joro/internal/capreg" "github.com/BishopFox/joro/internal/jsautomation" "github.com/BishopFox/joro/internal/jsruntime" ) // maxAutomationBody bounds a request on the automation control plane. Sized for the -// largest of them — one carrying automation source — comfortably over -// jsruntime.MaxSourceBytes so the source limit is the one that reports, with room for a -// manifest beside it. -const maxAutomationBody = 2 << 20 +// largest of them — one carrying automation source — from the program-size ceiling the +// budget offers, with room for a manifest beside it, so the program limit is the one that +// reports rather than this. +const maxAutomationBody = jsruntime.CapSourceBytes + (1 << 19) // decodeJSON reads a bounded JSON body, and requires the JSON content type. // @@ -162,7 +163,15 @@ func (s *APIServer) handleGetScript(w http.ResponseWriter, r *http.Request) { } // With source: this is the operator reading their own automation, which is the // point of keeping it as a real file in the first place. - writeJSON(w, http.StatusOK, a) + // + // effectiveLimits rides along for the same reason EffectiveLens does: the budget is + // the author's request narrowed by the operator's override and then held to the + // global, and resolving it here means no caller has to hold three halves and decide + // which wins. + writeJSON(w, http.StatusOK, struct { + *jsautomation.Automation + EffectiveLimits jsautomation.ManifestLimits `json:"effectiveLimits"` + }{a, a.EffectiveBudget(s.scriptManager.Budget())}) } type scriptWriteBody struct { @@ -468,6 +477,153 @@ func (s *APIServer) handleScriptSDK(w http.ResponseWriter, r *http.Request) { }) } +// ---- The run budget ---- +// +// The operator's run policy, plus everything needed to explain it: the shipped default +// and ceiling per field, the unit each is entered in, and the one-sentence rationale for +// each. All of it is served rather than restated in the frontend, so what the UI explains +// cannot drift from what the runtime enforces. + +type budgetResponse struct { + // Policy is what the operator set. A zero field means "Joro's own number". + Policy jsruntime.BudgetPolicy `json:"policy"` + // Effective is what a run that asks for nothing is held to, EffectiveMax the most + // one may ask for, and Host the resolved host limits. All three are what the policy + // actually comes to, so the UI shows no figure the runtime would not honor. + Effective jsruntime.Budget `json:"effective"` + EffectiveMax jsruntime.Budget `json:"effectiveMax"` + Host jsruntime.HostBudget `json:"host"` + // Specs describes the per-run fields, hostSpecs the ones that belong to this Joro. + Specs []jsruntime.BudgetSpec `json:"specs"` + HostSpecs []jsruntime.BudgetSpec `json:"hostSpecs"` + // AgentOutputCap is the size the two agent figures share. Their own specs carry it + // as a cap; this is here so the panel can name the sum rule once. + AgentOutputCap int `json:"agentOutputCap"` +} + +func (s *APIServer) scriptBudgetState() budgetResponse { + p := s.autoStore.ScriptBudget() + def, maxi := p.Bounds() + return budgetResponse{ + Policy: p, + Effective: def, + EffectiveMax: maxi, + Host: p.Host.Resolved(), + Specs: jsruntime.BudgetSpecs(), + HostSpecs: jsruntime.HostSpecs(), + AgentOutputCap: jsruntime.AgentOutputCap, + } +} + +func (s *APIServer) handleGetScriptBudget(w http.ResponseWriter, r *http.Request) { + if !s.requireScripting(w) { + return + } + writeJSON(w, http.StatusOK, s.scriptBudgetState()) +} + +// handleSetScriptBudget stores the operator's run policy. +// +// Over-ceiling values are rejected rather than clamped, which is the opposite of what a +// run request gets: a run may be submitted by a language model, where an argument error +// costs a turn, but this is the operator's own form and silently correcting what they +// typed would leave them believing a limit they do not have. +func (s *APIServer) handleSetScriptBudget(w http.ResponseWriter, r *http.Request) { + if !s.requireScripting(w) { + return + } + var body struct { + Policy jsruntime.BudgetPolicy `json:"policy"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := validateBudgetPolicy(body.Policy); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.autoStore.SetScriptBudget(body.Policy); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // No WS event: nothing listens for one, the operator's own request is the only way + // here, and it takes effect on the next run rather than on anything already open. + writeJSON(w, http.StatusOK, s.scriptBudgetState()) +} + +// validateBudgetPolicy checks every field the runtime declares, in the units the +// operator entered them in, plus the one cross-field rule. +// +// Iterating the specs rather than the struct keeps this total: a field the runtime +// declares but cannot read back is reported as a defect rather than waved through. +func validateBudgetPolicy(p jsruntime.BudgetPolicy) error { + perRun := []struct { + what string + budget jsruntime.Budget + }{ + {"default", p.Defaults}, + {"maximum", p.Maxima}, + } + for _, sp := range jsruntime.BudgetSpecs() { + for _, half := range perRun { + v, ok := half.budget.Value(sp.Key) + if !ok { + return fmt.Errorf("budget field %q cannot be read back; this is a defect in Joro", sp.Key) + } + if err := checkBudgetField(sp, v, half.what+" "); err != nil { + return err + } + } + // A default above the maximum is contradictory rather than merely high. The + // runtime resolves it to the maximum, but saying so beats storing it. + def, _ := p.Defaults.Value(sp.Key) + maxi, _ := p.Maxima.Value(sp.Key) + if def > 0 && maxi > 0 && def > maxi { + return fmt.Errorf("%s: the default (%d) cannot be above the maximum (%d)", + sp.Label, def/sp.Factor, maxi/sp.Factor) + } + } + + for _, sp := range jsruntime.HostSpecs() { + v, ok := p.Host.Value(sp.Key) + if !ok { + return fmt.Errorf("host limit %q cannot be read back; this is a defect in Joro", sp.Key) + } + if err := checkBudgetField(sp, v, ""); err != nil { + return err + } + } + + // The pair an agent gets back has to fit inside script.run's output cap, which is + // registered before the registry is sealed and errors rather than truncating. + h := p.Host.Resolved() + if room := capreg.ScriptRunOutputCap - capreg.ScriptRunHeaderRoom; h.AgentLogBytes+h.AgentResultBytes > room { + return fmt.Errorf("agent log output (%d KB) and agent result size (%d KB) together must stay "+ + "under %d KB, the tool result cap they share", + h.AgentLogBytes>>10, h.AgentResultBytes>>10, room>>10) + } + return nil +} + +// checkBudgetField reports a value the runtime cannot honor, in the operator's own unit. +// +// Only two things can be wrong: a fraction of the unit, or a value above a structural cap. +// A field with no cap has no upper bound here — the operator's number is the limit, which +// is what stops this form presenting a figure as theirs to set and then refusing it. +func checkBudgetField(sp jsruntime.BudgetSpec, v int, half string) error { + name := half + strings.ToLower(sp.Label) + switch { + case v < 0: + return fmt.Errorf("%s cannot be negative; 0 takes Joro's %d %s", name, sp.Default, sp.Unit) + case v%sp.Factor != 0: + return fmt.Errorf("%s must be a whole number of %s", name, sp.Unit) + case sp.Cap > 0 && v > sp.Cap*sp.Factor: + return fmt.Errorf("%s cannot be above %d %s: %s", name, sp.Cap, sp.Unit, sp.CapReason) + } + return nil +} + // globalDocs covers what the shim defines outside the joro namespace. The sandbox's // globals are ECMAScript built-ins and nothing else, so these are worth naming. var globalDocs = []map[string]string{ diff --git a/internal/api/routes.go b/internal/api/routes.go index b7c22a5..1110625 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -285,6 +285,8 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) { mux.HandleFunc("PUT /api/v1/automation/scripts/{id}/enabled", s.handleSetScriptEnabled) mux.HandleFunc("PUT /api/v1/automation/scripts/{id}/prefs", s.handleSetScriptPrefs) mux.HandleFunc("GET /api/v1/automation/sdk", s.handleScriptSDK) + mux.HandleFunc("GET /api/v1/automation/limits", s.handleGetScriptBudget) + mux.HandleFunc("PUT /api/v1/automation/limits", s.handleSetScriptBudget) } // Plugin routes (dynamic, based on loaded plugins). diff --git a/internal/automation/store.go b/internal/automation/store.go index 0982286..8482b58 100644 --- a/internal/automation/store.go +++ b/internal/automation/store.go @@ -17,6 +17,7 @@ import ( "time" "github.com/BishopFox/joro/internal/capability" + "github.com/BishopFox/joro/internal/jsruntime" ) // fileVersion is the on-disk schema version. Bump it only alongside a migration in @@ -52,7 +53,16 @@ const DefaultMCPPort = 9091 type file struct { Version int `json:"version"` MCP MCPState `json:"mcp"` - Tokens []*Token `json:"tokens"` + // ScriptBudget is the operator's policy for sandboxed runs: the default and the + // maximum per field, plus the host limits. It lives here for the same reasons the + // tokens do: a project config is published to teammates, and a user config + // round-trips through version-gated backfill, which is exactly the wrong semantics + // for a limit. An absent field means the runtime's own defaults and ceilings, so an + // older file needs no migration. + // omitzero, not omitempty: omitempty does nothing for a struct field, so an + // unconfigured policy would be written out as three empty objects. + ScriptBudget jsruntime.BudgetPolicy `json:"scriptBudget,omitzero"` + Tokens []*Token `json:"tokens"` } // Store holds automation tokens, persisted to a single 0600 JSON file. @@ -71,6 +81,8 @@ type Store struct { byHash map[string]*Token mcp MCPState + scriptBudget jsruntime.BudgetPolicy + dirty atomic.Bool } @@ -100,6 +112,7 @@ func NewStore(path string) (*Store, error) { f.MCP.Port = DefaultMCPPort } s.mcp = f.MCP + s.scriptBudget = f.ScriptBudget for _, t := range f.Tokens { if t == nil || t.ID == "" || t.Hash == "" { continue @@ -420,6 +433,24 @@ func (s *Store) SetMCP(state MCPState) error { return s.flush() } +// ScriptBudget returns the operator's run policy. A zero field means the runtime's own +// number for it. +func (s *Store) ScriptBudget() jsruntime.BudgetPolicy { + s.mu.RLock() + defer s.mu.RUnlock() + return s.scriptBudget +} + +// SetScriptBudget persists the run policy synchronously. Values are not clamped here — +// jsruntime holds every run to its own ceilings regardless, and the caller rejects an +// over-ceiling value so the operator is told rather than silently corrected. +func (s *Store) SetScriptBudget(b jsruntime.BudgetPolicy) error { + s.mu.Lock() + s.scriptBudget = b + s.mu.Unlock() + return s.flush() +} + // StartFlushLoop writes pending last-used telemetry every flushInterval, and once // more on shutdown. It mirrors APIServer.StartAutoSaveLoop, including the // dirty-check, so a store nobody uses costs one idle ticker. @@ -453,7 +484,12 @@ func (s *Store) flushIfDirty() { // read as a mass revocation. func (s *Store) flush() error { s.mu.RLock() - f := file{Version: fileVersion, MCP: s.mcp, Tokens: make([]*Token, 0, len(s.tokens))} + f := file{ + Version: fileVersion, + MCP: s.mcp, + ScriptBudget: s.scriptBudget, + Tokens: make([]*Token, 0, len(s.tokens)), + } for _, t := range s.tokens { cp := *t f.Tokens = append(f.Tokens, &cp) diff --git a/internal/capreg/caps_script.go b/internal/capreg/caps_script.go index a0cf579..bb68a1c 100644 --- a/internal/capreg/caps_script.go +++ b/internal/capreg/caps_script.go @@ -27,6 +27,10 @@ type ScriptRunner interface { // Invoke runs an installed automation. OperatorRun is never set from here: an // agent may run only what the operator has armed. Invoke(ctx context.Context, req jsautomation.InvokeRequest) (*jsautomation.Run, error) + // Budget is the operator's run policy. Read here for the two host figures that + // shape what an agent gets back — widening this interface rather than Deps, which + // is the field set whose growth would break the import rule in build.go. + Budget() jsruntime.BudgetPolicy } type scriptRunArgs struct { @@ -48,19 +52,37 @@ type scriptInvokeArgs struct { // The logs and the returned value both have to fit inside this capability's output cap, // and the cap is enforced after marshalling by erroring rather than truncating — so a // script that logged generously would fail wholesale and the operator would lose the -// result along with the logs. These ceilings are therefore set below the runtime's own +// result along with the logs. The two figures are therefore set below the runtime's own // defaults and leave room for the header, and the renderer truncates again as a backstop. +// +// They are the operator's to raise (jsruntime.HostBudget.AgentLogBytes and +// AgentResultBytes, read per call from the runner) but ScriptRunOutputCap is not: it is +// registered as this capability's MaxOutputBytes before the registry is sealed. Which is +// why the handler that stores the policy checks that the pair still fits inside it. const ( - scriptRunOutputCap = 256 << 10 - scriptLogBytes = 64 << 10 - scriptResultBytes = 96 << 10 + // ScriptRunHeaderRoom is what the run header, the truncation notes and the JSON + // envelope need beside the logs and the result. + ScriptRunHeaderRoom = 16 << 10 - // scriptRunTimeout must exceed the longest run the arguments can ask for, so the - // tool reports a real termination reason instead of the registry's bare timeout. - // Same reasoning as http.resend's 70s over a 60s per-request maximum. - scriptRunTimeout = 70 * time.Second + // ScriptRunOutputCap is derived from the budget rather than chosen here, so the + // operator-facing ceiling on the two agent figures and this registered cap cannot + // drift apart. It is registered as MaxOutputBytes before the registry is sealed, + // which is why it is the one number in this area the operator cannot raise. + ScriptRunOutputCap = jsruntime.AgentOutputCap + ScriptRunHeaderRoom + + // scriptRunTimeout must exceed the longest run the operator can permit, so the tool + // reports a real termination reason instead of the registry's bare timeout. Derived + // for the same reason as the cap above: jsruntime.CapTimeout is what the budget + // offers, and this has to stay above it. + scriptRunTimeout = jsruntime.CapTimeout + 10*time.Second ) +// agentCaps reports how much of a run the caller gets back, from the operator's policy. +func agentCaps(r ScriptRunner) (logBytes, resultBytes int) { + h := r.Budget().Host.Resolved() + return h.AgentLogBytes, h.AgentResultBytes +} + func registerScript(r *capability.Registry, d Deps) { r.MustRegister(capability.Capability{ ID: "script.run", @@ -89,7 +111,9 @@ func registerScript(r *capability.Registry, d Deps) { "arguments as the tool of the same name and throws on failure, with err.code carrying the reason. " + "There is no network, filesystem, process or module access — joro is the only way out, and " + "credential headers are masked in everything it returns. Each run has its own request budget and " + - "wall-clock deadline; console.log output and the return value both come back to you.", + "wall-clock deadline, both set by the operator and possibly above or below the maxima here; ask " + + "for what you need, and read your result for what you were actually given. console.log output " + + "and the return value both come back to you.", InputSchema: json.RawMessage(`{ "type":"object", @@ -103,16 +127,16 @@ func registerScript(r *capability.Registry, d Deps) { "description":"Arbitrary JSON handed to the script as ctx.input. Use it to parameterize a script instead of rewriting its source." }, "timeoutMs": { - "type":"integer","minimum":1000,"maximum":60000, - "description":"Wall-clock limit for the run. Default 25000." + "type":"integer","minimum":1000,"maximum":600000, + "description":"Wall-clock limit for the run. The maximum here is jsruntime.CapTimeout, the longest run this Joro can permit; the operator sets the default and may hold you below it, and a higher request is clamped, not refused." }, "maxCalls": { "type":"integer","minimum":1,"maximum":500, - "description":"Maximum SDK calls the run may make. Default 100." + "description":"Maximum SDK calls the run may make. The operator sets both the default and the most you may ask for; a higher request is clamped, not refused." }, "maxSendCalls": { "type":"integer","minimum":1,"maximum":100, - "description":"Maximum SDK calls that put bytes on the wire. Default 25. Counted in addition to maxCalls, so a sending call spends one of each." + "description":"Maximum SDK calls that put bytes on the wire, set by the operator like maxCalls. Counted in addition to maxCalls, so a sending call spends one of each." } }, "required":["source"], @@ -120,7 +144,7 @@ func registerScript(r *capability.Registry, d Deps) { }`), ArgsExample: json.RawMessage(`{"source":"async function run(ctx) {\n const base = await joro.http.fingerprint({ ref: ctx.input.ref });\n return { base: base };\n}","input":{"ref":1842}}`), - MaxOutputBytes: scriptRunOutputCap, + MaxOutputBytes: ScriptRunOutputCap, Timeout: scriptRunTimeout, Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args scriptRunArgs) (any, error) { @@ -134,6 +158,7 @@ func registerScript(r *capability.Registry, d Deps) { } } + logCap, resultCap := agentCaps(d.Script) run, err := d.Script.Run(ctx, jsautomation.RunRequest{ Source: args.Source, Input: args.Input, @@ -143,8 +168,8 @@ func registerScript(r *capability.Registry, d Deps) { Timeout: time.Duration(args.TimeoutMs) * time.Millisecond, MaxCalls: args.MaxCalls, MaxSendCalls: args.MaxSendCalls, - MaxLogBytes: scriptLogBytes, - MaxResultBytes: scriptResultBytes, + MaxLogBytes: logCap, + MaxResultBytes: resultCap, }, }) if err != nil { @@ -159,7 +184,7 @@ func registerScript(r *capability.Registry, d Deps) { // do many things is the least useful thing it could say. capability.RecordChange(ctx, "%s", run.Summary()) - return renderRun(run), nil + return renderRun(run, logCap, resultCap), nil }), }) @@ -211,7 +236,7 @@ func registerScript(r *capability.Registry, d Deps) { }`), ArgsExample: json.RawMessage(`{"id":"idor-check","input":{"ref":1842}}`), - MaxOutputBytes: scriptRunOutputCap, + MaxOutputBytes: ScriptRunOutputCap, Timeout: scriptRunTimeout, Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args scriptInvokeArgs) (any, error) { @@ -222,6 +247,7 @@ func registerScript(r *capability.Registry, d Deps) { return nil, &capability.Error{Code: capability.CodeInvalidArgs, Msg: "id is required"} } + logCap, resultCap := agentCaps(d.Script) run, err := d.Script.Invoke(ctx, jsautomation.InvokeRequest{ ID: args.ID, Input: args.Input, @@ -245,7 +271,7 @@ func registerScript(r *capability.Registry, d Deps) { } capability.RecordChange(ctx, "invoke %s: %s", args.ID, run.Summary()) - return renderRun(run), nil + return renderRun(run, logCap, resultCap), nil }), }) } @@ -291,13 +317,18 @@ func renderAutomations(items []jsautomation.Summary) string { // A single heterogeneous object, so this is a compact block rather than a table — but // it still follows the encoding rule's spirit, in that the header names units once and // nothing is repeated per line. -func renderRun(run *jsautomation.Run) string { +func renderRun(run *jsautomation.Run, logCap, resultCap int) string { res := run.Result var b strings.Builder fmt.Fprintf(&b, "run %s %s %dms\n", run.ID, res.Reason, res.DurationMs) - fmt.Fprintf(&b, "sdk calls: %d (%d sending) sdk bytes: %d in / %d out\n", - res.Calls, res.SendCalls, res.CallInputBytes, res.CallOutputBytes) + // Against the budget, not bare counts: the tool schema advertises the ceilings this + // capability accepts, but the operator's global budget can be lower and is editable + // while the registry is sealed — so this is where a caller learns what it actually + // got, and it is enough to correct the next call by. + fmt.Fprintf(&b, "sdk calls: %d/%d (%d/%d sending) sdk bytes: %d in / %d out\n", + res.Calls, res.Budget.MaxCalls, res.SendCalls, res.Budget.MaxSendCalls, + res.CallInputBytes, res.CallOutputBytes) fmt.Fprintf(&b, "bundle: %s source: sha256:%s\n", run.Bundle, run.SourceHash[:16]) if res.Err != "" { @@ -311,7 +342,7 @@ func renderRun(run *jsautomation.Run) string { written := 0 for _, l := range res.Logs { line := fmt.Sprintf(" %-5s %s\n", l.Level, oneLine(l.Text)) - if written+len(line) > scriptLogBytes { + if written+len(line) > logCap { b.WriteString(" … log output truncated\n") break } @@ -325,8 +356,8 @@ func renderRun(run *jsautomation.Run) string { if len(res.Value) > 0 { b.WriteString("\nresult\n") - if len(res.Value) > scriptResultBytes { - b.WriteString(string(res.Value[:scriptResultBytes])) + if len(res.Value) > resultCap { + b.WriteString(string(res.Value[:resultCap])) b.WriteString("\n… result truncated\n") } else { b.Write(res.Value) diff --git a/internal/jsautomation/manager.go b/internal/jsautomation/manager.go index 316ca98..fe66a3a 100644 --- a/internal/jsautomation/manager.go +++ b/internal/jsautomation/manager.go @@ -19,6 +19,7 @@ import ( "fmt" "log" "slices" + "sync" "time" "github.com/BishopFox/joro/internal/capability" @@ -26,14 +27,14 @@ import ( "github.com/BishopFox/joro/internal/jsruntime" ) -// maxConcurrentRuns bounds how many scripts execute at once. +// How many scripts execute at once is the operator's to set — jsruntime.HostBudget +// carries it, with the default and the ceiling declared beside the rest of the budget. // -// Each active run occupies two of the registry's global concurrency slots: one for the -// outer capability, held for the run's whole duration, and one for whichever SDK call is -// in flight. Leaving this unbounded would let a handful of scripts consume every slot +// Each active run occupies up to two of the registry's global concurrency slots: one for +// the outer capability, held for the run's whole duration, and one for whichever SDK call +// is in flight. Leaving this unbounded would let a handful of scripts consume every slot // and starve the operator's other automation calls, which is the failure the global // semaphore exists to prevent in the first place. -const maxConcurrentRuns = 2 // Invoker is the sealed capability registry, narrowed to what a script run needs. // *capability.Registry satisfies it. @@ -69,28 +70,64 @@ type Deps struct { // one-shot runs work, which is exactly phase B's behavior. Store *Store Storage *Storage + + // Budget is the operator's run policy: the default and the maximum per field, plus + // the host limits. A getter because it is edited at runtime and lives in the token + // store, which this package does not import; nil, or a zero policy, means the + // runtime's own defaults and ceilings apply. + Budget func() jsruntime.BudgetPolicy } // Manager runs scripts and remembers what they did. type Manager struct { deps Deps - sem chan struct{} runs *RunLog + + // A counter rather than a buffered channel, because the ceiling is the operator's + // and they can change it between runs. Resizing a channel is not a thing; comparing + // against the value read at admission is. + mu sync.Mutex + active int } // New returns a manager. A nil Runtime or Registry getter is tolerated at construction // and reported at Run, matching capreg.Build's tolerance of a partially wired Deps. func New(d Deps) *Manager { - return &Manager{ - deps: d, - sem: make(chan struct{}, maxConcurrentRuns), - runs: NewRunLog(maxRuns), + return &Manager{deps: d, runs: NewRunLog(maxRuns)} +} + +// admit takes a run slot if the operator's ceiling allows one. +func (m *Manager) admit(max int) bool { + m.mu.Lock() + defer m.mu.Unlock() + if m.active >= max { + return false } + m.active++ + return true +} + +func (m *Manager) release() { + m.mu.Lock() + m.active-- + m.mu.Unlock() } // Runs exposes the run log for the control plane. func (m *Manager) Runs() *RunLog { return m.runs } +// Budget reports the operator's run policy, or a zero one when none is configured. +// Exposed so the control plane and the script capability can read what a run would be +// held to without reaching for the token store themselves. +func (m *Manager) Budget() jsruntime.BudgetPolicy { return m.budget() } + +func (m *Manager) budget() jsruntime.BudgetPolicy { + if m.deps.Budget == nil { + return jsruntime.BudgetPolicy{} + } + return m.deps.Budget() +} + // Packages exposes the installed-automation store for the control plane. Nil when // scripting is configured without a data directory. func (m *Manager) Packages() *Store { return m.deps.Store } @@ -146,12 +183,11 @@ func (m *Manager) Run(ctx context.Context, req RunRequest) (*Run, error) { return nil, errors.New("the capability registry is unavailable") } - select { - case m.sem <- struct{}{}: - defer func() { <-m.sem }() - default: + policy := m.budget() + if !m.admit(policy.Host.Resolved().ConcurrentRuns) { return nil, ErrBusy } + defer m.release() runID := newRunID() sendCaps := sendCapsFrom(reg) @@ -172,7 +208,10 @@ func (m *Manager) Run(ctx context.Context, req RunRequest) (*Run, error) { rtReq := jsruntime.Request{ Source: req.Source, Input: req.Input, - Limits: req.Limits, + // The one place the operator's global budget is applied, so it holds every + // path equally: an agent's script.run, the operator's own inline run from the + // editor, script.invoke, a lens, and a trigger firing. + Limits: req.Limits.NormalizeWith(policy), Meta: jsruntime.Meta{ RunID: runID, StartedAt: started.UTC().Format(time.RFC3339), @@ -281,7 +320,7 @@ func (m *Manager) Invoke(ctx context.Context, req InvokeRequest) (*Run, error) { Caller: caller, Trigger: orDefault(trigger, TriggerManual), TriggerData: req.TriggerData, - Limits: a.Limits(), + Limits: a.RequestedBudget().Limits(), AutomationID: a.Manifest.ID, AutomationVersion: a.Manifest.Version, NoSend: req.NoSend, diff --git a/internal/jsautomation/manifest.go b/internal/jsautomation/manifest.go index 9ce9ee7..e22ffd2 100644 --- a/internal/jsautomation/manifest.go +++ b/internal/jsautomation/manifest.go @@ -85,16 +85,13 @@ const DefaultEntrypoint = "index.js" // ManifestLimits is the author's requested budget. Every field is optional; a zero // means "take the default". Values are requests, not guarantees: the operator may -// lower any of them, and jsruntime.Limits.Normalize clamps the result to its ceilings. -// Nothing here can raise a limit. -type ManifestLimits struct { - TimeoutMs int `json:"timeoutMs,omitempty"` - MemoryMB int `json:"memoryMb,omitempty"` - MaxCalls int `json:"maxCalls,omitempty"` - MaxSendCalls int `json:"maxSendCalls,omitempty"` - MaxLogBytes int `json:"maxLogBytes,omitempty"` - MaxResultBytes int `json:"maxResultBytes,omitempty"` -} +// lower any of them per automation or globally, and jsruntime resolves the result +// against its own defaults and ceilings. Nothing here can raise a limit. +// +// An alias rather than a type of its own: the author's request, the operator's global +// budget, the wire and ~/.joro/automation.json all carry the same six fields in the same +// units, and a second declaration of them is a table someone has to keep in step. +type ManifestLimits = jsruntime.Budget // Lens declares that this automation renders a tab in the request/response viewer. // @@ -411,13 +408,23 @@ func (a *Automation) ArmedTriggers() []string { return out } -// Limits resolves the budget one run of this automation gets. +// RequestedBudget is what this automation asks for: the author's manifest narrowed by +// the operator's per-automation override. // -// The author asks, the operator narrows, the runtime clamps. Taking the smaller of each -// pair rather than letting either win outright is what makes "an automation can never -// raise its own limits" true no matter which side was edited last. -func (a *Automation) Limits() jsruntime.Limits { - return narrower(a.Manifest.Limits, a.State.Limits).toRuntime().Normalize() +// Taking the smaller of each pair rather than letting either win outright is what makes +// "an automation can never raise its own limits" true no matter which side was edited +// last. Deliberately **not** normalized: a zero has to stay a zero here, or the shipped +// default would be baked in as a hard value and the operator's global budget could never +// reach an automation that declares nothing. Resolution happens once, in Manager.Run. +func (a *Automation) RequestedBudget() ManifestLimits { + return narrower(a.Manifest.Limits, a.State.Limits) +} + +// EffectiveBudget is what a run of this automation would actually get under the +// operator's policy. For display: the editor shows the author's request, the operator's +// per-automation override and this, so nobody has to work out which one wins. +func (a *Automation) EffectiveBudget(p jsruntime.BudgetPolicy) ManifestLimits { + return a.RequestedBudget().Limits().NormalizeWith(p).Budget() } // narrower returns the field-wise minimum of two optional budgets, treating zero as @@ -451,14 +458,3 @@ func narrower(a, b *ManifestLimits) ManifestLimits { out.MaxResultBytes = pick(a.MaxResultBytes, b.MaxResultBytes) return out } - -func (l ManifestLimits) toRuntime() jsruntime.Limits { - return jsruntime.Limits{ - Timeout: time.Duration(l.TimeoutMs) * time.Millisecond, - MemoryBytes: int64(l.MemoryMB) << 20, - MaxCalls: l.MaxCalls, - MaxSendCalls: l.MaxSendCalls, - MaxLogBytes: l.MaxLogBytes, - MaxResultBytes: l.MaxResultBytes, - } -} diff --git a/internal/jsautomation/store.go b/internal/jsautomation/store.go index 13cf67d..6669f05 100644 --- a/internal/jsautomation/store.go +++ b/internal/jsautomation/store.go @@ -51,11 +51,23 @@ type Store struct { // the trigger dispatcher — can reload only when something actually changed rather // than re-reading every package on a 250ms tick. rev atomic.Uint64 + + // MaxSourceBytes reports the operator's program-size limit at install time, where + // the run's own copy of it is not in reach. A getter because the limit is edited at + // runtime; nil takes the shipped default. + MaxSourceBytes func() int } // NewStore returns a store rooted at dir, which is created lazily on first write. func NewStore(dir string) *Store { return &Store{dir: dir} } +func (s *Store) sourceLimit() int { + if s.MaxSourceBytes == nil { + return 0 + } + return s.MaxSourceBytes() +} + // Revision reports the mutation counter. func (s *Store) Revision() uint64 { return s.rev.Load() } @@ -174,7 +186,7 @@ func (s *Store) Install(m Manifest, source string) (*Automation, error) { if err := m.Validate(); err != nil { return nil, err } - if err := ValidateSource(source); err != nil { + if err := ValidateSource(source, s.sourceLimit()); err != nil { return nil, err } @@ -225,7 +237,7 @@ func (s *Store) Update(id string, m Manifest, source, expectedHash string) (*Aut if err := m.Validate(); err != nil { return nil, err } - if err := ValidateSource(source); err != nil { + if err := ValidateSource(source, s.sourceLimit()); err != nil { return nil, err } @@ -402,9 +414,9 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error { // Compiling at install time rather than at first run means a syntax error is reported to // whoever submitted the package, while they are still looking at it, instead of surfacing // hours later as a trigger that quietly fails. Compilation parses and does not execute. -func ValidateSource(source string) error { +func ValidateSource(source string, maxBytes int) error { if strings.TrimSpace(source) == "" { return errors.New("source is required") } - return jsruntime.Validate(source) + return jsruntime.Validate(source, maxBytes) } diff --git a/internal/jsruntime/jsruntime.go b/internal/jsruntime/jsruntime.go index 5fbec96..a225436 100644 --- a/internal/jsruntime/jsruntime.go +++ b/internal/jsruntime/jsruntime.go @@ -65,11 +65,10 @@ const ( ReasonWorkerLost = "worker lost" ) -// Limits bound one run. Every field has a default and a ceiling; a zero field takes -// the default, and a field over the ceiling is clamped down to it. Clamping rather -// than rejecting is deliberate: these arrive from a caller that may be a language -// model, and the useful response to "timeoutMs: 600000" is a 60-second run, not an -// argument error that costs a turn. +// Limits bound one run. A zero field takes the operator's default, and a field over +// their maximum is clamped down to it. Clamping rather than rejecting is deliberate: +// these arrive from a caller that may be a language model, and the useful response to +// "maxCalls: 9000" is a run at the maximum, not an argument error that costs a turn. type Limits struct { Timeout time.Duration `json:"timeout"` MemoryBytes int64 `json:"memoryBytes"` @@ -88,72 +87,457 @@ type Limits struct { // time, and the parent has to hold each result in memory to forward it. MaxCallInputBytes int `json:"maxCallInputBytes"` MaxCallOutputBytes int `json:"maxCallOutputBytes"` + + // MaxStorageOps and MaxSourceBytes come from the host half of the budget rather + // than from anything a run or an author may request: one bounds how hard a loop + // can hammer the host pipe, the other how large a program may be at all. + MaxStorageOps int `json:"maxStorageOps"` + MaxSourceBytes int `json:"maxSourceBytes"` } -// Defaults and ceilings for Limits, plus the source-size cap. +// The numbers Joro ships. Each field has two: the default a run gets when nobody said +// otherwise, and the maximum a run may ask for when the *operator* has not set one. +// +// Stock maxima are not a bound on the operator. They are what applies in their absence, +// so an agent cannot ask for an unbounded run on a Joro nobody has configured. An +// operator may set any maximum they like; the only real ceilings are the structural ones +// below, each tied to a number somewhere else that cannot move at runtime. const ( + // DefaultTimeout has no stock maximum beside it because CapTimeout serves as one: + // the operator may hold a run below the cap but cannot raise past it, so the two + // figures would be the same number. See boundsLimits. DefaultTimeout = 25 * time.Second - MaxTimeout = 60 * time.Second - DefaultMemoryBytes int64 = 64 << 20 - MaxMemoryBytes int64 = 256 << 20 + DefaultMemoryBytes int64 = 64 << 20 + StockMaxMemoryBytes int64 = 256 << 20 DefaultMaxCalls = 100 - CeilMaxCalls = 500 + StockMaxCalls = 500 DefaultMaxSendCalls = 25 - CeilMaxSendCalls = 100 + StockMaxSendCalls = 100 DefaultMaxLogBytes = 256 << 10 - CeilMaxLogBytes = 1 << 20 + StockMaxLogBytes = 1 << 20 DefaultMaxResultBytes = 128 << 10 - CeilMaxResultBytes = 1 << 20 + StockMaxResultBytes = 1 << 20 + // The cumulative byte budgets are not offered to the operator at all: they bound + // what the host holds in memory while forwarding, which is Joro's concern rather + // than a policy an engagement varies. Default and hard bound, therefore. DefaultMaxCallInputBytes = 2 << 20 - CeilMaxCallInputBytes = 8 << 20 + CapMaxCallInputBytes = 8 << 20 DefaultMaxCallOutputBytes = 8 << 20 - CeilMaxCallOutputBytes = 32 << 20 - - // MaxSourceBytes bounds the program itself. Well above anything hand-written or - // generated, and far below a bundled dependency tree — which is a Phase C - // concern and will want its own budget. - MaxSourceBytes = 256 << 10 + CapMaxCallOutputBytes = 32 << 20 ) -// Normalize fills zero fields with defaults and clamps everything to its ceiling. -func (l Limits) Normalize() Limits { - l.Timeout = clampDur(l.Timeout, DefaultTimeout, MaxTimeout) - l.MemoryBytes = clamp64(l.MemoryBytes, DefaultMemoryBytes, MaxMemoryBytes) - l.MaxCalls = clampInt(l.MaxCalls, DefaultMaxCalls, CeilMaxCalls) - l.MaxSendCalls = clampInt(l.MaxSendCalls, DefaultMaxSendCalls, CeilMaxSendCalls) - l.MaxLogBytes = clampInt(l.MaxLogBytes, DefaultMaxLogBytes, CeilMaxLogBytes) - l.MaxResultBytes = clampInt(l.MaxResultBytes, DefaultMaxResultBytes, CeilMaxResultBytes) - l.MaxCallInputBytes = clampInt(l.MaxCallInputBytes, DefaultMaxCallInputBytes, CeilMaxCallInputBytes) - l.MaxCallOutputBytes = clampInt(l.MaxCallOutputBytes, DefaultMaxCallOutputBytes, CeilMaxCallOutputBytes) +// The structural ceilings: the only figures here an operator cannot raise, each because +// something outside this budget is fixed against it. Every one of them is reported to the +// UI with its reason, so a field is never presented as free when it is not. +const ( + // CapTimeout bounds a run because the capability that exposes one to an agent + // registers its own deadline before the registry is sealed, and that cannot change + // while Joro is running. capreg derives that deadline from this. + CapTimeout = 10 * time.Minute + + // CapSourceBytes bounds a program because the automation control plane caps the + // request that carries it. internal/api derives that body limit from this. + CapSourceBytes = 1 << 20 + + // CapConcurrentRuns bounds overlapping runs because each holds up to two of the + // capability registry's eight global concurrency slots: a fourth could take every + // slot and starve the operator's own automation calls. + CapConcurrentRuns = 3 + + // AgentOutputCap is what an agent's log and result figures share, because the tool + // result they travel in has one size and fails whole rather than truncating. + // capreg derives that tool result cap from this. + AgentOutputCap = 240 << 10 +) + +// Defaults and ceilings for the host half of the budget: limits an operator sets once +// for this Joro rather than per run, and which neither an author nor a caller may ask +// to change. +// +// Two of these are enforced outside this package — concurrent runs in the run manager, +// the agent output caps in the capability that exposes a run to an agent. They are +// declared here anyway, so the operator sees one budget with one form and one +// validator instead of three, and each field's spec names where it bites. +const ( + // DefaultStorageOps bounds joro.storage calls per run. It exists to stop a loop + // hammering the host pipe, not to express a policy, which is why the default is + // far above any real automation's use — and why the operator's number is final. + DefaultStorageOps = 1000 + + // DefaultSourceBytes: well above anything hand-written or generated, far below a + // bundled dependency tree. Capped by CapSourceBytes. + DefaultSourceBytes = 256 << 10 + + // DefaultConcurrentRuns, capped by CapConcurrentRuns. + DefaultConcurrentRuns = 2 + + // What an agent gets back from a run. The pair shares AgentOutputCap, so each is + // capped by it and the caller of SetScriptBudget checks their sum as well. + DefaultAgentLogBytes = 64 << 10 + DefaultAgentResultBytes = 96 << 10 +) + +// MaxSourceBytes is the shipped program-size limit, kept as the name callers already +// use for the default. +const MaxSourceBytes = DefaultSourceBytes + +// Normalize resolves a request against no policy at all: Joro's own defaults and stock +// maxima. It is what a caller with no operator policy in hand gets. +func (l Limits) Normalize() Limits { return l.NormalizeWith(BudgetPolicy{}) } + +// Fill supplies a default for any field left at zero and enforces the absolute caps, and +// deliberately does *not* apply the stock maxima. +// +// Those maxima bound what a caller may ask for, and that question is settled once, in the +// run manager, against the operator's policy. By the time Limits reaches this package the +// numbers are a resolved budget rather than a request — so re-applying a stock maximum +// here would silently lower a limit the operator raised, which is exactly what happened +// when the worker re-normalized a job the parent had already resolved. Every internal +// entry point therefore fills rather than normalizes. +func (l Limits) Fill() Limits { + l.Timeout = clamp(l.Timeout, DefaultTimeout, CapTimeout) + l.MemoryBytes = clamp(l.MemoryBytes, DefaultMemoryBytes, 0) + l.MaxCalls = clamp(l.MaxCalls, DefaultMaxCalls, 0) + l.MaxSendCalls = clamp(l.MaxSendCalls, DefaultMaxSendCalls, 0) + l.MaxLogBytes = clamp(l.MaxLogBytes, DefaultMaxLogBytes, 0) + l.MaxResultBytes = clamp(l.MaxResultBytes, DefaultMaxResultBytes, 0) + l.MaxCallInputBytes = clamp(l.MaxCallInputBytes, DefaultMaxCallInputBytes, CapMaxCallInputBytes) + l.MaxCallOutputBytes = clamp(l.MaxCallOutputBytes, DefaultMaxCallOutputBytes, CapMaxCallOutputBytes) + l.MaxStorageOps = clamp(l.MaxStorageOps, DefaultStorageOps, 0) + l.MaxSourceBytes = clamp(l.MaxSourceBytes, DefaultSourceBytes, CapSourceBytes) return l } -func clampDur(v, def, max time.Duration) time.Duration { - if v <= 0 { - return def - } - return min(v, max) +// NormalizeWith resolves a requested budget against the operator's policy. +// +// Per field the operator sets two numbers, and they answer two different questions: the +// default is what a run that asks for nothing gets, and the maximum is the most a run may +// ask for. Either can be left unset, in which case this package's own number applies — +// so an unconfigured Joro behaves exactly as it did before there was a policy. A request +// over the maximum is clamped down rather than refused, for the reason Limits' own doc +// comment gives, and nothing here can exceed a shipped ceiling even from a hand-edited +// file. +func (l Limits) NormalizeWith(p BudgetPolicy) Limits { + lo, hi := boundsLimits(p) + l.Timeout = clamp(l.Timeout, lo.Timeout, hi.Timeout) + l.MemoryBytes = clamp(l.MemoryBytes, lo.MemoryBytes, hi.MemoryBytes) + l.MaxCalls = clamp(l.MaxCalls, lo.MaxCalls, hi.MaxCalls) + l.MaxSendCalls = clamp(l.MaxSendCalls, lo.MaxSendCalls, hi.MaxSendCalls) + l.MaxLogBytes = clamp(l.MaxLogBytes, lo.MaxLogBytes, hi.MaxLogBytes) + l.MaxResultBytes = clamp(l.MaxResultBytes, lo.MaxResultBytes, hi.MaxResultBytes) + + // Not offered to the operator, so a plain default and a hard bound. + l.MaxCallInputBytes = clamp(l.MaxCallInputBytes, DefaultMaxCallInputBytes, CapMaxCallInputBytes) + l.MaxCallOutputBytes = clamp(l.MaxCallOutputBytes, DefaultMaxCallOutputBytes, CapMaxCallOutputBytes) + + // Host limits have no requestable side, so the operator's value is simply the + // default here. Clamped rather than assigned because this runs a second time inside + // the worker, which holds no policy: assigning would reset a limit the parent had + // already resolved back to the shipped default. + host := p.Host.Resolved() + l.MaxStorageOps = clamp(l.MaxStorageOps, host.StorageOps, 0) + l.MaxSourceBytes = clamp(l.MaxSourceBytes, host.SourceBytes, CapSourceBytes) + return l } -func clamp64(v, def, max int64) int64 { - if v <= 0 { - return def - } - return min(v, max) +// boundsLimits is the one per-field table: what each field's default and maximum resolve +// to under a policy. NormalizeWith clamps a request between them and Bounds reports them, +// so a field's rule is written once and the two cannot drift. +func boundsLimits(p BudgetPolicy) (lo, hi Limits) { + d, m := p.Defaults.Limits(), p.Maxima.Limits() + + // Wall clock is the one field whose stock maximum *is* its cap. The operator sets a + // maximum here like anywhere else and it is honored below the ceiling — but a blank + // box then means 600s rather than some third number, so the greyed figure in the box + // and the ceiling printed under the row are the same value and cannot read as + // contradicting each other. + lo.Timeout, hi.Timeout = bounds(d.Timeout, m.Timeout, DefaultTimeout, CapTimeout, CapTimeout) + + lo.MemoryBytes, hi.MemoryBytes = bounds(d.MemoryBytes, m.MemoryBytes, DefaultMemoryBytes, StockMaxMemoryBytes, 0) + lo.MaxCalls, hi.MaxCalls = bounds(d.MaxCalls, m.MaxCalls, DefaultMaxCalls, StockMaxCalls, 0) + lo.MaxSendCalls, hi.MaxSendCalls = bounds(d.MaxSendCalls, m.MaxSendCalls, DefaultMaxSendCalls, StockMaxSendCalls, 0) + lo.MaxLogBytes, hi.MaxLogBytes = bounds(d.MaxLogBytes, m.MaxLogBytes, DefaultMaxLogBytes, StockMaxLogBytes, 0) + lo.MaxResultBytes, hi.MaxResultBytes = bounds(d.MaxResultBytes, m.MaxResultBytes, DefaultMaxResultBytes, StockMaxResultBytes, 0) + return lo, hi } -func clampInt(v, def, max int) int { +// budgetNum is the three field types in Limits: int, int64 and time.Duration. +type budgetNum interface{ ~int | ~int64 } + +// clamp reads a zero as "unspecified" and takes def; anything else is held to cap, where +// a cap of zero means there is none. +func clamp[T budgetNum](v, def, cap T) T { if v <= 0 { - return def + v = def + } + if cap > 0 && v > cap { + return cap + } + return v +} + +// bounds reports what one field's default and maximum resolve to under a policy. +// +// opDef and opMax are the operator's pair, either of which may be unset; def and stockMax +// are what Joro ships in their absence; cap is the structural ceiling, or zero where the +// operator's number is final. +// +// Two rules worth stating. Joro's stock maximum never holds the operator's own default +// down: an operator who sets a default of 2000 MB and names no maximum has said what a run +// gets, and answering 256 because that is our number would be the setting quietly not +// taking. And an operator maximum below their own default wins, because a maximum is the +// harder statement — though the control plane rejects that pair rather than storing it, so +// this only arises from a hand-edited file. +func bounds[T budgetNum](opDef, opMax, def, stockMax, cap T) (lo, hi T) { + lo = clamp(opDef, def, cap) + hi = clamp(opMax, max(stockMax, lo), cap) + return min(lo, hi), hi +} + +// resolve applies one field's rule to a requested value. +func resolve[T budgetNum](v, opDef, opMax, def, stockMax, cap T) T { + lo, hi := bounds(opDef, opMax, def, stockMax, cap) + return clamp(v, lo, hi) +} + +// Budget is Limits in the units an operator reads and a config file stores: whole +// milliseconds and megabytes rather than a time.Duration and a byte count. A zero field +// means "unspecified" rather than zero of anything, which is what lets one shape serve +// an author's request, the operator's global, and the wire. +type Budget struct { + TimeoutMs int `json:"timeoutMs,omitempty"` + MemoryMB int `json:"memoryMb,omitempty"` + MaxCalls int `json:"maxCalls,omitempty"` + MaxSendCalls int `json:"maxSendCalls,omitempty"` + MaxLogBytes int `json:"maxLogBytes,omitempty"` + MaxResultBytes int `json:"maxResultBytes,omitempty"` +} + +// Limits converts to the runtime's own units. Not normalized: a zero stays a zero, so a +// caller can still tell "unspecified" from a real value. +func (b Budget) Limits() Limits { + return Limits{ + Timeout: time.Duration(b.TimeoutMs) * time.Millisecond, + MemoryBytes: int64(b.MemoryMB) << 20, + MaxCalls: b.MaxCalls, + MaxSendCalls: b.MaxSendCalls, + MaxLogBytes: b.MaxLogBytes, + MaxResultBytes: b.MaxResultBytes, + } +} + +// Value reports one field by its BudgetSpec key, and whether the key is known. +// +// Paired with BudgetSpecs so a caller can validate the whole budget without keeping a +// second list of fields: a seventh field added above without a case here fails loudly at +// its validator rather than reading as zero and passing unchecked. +func (b Budget) Value(key string) (int, bool) { + switch key { + case "timeoutMs": + return b.TimeoutMs, true + case "memoryMb": + return b.MemoryMB, true + case "maxCalls": + return b.MaxCalls, true + case "maxSendCalls": + return b.MaxSendCalls, true + case "maxLogBytes": + return b.MaxLogBytes, true + case "maxResultBytes": + return b.MaxResultBytes, true + } + return 0, false +} + +// Budget projects Limits back into operator units. Memory rounds up, because reporting a +// ceiling lower than the one being enforced would read as a bug in the enforcement. +func (l Limits) Budget() Budget { + return Budget{ + TimeoutMs: int(l.Timeout / time.Millisecond), + MemoryMB: int((l.MemoryBytes + (1 << 20) - 1) >> 20), + MaxCalls: l.MaxCalls, + MaxSendCalls: l.MaxSendCalls, + MaxLogBytes: l.MaxLogBytes, + MaxResultBytes: l.MaxResultBytes, + } +} + +// BudgetPolicy is everything the operator sets about runs: what a run gets by default, +// the most it may ask for, and the host limits that are neither requestable nor +// declarable. +// +// One struct because it is one form, one stored object and one validator. Persisted in +// ~/.joro/automation.json; see internal/automation. +type BudgetPolicy struct { + Defaults Budget `json:"defaults,omitzero"` + Maxima Budget `json:"maxima,omitzero"` + Host HostBudget `json:"host,omitzero"` +} + +// HostBudget is the half of the policy that is a property of this Joro rather than of one +// run: how hard a script may hammer storage, how large a program may be, how many runs +// may overlap, and how much of a run's output an agent gets back. +type HostBudget struct { + StorageOps int `json:"storageOps,omitempty"` + SourceBytes int `json:"sourceBytes,omitempty"` + ConcurrentRuns int `json:"concurrentRuns,omitempty"` + AgentLogBytes int `json:"agentLogBytes,omitempty"` + AgentResultBytes int `json:"agentResultBytes,omitempty"` +} + +// Resolved fills each unset field with its shipped default and holds every field to its +// ceiling, so a caller can use the result without checking anything. +func (h HostBudget) Resolved() HostBudget { + return HostBudget{ + StorageOps: clamp(h.StorageOps, DefaultStorageOps, 0), + SourceBytes: clamp(h.SourceBytes, DefaultSourceBytes, CapSourceBytes), + ConcurrentRuns: clamp(h.ConcurrentRuns, DefaultConcurrentRuns, CapConcurrentRuns), + AgentLogBytes: clamp(h.AgentLogBytes, DefaultAgentLogBytes, AgentOutputCap), + AgentResultBytes: clamp(h.AgentResultBytes, DefaultAgentResultBytes, AgentOutputCap), + } +} + +// Value reports one field by its spec key, paired with HostSpecs for the same reason +// Budget.Value is paired with BudgetSpecs. +func (h HostBudget) Value(key string) (int, bool) { + switch key { + case "storageOps": + return h.StorageOps, true + case "sourceBytes": + return h.SourceBytes, true + case "concurrentRuns": + return h.ConcurrentRuns, true + case "agentLogBytes": + return h.AgentLogBytes, true + case "agentResultBytes": + return h.AgentResultBytes, true + } + return 0, false +} + +// Bounds reports what a run's default and maximum actually resolve to under this policy. +// +// The UI shows both, so a field never displays Joro's stock figure as the maximum when the +// operator's own default has raised it past that. +func (p BudgetPolicy) Bounds() (defaults, maxima Budget) { + lo, hi := boundsLimits(p) + return lo.Budget(), hi.Budget() +} + +// DefaultBudget and StockMaxima report this package's own numbers in operator units, so +// the UI can state them without a second copy of the table. +func DefaultBudget() Budget { return Limits{}.Normalize().Budget() } + +// StockMaxima carries no TimeoutMs: the wall-clock maximum is CapTimeout rather than a +// figure that applies in the operator's absence, and BudgetSpecs reports it as a cap. +func StockMaxima() Budget { + return Budget{ + // For wall clock the figure that applies in the operator's absence and the one + // they cannot exceed are the same: there is no separate stock maximum. + TimeoutMs: int(CapTimeout / time.Millisecond), + MemoryMB: int(StockMaxMemoryBytes >> 20), + MaxCalls: StockMaxCalls, + MaxSendCalls: StockMaxSendCalls, + MaxLogBytes: StockMaxLogBytes, + MaxResultBytes: StockMaxResultBytes, + } +} + +// BudgetSpec documents one configurable field for the operator. +// +// The rationale lives here beside the constants rather than in the frontend, so what the +// UI explains cannot drift from what the runtime enforces. +type BudgetSpec struct { + Key string `json:"key"` + Label string `json:"label"` + Unit string `json:"unit"` + // Factor converts the operator's unit to the stored one: a value entered as + // seconds is stored as milliseconds with a factor of 1000. Every other figure on + // this struct is already in the operator's unit; the field on Budget is not. + Factor int `json:"factor"` + // Default is what a run gets when nobody set one; DefaultMax is the most a run may + // ask for while the operator has named no maximum. Both are Joro's own numbers, and + // both are replaced outright by whatever the operator types. + // + // DefaultMax is absent only on a host spec, which has no requestable side and so no + // maximum to speak of — one editable figure and, where it applies, a Cap. + Default int `json:"default"` + DefaultMax int `json:"defaultMax,omitempty"` + // Cap is the one figure the operator cannot exceed, and CapReason says what it is + // fixed against. Zero means there is no cap and their number is final — which is + // the case for most of these, so a field is never shown as free when it is not. + Cap int `json:"cap,omitempty"` + CapReason string `json:"capReason,omitempty"` + Description string `json:"description"` +} + +// BudgetSpecs describes the six per-run fields, in the order they should be read: what a +// run may do, then how long it may take, then what it may return. +func BudgetSpecs() []BudgetSpec { + def, stock := DefaultBudget(), StockMaxima() + return []BudgetSpec{ + {Key: "maxCalls", Label: "SDK calls", Unit: "calls", Factor: 1, + Default: def.MaxCalls, DefaultMax: stock.MaxCalls, + Description: "Bounds SDK calls of any kind; the cost of a read is your context, and the default covers the multi-item sweep this tier exists for."}, + {Key: "maxSendCalls", Label: "Sending calls", Unit: "calls", Factor: 1, + Default: def.MaxSendCalls, DefaultMax: stock.MaxSendCalls, + Description: "Bounds the subset that puts bytes on a target, counted separately because the cost of a send is traffic against someone's systems."}, + // Entered in seconds. The stored field is milliseconds because that is what an + // automation manifest declares, and the two have to be the same number. + {Key: "timeoutMs", Label: "Wall clock", Unit: "seconds", Factor: 1000, + Default: def.TimeoutMs / 1000, + DefaultMax: stock.TimeoutMs / 1000, + Cap: int(CapTimeout / time.Second), + CapReason: "the deadline the script.run tool registers for itself, which is fixed when Joro starts", + Description: "How long a run may take before its worker process is killed. A run that ends sooner costs nothing extra, so this is a bound rather than a budget to spend."}, + {Key: "memoryMb", Label: "Memory", Unit: "MB", Factor: 1, + Default: def.MemoryMB, DefaultMax: stock.MemoryMB, + Description: "Heap ceiling for the worker process; the engine has no allocation limit of its own and Joro holds captured traffic in memory, so a runaway must cost the worker rather than the proxy."}, + {Key: "maxLogBytes", Label: "Log output", Unit: "KB", Factor: 1 << 10, + Default: def.MaxLogBytes >> 10, DefaultMax: stock.MaxLogBytes >> 10, + Description: "How much console output is kept; over this the log truncates rather than the run failing, so a chatty loop costs a note and not the result."}, + {Key: "maxResultBytes", Label: "Result size", Unit: "KB", Factor: 1 << 10, + Default: def.MaxResultBytes >> 10, DefaultMax: stock.MaxResultBytes >> 10, + Description: "How large a value run() may return; over this the run fails rather than truncating, because truncated JSON would be mis-parsed silently."}, + } +} + +// HostSpecs describes the five fields that belong to this Joro rather than to one run. +// They have no DefaultMax, because nothing can ask for another value: the operator's +// number is the limit. +func HostSpecs() []BudgetSpec { + return []BudgetSpec{ + {Key: "concurrentRuns", Label: "Concurrent runs", Unit: "runs", Factor: 1, + Default: DefaultConcurrentRuns, + Cap: CapConcurrentRuns, + CapReason: "each run holds up to two of the capability registry's eight global slots, and the rest have to stay reachable", + Description: "How many runs may execute at once. A run beyond this is refused rather than queued, so the caller learns to retry instead of waiting."}, + {Key: "storageOps", Label: "Storage operations", Unit: "ops", Factor: 1, + Default: DefaultStorageOps, + Description: "How many joro.storage reads and writes one run may make. It stops a loop hammering the host pipe; the wall clock already bounds the run itself."}, + {Key: "sourceBytes", Label: "Program size", Unit: "KB", Factor: 1 << 10, + Default: DefaultSourceBytes >> 10, + Cap: CapSourceBytes >> 10, + CapReason: "the automation API caps the request that carries a program, and this has to stay inside it", + Description: "How large an automation's code may be. Checked at install and again at run, since there is no module loader and a package is one bundled script."}, + {Key: "agentLogBytes", Label: "Agent log output", Unit: "KB", Factor: 1 << 10, + Default: DefaultAgentLogBytes >> 10, + Cap: AgentOutputCap >> 10, + CapReason: "an agent's whole tool result shares this size and fails whole rather than truncating, so the two agent figures share it too", + Description: "How much of a run's log an agent gets back from script_run. Separate from the run's own log budget, which is what the operator reads in the run log."}, + {Key: "agentResultBytes", Label: "Agent result size", Unit: "KB", Factor: 1 << 10, + Default: DefaultAgentResultBytes >> 10, + Cap: AgentOutputCap >> 10, + CapReason: "shared with the agent log figure above; their sum is what has to fit", + Description: "How much of a run's return value an agent gets back. Raise this and the log figure together only as far as the pair fits."}, } - return min(v, max) } // Meta is the non-secret description of a run, handed to the script as ctx.run, @@ -229,6 +613,12 @@ type Result struct { CallInputBytes int `json:"callInputBytes"` CallOutputBytes int `json:"callOutputBytes"` + // Budget is what this run was actually held to, after the operator's global was + // applied. Reported rather than left implicit because a count with nothing to read + // it against explains nothing — and because a model that asked for more than it got + // learns the real number here, which the static tool schema cannot tell it. + Budget Budget `json:"budget"` + DurationMs int64 `json:"durationMs"` } @@ -271,11 +661,6 @@ const storageUnavailableMsg = "joro.storage is available only to an installed au "one-shot script has no namespace of its own. Return the value instead, or install this as " + "an automation" -// maxStorageOps bounds joro.storage calls per run. A fixed constant rather than a -// configurable limit: it exists to stop a loop from hammering the pipe, not to express an -// operator's policy, and the wall clock already bounds the run itself. -const maxStorageOps = 1000 - // CallError is a capability failure the script can inspect. Code becomes err.code in // JavaScript, so a script can retry on "busy" and give up on "forbidden". type CallError struct { diff --git a/internal/jsruntime/sdk.go b/internal/jsruntime/sdk.go index 36b4542..9e2350d 100644 --- a/internal/jsruntime/sdk.go +++ b/internal/jsruntime/sdk.go @@ -307,12 +307,17 @@ var ( // `import y from "lodash"` would reject the whole program — either way the source an // operator reads back would not be the source that ran, which is the one property the run // log exists to provide. -func Prepare(source string) (string, error) { +// maxBytes is the operator's program-size limit; zero or less takes the shipped default, +// so a caller with no policy in hand still gets a bound. +func Prepare(source string, maxBytes int) (string, error) { if strings.TrimSpace(source) == "" { return "", fmt.Errorf("script is empty: define an entry point, for example `async function run(ctx) { ... }`") } - if len(source) > MaxSourceBytes { - return "", fmt.Errorf("script is %d bytes, over the %d byte limit", len(source), MaxSourceBytes) + if maxBytes <= 0 { + maxBytes = DefaultSourceBytes + } + if len(source) > maxBytes { + return "", fmt.Errorf("script is %d bytes, over the %d byte limit", len(source), maxBytes) } out := source diff --git a/internal/jsruntime/vm.go b/internal/jsruntime/vm.go index bfe8567..9f94a53 100644 --- a/internal/jsruntime/vm.go +++ b/internal/jsruntime/vm.go @@ -66,8 +66,8 @@ func New() *VM { return &VM{} } // It compiles without executing, which is what makes it safe to run in the parent process // at install time. Catching a syntax error while whoever submitted the package is still // looking at it beats surfacing it hours later as a trigger that quietly does nothing. -func Validate(source string) error { - prepared, err := Prepare(source) +func Validate(source string, maxBytes int) error { + prepared, err := Prepare(source, maxBytes) if err != nil { return err } @@ -119,7 +119,7 @@ func (s *logSink) add(level, text string) { // throw, a timeout, an exhausted budget — comes back as a Result with a reason, so a // caller has exactly one thing to report. func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Result, err error) { - lim := req.Limits.Normalize() + lim := req.Limits.Fill() start := time.Now() sink := &logSink{maxBytes: lim.MaxLogBytes} @@ -137,6 +137,7 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul r.LogsTruncated = sink.truncated r.Calls, r.SendCalls, r.StorageOps = calls, sendCalls, storageOps r.CallInputBytes, r.CallOutputBytes = callInBytes, callOutBytes + r.Budget = lim.Budget() r.DurationMs = time.Since(start).Milliseconds() return r, nil } @@ -154,7 +155,7 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul } }() - prepared, perr := Prepare(req.Source) + prepared, perr := Prepare(req.Source, lim.MaxSourceBytes) if perr != nil { return finish(Result{Reason: ReasonRuntimeFailure, Err: perr.Error()}) } @@ -279,11 +280,11 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul if h := readHalt(); h.reason != "" { return errEnvelope("halted", "the run has been stopped") } - if storageOps >= maxStorageOps { + if storageOps >= lim.MaxStorageOps { setHalt(halt{reason: ReasonBudget, detail: fmt.Sprintf( - "this run reached its limit of %d storage operations", maxStorageOps)}) + "this run reached its limit of %d storage operations", lim.MaxStorageOps)}) return errEnvelope("budget_exceeded", fmt.Sprintf( - "this run reached its limit of %d storage operations", maxStorageOps)) + "this run reached its limit of %d storage operations", lim.MaxStorageOps)) } storageOps++ diff --git a/internal/jsruntime/worker.go b/internal/jsruntime/worker.go index 47e0527..2b20853 100644 --- a/internal/jsruntime/worker.go +++ b/internal/jsruntime/worker.go @@ -121,7 +121,7 @@ func (w *WorkerRuntime) Run(ctx context.Context, req Request, bridge HostBridge) if w.exePath == "" { return Result{}, errors.New("no worker executable path configured") } - lim := req.Limits.Normalize() + lim := req.Limits.Fill() req.Limits = lim start := time.Now() @@ -189,6 +189,7 @@ func (w *WorkerRuntime) Run(ctx context.Context, req Request, bridge HostBridge) _ = wait() res := Result{ Calls: forwarded, SendCalls: forwardedSends, + Budget: lim.Budget(), DurationMs: time.Since(start).Milliseconds(), } switch { @@ -217,6 +218,7 @@ func (w *WorkerRuntime) Run(ctx context.Context, req Request, bridge HostBridge) return Result{ Reason: ReasonRuntimeFailure, Err: f.Fatal, + Budget: lim.Budget(), DurationMs: time.Since(start).Milliseconds(), }, nil @@ -361,7 +363,7 @@ func RunWorker(ctx context.Context, in io.Reader, out io.Writer) (err error) { return errors.New("first frame was not a job") } job := *f.Job - job.Limits = job.Limits.Normalize() + job.Limits = job.Limits.Fill() // Go's soft heap limit makes the collector work harder as the script approaches // the ceiling, which slows a runaway allocation enough for the VM's own sampling diff --git a/web/src/components/AutomationSettings.tsx b/web/src/components/AutomationSettings.tsx index 1f3e0c8..c1fb35d 100644 --- a/web/src/components/AutomationSettings.tsx +++ b/web/src/components/AutomationSettings.tsx @@ -7,17 +7,18 @@ import AutomationTokenModal from './AutomationTokenModal' import AutomationSecretModal from './AutomationSecretModal' import AutomationActivity from './AutomationActivity' import ScriptRuns from './ScriptRuns' +import BudgetPanel from './automation/BudgetPanel' import LensPanel from './automation/LensPanel' import ScriptsPanel from './automation/ScriptsPanel' import ConfirmModal from './ConfirmModal' const inputCls = 'bg-surface-input text-xs px-2 py-1 rounded-sm border border-border' -// Four surfaces that share a subject and nothing else: what a client may do, what code -// is installed, which of it renders in the viewer, and what has happened. Sub-tabs rather -// than one long panel, mirroring Settings -> Plugins — and the editor needs the full -// panel height regardless. -type SubTab = 'tokens' | 'scripts' | 'lenses' | 'activity' +// Five surfaces that share a subject and nothing else: what a client may do, what code +// is installed, which of it renders in the viewer, what every run is held to, and what +// has happened. Sub-tabs rather than one long panel, mirroring Settings -> Plugins — and +// the editor needs the full panel height regardless. +type SubTab = 'tokens' | 'scripts' | 'lenses' | 'settings' | 'activity' export default function AutomationSettings() { const { tokens, capabilities, profiles, classes, mcp, available, refresh, create, update, rotate, setEnabled, review, revoke, setMcp } = @@ -114,6 +115,7 @@ export default function AutomationSettings() { {tab('tokens', 'Tokens')} {tab('scripts', 'Automations')} {tab('lenses', 'Lenses')} + {tab('settings', 'Settings')} {tab('activity', 'Activity')} @@ -130,6 +132,8 @@ export default function AutomationSettings() { /> )} + {subTab === 'settings' && } + {subTab === 'activity' && (
diff --git a/web/src/components/ScriptRuns.tsx b/web/src/components/ScriptRuns.tsx index e7edd5b..2701df9 100644 --- a/web/src/components/ScriptRuns.tsx +++ b/web/src/components/ScriptRuns.tsx @@ -107,7 +107,9 @@ export default function ScriptRuns() { {r.tokenName} {r.result.reason} - {r.result.calls} call{r.result.calls === 1 ? '' : 's'} + {r.result.calls} + {r.result.budget?.maxCalls ? `/${r.result.budget.maxCalls}` : ''} call + {r.result.calls === 1 ? '' : 's'} {r.result.sendCalls > 0 ? ` (${r.result.sendCalls}s)` : ''} {r.durationMs}ms @@ -139,8 +141,11 @@ function RunDetail({ run, onClose }: { run: ScriptRun; onClose: () => void }) {

{run.id}

{r.reason} · {run.tokenName} · {run.trigger} · {run.bundle} · {run.durationMs}ms - · {r.calls} SDK call{r.calls === 1 ? '' : 's'} - {r.sendCalls > 0 ? ` (${r.sendCalls} sending)` : ''} + · {r.calls} + {r.budget?.maxCalls ? `/${r.budget.maxCalls}` : ''} SDK call{r.calls === 1 ? '' : 's'} + {r.sendCalls > 0 + ? ` (${r.sendCalls}${r.budget?.maxSendCalls ? `/${r.budget.maxSendCalls}` : ''} sending)` + : ''}

+ + + + + {/* Per run: a default and a maximum. */} +
+
+

Per run

+
+ Default + Max +
+
+
+ {budget.specs.map((sp) => ( +
+
+
+ {sp.label} ({sp.unit}) +
+

+ {sp.description} +

+ +
+
+
+ {cell(sp, defaults[sp.key], sp.default, (v) => + setDefaults({ ...defaults, [sp.key]: v }) + )} + {cell(sp, maxima[sp.key], maxPlaceholder(budget, sp), (v) => + setMaxima({ ...maxima, [sp.key]: v }) + )} +
+ +
+
+ ))} +
+

+ The default is what a run that asks for nothing gets. The{' '} + max is the most a run may ask for — an agent calling{' '} + script_run names its own figure, and anything above + yours is trimmed to yours rather than refused. A blank box takes the greyed number + beside it, which is Joro’s own; type over it and your number stands, however much + higher. A row showing ≤ under its numbers has a + ceiling neither figure may pass, and says what that ceiling is tied to; a row without + one has no further limit. +

+
+ + {/* Host limits: one number each. */} +
+
+

+ This Joro +

+
+ Limit +
+
+
+ {budget.hostSpecs.map((sp) => ( +
+
+
+ {sp.label} ({sp.unit}) +
+

+ {sp.description} +

+ +
+
+ {cell(sp, host[sp.key], sp.default, (v) => + setHost({ ...host, [sp.key]: v }) + )} + +
+
+ ))} +
+

+ These belong to this Joro rather than to one run, so there is nothing for an automation + or an agent to ask for: your number is the limit. The two agent figures are the + exception — they share {Math.round(budget.agentOutputCap / 1024)} KB of tool result + between them, so their sum is checked as well as each one. +

+
+ + {/* What it applies to. */} +
+

+ Where this applies +

+ +
+ + ) +} diff --git a/web/src/components/automation/RunOutput.tsx b/web/src/components/automation/RunOutput.tsx index 56a2666..1b73271 100644 --- a/web/src/components/automation/RunOutput.tsx +++ b/web/src/components/automation/RunOutput.tsx @@ -16,9 +16,14 @@ export default function RunOutput({ run, onClose }: { run: ScriptRun; onClose: ( {r.reason} + {/* Counts against the budget they were held to: a bare "12 calls" says nothing + about whether the run was cut short. */} - {run.durationMs}ms · {r.calls} call{r.calls === 1 ? '' : 's'} - {r.sendCalls > 0 ? ` (${r.sendCalls} sending)` : ''} + {run.durationMs}ms · {r.calls} + {r.budget?.maxCalls ? `/${r.budget.maxCalls}` : ''} call{r.calls === 1 ? '' : 's'} + {r.sendCalls > 0 + ? ` (${r.sendCalls}${r.budget?.maxSendCalls ? `/${r.budget.maxSendCalls}` : ''} sending)` + : ''} {r.storageOps ? ` · ${r.storageOps} storage` : ''} +

+ Your own ceiling for this automation, saved separately so updating the code cannot + revert it. + {effective && ( + <> + {' '} + Effective: {effective.timeoutMs} ms · {effective.memoryMb} MB ·{' '} + {effective.maxCalls} calls · {effective.maxSendCalls} sending. + + )} +

+ + )} +