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' &&
{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)` + : ''}