Surfaced run budget configuration settings in the UI

This commit is contained in:
Tony West committed 2026-08-20 02:01:36 -04:00
1 parent b8cc32062d
commit e018c7fcb0
19 files changed
+1361 -169

No files matched your search

+19 -1
View File
@@ -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()
},
})
}
+161 -5
View File
@@ -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{
+2
View File
@@ -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).
+38 -2
View File
@@ -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)
+56 -25
View File
@@ -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)
+55 -16
View File
@@ -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,
+23 -27
View File
@@ -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,
}
}
+16 -4
View File
@@ -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)
}
+433 -48
View File
@@ -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 {
+8 -3
View File
@@ -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
+8 -7
View File
@@ -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++
+4 -2
View File
@@ -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
+9 -5
View File
@@ -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')}
</div>
@@ -130,6 +132,8 @@ export default function AutomationSettings() {
/>
)}
{subTab === 'settings' && <BudgetPanel />}
{subTab === 'activity' && (
<div className="flex-1 overflow-auto p-5 space-y-4">
<AutomationActivity />
+8 -3
View File
@@ -107,7 +107,9 @@ export default function ScriptRuns() {
<span className="shrink-0 w-24 truncate">{r.tokenName}</span>
<span className="shrink-0 w-32 truncate">{r.result.reason}</span>
<span className="shrink-0 w-20">
{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)` : ''}
</span>
<span className="shrink-0 w-14">{r.durationMs}ms</span>
@@ -139,8 +141,11 @@ function RunDetail({ run, onClose }: { run: ScriptRun; onClose: () => void }) {
<h3 className="text-sm font-semibold text-content-primary font-mono truncate">{run.id}</h3>
<p className="text-[11px] text-content-muted">
{r.reason} &middot; {run.tokenName} &middot; {run.trigger} &middot; {run.bundle} &middot; {run.durationMs}ms
&middot; {r.calls} SDK call{r.calls === 1 ? '' : 's'}
{r.sendCalls > 0 ? ` (${r.sendCalls} sending)` : ''}
&middot; {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)`
: ''}
</p>
</div>
<button onClick={onClose} className="ml-auto text-content-muted hover:text-content-primary" aria-label="Close">
@@ -0,0 +1,326 @@
import { useEffect, useState } from 'react'
import { RotateCcw, Save } from 'lucide-react'
import type {
AutomationBudget,
AutomationHostLimits,
AutomationLimits,
BudgetSpec,
} from '../../lib/api'
import { useAutomationStore } from '../../stores/automationStore'
import { useToastStore } from '../../stores/toastStore'
const inputCls = 'bg-surface-input text-xs px-2 py-1 rounded-sm border border-border w-24 text-right'
/** A draft as this panel holds it: every row is driven by a spec key the server sent, so
* indexing by string is what the panel actually does. Cast once at the API boundary. */
type Fields = Record<string, number | undefined>
/**
* The ceiling on a row, printed under its numbers — for the few fields that really do stop
* somewhere. A field with no cap renders nothing, and that absence is the statement: the
* operator's number is final there, so printing a bound would be the same illusion as
* refusing the value they typed.
*/
function CapLine({ spec }: { spec: BudgetSpec }) {
if (!spec.cap) return null
return (
<div className="text-[10px] text-semantic-warning text-right mt-1">
&le; {spec.cap} {spec.unit}
</div>
)
}
/** Why that ceiling is where it is, in the row's own words. */
function CapReason({ spec }: { spec: BudgetSpec }) {
if (!spec.cap) return null
return (
<p className="text-[10px] text-content-muted leading-snug mt-0.5 max-w-xl italic">
{spec.cap} {spec.unit} is the ceiling: {spec.capReason}.
</p>
)
}
/** The greyed figure in the Max column: what the maximum currently resolves to, which is
* Joro's own number unless the operator's default has raised it past that. */
function maxPlaceholder(budget: AutomationBudget, sp: BudgetSpec): number {
const resolved = (budget.effectiveMax as Record<string, number | undefined>)[sp.key]
return resolved !== undefined ? resolved / sp.factor : (sp.defaultMax ?? 0)
}
function shown(stored: number | undefined, sp: BudgetSpec): string {
if (!stored) return ''
return String(stored / sp.factor)
}
/**
* The run budget: what one sandboxed automation may do, and how many may run at once.
*
* Two halves, because they answer different questions. Per run the operator sets a
* default (what a run that asks for nothing gets) and a maximum (the most a run may ask
* for). The host limits are properties of this Joro rather than of one run, so they are a
* single number each.
*
* Every figure and every sentence comes from GET /automation/limits, which builds them
* from the constants the runtime enforces. Nothing about the budget is written twice.
*/
export default function BudgetPanel() {
const { budget, scriptsUnavailable, refreshBudget, setBudget } = useAutomationStore()
const addToast = useToastStore((s) => s.addToast)
const [defaults, setDefaults] = useState<Fields>({})
const [maxima, setMaxima] = useState<Fields>({})
const [host, setHost] = useState<Fields>({})
const [busy, setBusy] = useState(false)
useEffect(() => {
refreshBudget()
}, [refreshBudget])
// Re-seeded whenever the server's copy changes, which is on load and after a save.
useEffect(() => {
if (!budget) return
setDefaults({ ...budget.policy.defaults })
setMaxima({ ...budget.policy.maxima })
setHost({ ...budget.policy.host })
}, [budget])
if (!budget) {
return (
<div className="flex-1 overflow-auto p-5">
<h3 className="text-sm font-semibold text-content-primary mb-2">Run budget</h3>
<p className="text-[11px] text-content-secondary leading-relaxed max-w-xl">
{scriptsUnavailable?.includes('--automation-scripting') ? (
<>
Script automation is off, so there is no run budget to set. Start Joro with{' '}
<code className="font-mono text-content-primary">--automation-scripting</code> to install
JavaScript automations.
</>
) : (
<>{scriptsUnavailable ?? 'Loading…'}</>
)}
</p>
</div>
)
}
const stored = budget.policy
const same = (a: Fields, b: Fields, keys: string[]) =>
keys.every((k) => (a[k] ?? 0) === (b[k] ?? 0))
const runKeys = budget.specs.map((sp) => sp.key)
const hostKeys = budget.hostSpecs.map((sp) => sp.key)
const dirty =
!same(defaults, { ...stored.defaults }, runKeys) ||
!same(maxima, { ...stored.maxima }, runKeys) ||
!same(host, { ...stored.host }, hostKeys)
const clear = () => {
setDefaults({})
setMaxima({})
setHost({})
}
const save = async () => {
setBusy(true)
try {
await setBudget({
defaults: defaults as AutomationLimits,
maxima: maxima as AutomationLimits,
host: host as AutomationHostLimits,
})
addToast('Run budget saved', 'info')
} catch (e) {
// The server names the field it refused and the limit it broke rather than
// clamping silently, so this message is the whole explanation.
addToast(String(e instanceof Error ? e.message : e), 'error')
} finally {
setBusy(false)
}
}
/**
* One figure, in the operator's unit, stored in the field's.
*
* A text box rather than type=number, deliberately. A number input's stepper starts from
* its minimum when the box is empty — and an unset field here *is* empty, showing what it
* inherits as a placeholder — so the first press produced 1 and the next 2, which reads as
* the field resetting and refusing to climb. Seeding the box to fix that then ran into a
* number input supporting no selection API, so typing over the seed appended to it. None
* of that exists for a text box: it holds what you type, an empty one still means "inherit
* Joro's figure", and no browser-side max, min or step implies a bound the field does not
* have. Where a bound does exist the row prints it, and the server refuses a value past it
* naming what it is fixed against.
*
* Non-digits are dropped as they arrive, so the box cannot hold something that is not a
* number and every value here parses.
*/
const cell = (
sp: BudgetSpec,
value: number | undefined,
placeholder: number,
onChange: (v: number | undefined) => void
) => (
<input
type="text"
inputMode="numeric"
autoComplete="off"
aria-label={`${sp.label} (${sp.unit})`}
className={inputCls}
placeholder={String(placeholder)}
value={shown(value, sp)}
onChange={(e) => {
const digits = e.target.value.replace(/[^0-9]/g, '')
const n = Number(digits)
onChange(digits !== '' && n > 0 ? Math.round(n * sp.factor) : undefined)
}}
/>
)
return (
<div className="flex-1 overflow-auto p-5 space-y-5">
<div className="flex items-start gap-2">
<div className="max-w-2xl space-y-1.5">
<h3 className="text-sm font-semibold text-content-primary">Run budget</h3>
<p className="text-[11px] text-content-muted leading-relaxed">
An automation is code Joro executes on your behalf, and some of it is written by an
agent rather than by you. The budget is what keeps the cost of running it bounded and
knowable: a script that loops forever, allocates without end, or calls the SDK ten
thousand times stops at a number you chose, and a runaway costs a worker process
rather than the proxy holding your captured traffic. It is also the leash on an agent
— without it, one <code className="font-mono">script_run</code> call could turn into an
open-ended sweep of a client&rsquo;s systems.
</p>
<p className="text-[11px] text-content-muted leading-relaxed">
Raise these when the defaults are too small for the work — a wider sweep, a longer
comparison — and lower them when an agent should be kept on a shorter leash than you
are.
</p>
</div>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<button
onClick={clear}
disabled={busy}
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary disabled:opacity-40"
title="Clear every field back to Joro's own numbers"
>
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
Defaults
</button>
<button
onClick={save}
disabled={busy || !dirty}
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-accent-secondary hover:bg-accent-secondary-hover text-black font-semibold disabled:opacity-40"
>
<Save size={11} strokeWidth={2.2} aria-hidden="true" />
Save
</button>
</div>
</div>
{/* Per run: a default and a maximum. */}
<div>
<div className="flex items-end gap-3 mb-1.5">
<h4 className="text-xs font-semibold text-content-muted uppercase tracking-wide">Per run</h4>
<div className="ml-auto flex gap-3 text-[10px] text-content-muted uppercase tracking-wide">
<span className="w-24 text-right">Default</span>
<span className="w-24 text-right">Max</span>
</div>
</div>
<div className="bg-surface-card border border-border rounded divide-y divide-border-subtle">
{budget.specs.map((sp) => (
<div key={sp.key} className="p-3 flex items-start gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-content-primary">
{sp.label} <span className="text-content-muted font-normal">({sp.unit})</span>
</div>
<p className="text-[10px] text-content-muted leading-snug mt-0.5 max-w-xl">
{sp.description}
</p>
<CapReason spec={sp} />
</div>
<div className="ml-auto shrink-0">
<div className="flex gap-3">
{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 })
)}
</div>
<CapLine spec={sp} />
</div>
</div>
))}
</div>
<p className="text-[10px] text-content-muted leading-relaxed mt-1.5 max-w-2xl">
The <strong>default</strong> is what a run that asks for nothing gets. The{' '}
<strong>max</strong> is the most a run may ask for — an agent calling{' '}
<code className="font-mono">script_run</code> 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&rsquo;s own; type over it and your number stands, however much
higher. A row showing <span className="font-mono">&le;</span> 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.
</p>
</div>
{/* Host limits: one number each. */}
<div>
<div className="flex items-end gap-3 mb-1.5">
<h4 className="text-xs font-semibold text-content-muted uppercase tracking-wide">
This Joro
</h4>
<div className="ml-auto flex gap-3 text-[10px] text-content-muted uppercase tracking-wide">
<span className="w-24 text-right">Limit</span>
</div>
</div>
<div className="bg-surface-card border border-border rounded divide-y divide-border-subtle">
{budget.hostSpecs.map((sp) => (
<div key={sp.key} className="p-3 flex items-start gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-content-primary">
{sp.label} <span className="text-content-muted font-normal">({sp.unit})</span>
</div>
<p className="text-[10px] text-content-muted leading-snug mt-0.5 max-w-xl">
{sp.description}
</p>
<CapReason spec={sp} />
</div>
<div className="ml-auto shrink-0">
{cell(sp, host[sp.key], sp.default, (v) =>
setHost({ ...host, [sp.key]: v })
)}
<CapLine spec={sp} />
</div>
</div>
))}
</div>
<p className="text-[10px] text-content-muted leading-relaxed mt-1.5 max-w-2xl">
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.
</p>
</div>
{/* What it applies to. */}
<div className="max-w-2xl">
<h4 className="text-xs font-semibold text-content-muted uppercase tracking-wide mb-1.5">
Where this applies
</h4>
<ul className="text-[11px] text-content-muted leading-relaxed space-y-1 list-disc pl-4">
<li>
Every run, however it started: an agent&rsquo;s{' '}
<code className="font-mono">script_run</code>, a run you start from the editor, an
automation an agent invokes, a lens rendering a viewer tab, and a trigger firing.
</li>
<li>
An automation can ask for less than this, never more. Joro takes the smallest of three
numbers: what the author asked for in the manifest, the override you set on that
automation, and the budget here.
</li>
<li>Changes take effect on the next run. Anything already running keeps its budget.</li>
</ul>
</div>
</div>
)
}
+7 -2
View File
@@ -16,9 +16,14 @@ export default function RunOutput({ run, onClose }: { run: ScriptRun; onClose: (
<span className={`text-[11px] font-semibold ${bad ? 'text-semantic-warning' : 'text-semantic-success'}`}>
{r.reason}
</span>
{/* Counts against the budget they were held to: a bare "12 calls" says nothing
about whether the run was cut short. */}
<span className="text-[10px] text-content-muted font-mono">
{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` : ''}
</span>
<button onClick={onClose} className="ml-auto text-content-muted hover:text-content-primary" aria-label="Dismiss">
+92 -16
View File
@@ -13,6 +13,7 @@ import {
type ScriptRun,
} from '../../lib/api'
import { downloadPackage } from '../../lib/automationPackage'
import { useAutomationStore } from '../../stores/automationStore'
import { useToastStore } from '../../stores/toastStore'
import RunOutput from './RunOutput'
import SdkReference from './SdkReference'
@@ -60,9 +61,18 @@ export default function ScriptEditor({
onSaved: () => void
}) {
const addToast = useToastStore((s) => s.addToast)
// The global run budget, so the boxes below show what a run actually gets today rather
// than an empty field. Settings -> Automation -> Settings is where it is set.
const globalBudget = useAutomationStore((st) => st.budget)
const refreshBudget = useAutomationStore((st) => st.refreshBudget)
const [manifest, setManifest] = useState<AutomationManifest>(draft?.manifest ?? blankManifest())
const [source, setSource] = useState(draft?.source ?? STARTER)
// The operator's half of the budget, and what the two halves resolve to under the
// global. Kept apart from the manifest because they are saved through a different
// endpoint: updating the code must never revert a limit the operator lowered.
const [override, setOverride] = useState<AutomationLimits>({})
const [effective, setEffective] = useState<AutomationLimits | null>(null)
// The hash the source was loaded at. Sent back on update so replacing the code of an
// armed automation cannot silently clobber a concurrent edit.
const [baseHash, setBaseHash] = useState('')
@@ -80,6 +90,8 @@ export default function ScriptEditor({
setManifest(pkg.manifest)
setSource(pkg.source ?? '')
setBaseHash(pkg.sourceHash)
setOverride(pkg.state.limits ?? {})
setEffective(pkg.effectiveLimits ?? null)
})
.catch((e) => addToast(String(e instanceof Error ? e.message : e), 'error'))
return () => {
@@ -87,12 +99,19 @@ export default function ScriptEditor({
}
}, [id, addToast])
useEffect(() => {
refreshBudget()
}, [refreshBudget])
const extensions = useMemo(() => [javascript(), EditorView.lineWrapping], [])
const patch = (p: Partial<AutomationManifest>) => setManifest((m) => ({ ...m, ...p }))
const patchLimits = (p: Partial<AutomationLimits>) =>
setManifest((m) => ({ ...m, limits: { ...m.limits, ...p } }))
/** What a run gets right now for one field, for use as a placeholder. */
const globalOf = (k: keyof AutomationLimits) => globalBudget?.effective[k]
const guard = useCallback(
async (fn: () => Promise<unknown>, ok?: string) => {
setBusy(true)
@@ -140,6 +159,19 @@ export default function ScriptEditor({
})
}
// Saved through the prefs endpoint, not with the manifest: author intent and operator
// intent live in different files precisely so one cannot overwrite the other.
const saveOverride = async () => {
if (!id) return
await guard(async () => {
await api.setScriptPrefs(id, { limits: override })
// Re-read only the resolved budget. Reloading the package wholesale would throw
// away whatever is in the editor.
const pkg = await api.getScript(id)
setEffective(pkg.effectiveLimits ?? null)
}, 'Operator limits saved')
}
const toggleTrigger = (t: string) => {
const cur = manifest.triggers ?? []
patch({ triggers: cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t] })
@@ -303,28 +335,58 @@ export default function ScriptEditor({
</Field>
<Field label="Minimum interval (ms)">
<input
type="number"
className={inputCls}
value={manifest.minIntervalMs ?? ''}
placeholder="1000"
onChange={(e) => patch({ minIntervalMs: Number(e.target.value) || undefined })}
{/* A LimitBox with no label of its own: same blank-means-inherit box, so it
gets the same stepper behaviour rather than a second copy of it. */}
<LimitBox
label=""
value={manifest.minIntervalMs}
hint={1000}
onChange={(v) => patch({ minIntervalMs: v })}
/>
</Field>
<Field label="Limits">
<Field label="Limits (author)">
<div className="grid grid-cols-2 gap-1.5">
<LimitBox label="timeout ms" value={manifest.limits?.timeoutMs} onChange={(v) => patchLimits({ timeoutMs: v })} />
<LimitBox label="memory MB" value={manifest.limits?.memoryMb} onChange={(v) => patchLimits({ memoryMb: v })} />
<LimitBox label="max calls" value={manifest.limits?.maxCalls} onChange={(v) => patchLimits({ maxCalls: v })} />
<LimitBox label="max sends" value={manifest.limits?.maxSendCalls} onChange={(v) => patchLimits({ maxSendCalls: v })} />
<LimitBox label="timeout ms" value={manifest.limits?.timeoutMs} hint={globalOf('timeoutMs')} onChange={(v) => patchLimits({ timeoutMs: v })} />
<LimitBox label="memory MB" value={manifest.limits?.memoryMb} hint={globalOf('memoryMb')} onChange={(v) => patchLimits({ memoryMb: v })} />
<LimitBox label="max calls" value={manifest.limits?.maxCalls} hint={globalOf('maxCalls')} onChange={(v) => patchLimits({ maxCalls: v })} />
<LimitBox label="max sends" value={manifest.limits?.maxSendCalls} hint={globalOf('maxSendCalls')} onChange={(v) => patchLimits({ maxSendCalls: v })} />
</div>
<p className="text-[10px] text-content-muted mt-0.5">
Blank takes the default. The operator can lower these further; nothing here can
raise a limit.
What this automation asks for. Blank takes the run budget, shown greyed. Nothing
here can raise a limit.
</p>
</Field>
{id && (
<Field label="Limits (operator)">
<div className="grid grid-cols-2 gap-1.5">
<LimitBox label="timeout ms" value={override.timeoutMs} hint={globalOf('timeoutMs')} onChange={(v) => setOverride({ ...override, timeoutMs: v })} />
<LimitBox label="memory MB" value={override.memoryMb} hint={globalOf('memoryMb')} onChange={(v) => setOverride({ ...override, memoryMb: v })} />
<LimitBox label="max calls" value={override.maxCalls} hint={globalOf('maxCalls')} onChange={(v) => setOverride({ ...override, maxCalls: v })} />
<LimitBox label="max sends" value={override.maxSendCalls} hint={globalOf('maxSendCalls')} onChange={(v) => setOverride({ ...override, maxSendCalls: v })} />
</div>
<button
onClick={saveOverride}
disabled={busy}
className="mt-1.5 w-full text-[11px] px-2 py-1 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary disabled:opacity-40"
>
Save operator limits
</button>
<p className="text-[10px] text-content-muted mt-1 leading-snug">
Your own ceiling for this automation, saved separately so updating the code cannot
revert it.
{effective && (
<>
{' '}
Effective: {effective.timeoutMs} ms &middot; {effective.memoryMb} MB &middot;{' '}
{effective.maxCalls} calls &middot; {effective.maxSendCalls} sending.
</>
)}
</p>
</Field>
)}
<Field label="Test input (JSON)">
<textarea
className={`${inputCls} h-14 resize-none font-mono`}
@@ -353,23 +415,37 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
)
}
/**
* One numeric limit. hint is what a run gets while the box is empty.
*
* A text box rather than type=number, for the reason BudgetPanel's cell gives: a stepper on
* a box that is empty by design steps from 1 rather than from the figure being inherited.
*/
function LimitBox({
label,
value,
hint,
onChange,
}: {
label: string
value?: number
hint?: number
onChange: (v: number | undefined) => void
}) {
return (
<label className="block">
<span className="block text-[10px] text-content-muted">{label}</span>
{label && <span className="block text-[10px] text-content-muted">{label}</span>}
<input
type="number"
type="text"
inputMode="numeric"
autoComplete="off"
className={inputCls}
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value) || undefined)}
placeholder={hint !== undefined ? String(hint) : ''}
onChange={(e) => {
const digits = e.target.value.replace(/[^0-9]/g, '')
onChange(digits !== '' && Number(digits) > 0 ? Number(digits) : undefined)
}}
/>
</label>
)
+69 -3
View File
@@ -261,6 +261,9 @@ export interface ScriptResult {
storageOps?: number
callInputBytes: number
callOutputBytes: number
/** The budget this run was actually held to, after the operator's global was applied.
* Reported so a count has something to be read against. */
budget?: AutomationLimits
durationMs: number
}
@@ -360,6 +363,64 @@ export interface AutomationPackage {
state: AutomationState
source?: string
sourceHash: string
/** What a run of this automation actually gets: the author's request, narrowed by the
* operator's override, held to the global budget. Resolved server-side so no caller
* has to hold three halves and decide which wins. */
effectiveLimits?: AutomationLimits
}
/** One configurable field of the run budget, with the reason it is set where it is.
* Served rather than restated here, so the UI cannot drift from the runtime. */
export interface BudgetSpec {
key: string
label: string
/** The unit the operator types in — seconds, KB, calls. */
unit: string
/** Stored value = entered value x factor. Wall clock is entered in seconds and
* stored in milliseconds, because that is what an automation manifest declares. */
factor: number
/** Joro's own default, and the maximum that applies while the operator has set none.
* Both in the operator's unit, unlike the stored field. defaultMax is absent only on a
* host spec, which has no requestable side and so no maximum. */
default: number
defaultMax?: number
/** The one figure the operator cannot exceed, with what it is fixed against. Absent
* for most fields, where their number is final. */
cap?: number
capReason?: string
description: string
}
/** Limits that belong to this Joro rather than to one run. */
export interface AutomationHostLimits {
storageOps?: number
sourceBytes?: number
concurrentRuns?: number
agentLogBytes?: number
agentResultBytes?: number
}
/** What the operator has set: per field a default and a maximum, plus the host limits. */
export interface AutomationPolicy {
defaults?: AutomationLimits
maxima?: AutomationLimits
host?: AutomationHostLimits
}
export interface AutomationBudget {
policy: AutomationPolicy
/** What a run that asks for nothing is held to. */
effective: AutomationLimits
/** The most a run may ask for. Not always the shipped figure: an operator default
* above it raises it, because their setting has to take. */
effectiveMax: AutomationLimits
/** The host limits with every unset field resolved. */
host: AutomationHostLimits
specs: BudgetSpec[]
hostSpecs: BudgetSpec[]
/** The bytes the two agent-output limits share; the one ceiling here that is fixed at
* startup, so the pair is checked against it on save. */
agentOutputCap: number
}
/** List projection. Source is withheld, not merely omitted. */
@@ -1145,8 +1206,10 @@ export const api = {
}
) =>
req<AutomationSummary>('PUT', `/automation/scripts/${id}/prefs`, prefs),
// No client timeout below the server's: a run may legitimately take a minute, and
// aborting it here would leave the operator with no result and the run still going.
// No client timeout below the server's: a run may legitimately last as long as the
// operator's wall-clock budget allows, and aborting here would leave them with no
// report while the run carried on. Keep this above jsruntime.CapTimeout (10 minutes),
// which is the longest wall clock the budget can be set to.
runScript: (body: {
scriptId?: string
source?: string
@@ -1154,7 +1217,7 @@ export const api = {
/** Labels the run in the log; 'lens' also strips the send capabilities. */
trigger?: string
timeoutMs?: number
}) => req<ScriptRun>('POST', '/automation/runs', body, 120000),
}) => req<ScriptRun>('POST', '/automation/runs', body, 630_000),
getScriptSdk: () =>
req<{
bundle: string
@@ -1163,6 +1226,9 @@ export const api = {
globals: { js: string; description: string }[]
triggers: string[]
}>('GET', '/automation/sdk'),
getAutomationLimits: () => req<AutomationBudget>('GET', '/automation/limits'),
setAutomationLimits: (policy: AutomationPolicy) =>
req<AutomationBudget>('PUT', '/automation/limits', { policy }),
getMcpState: () => req<McpState>('GET', '/automation/mcp'),
setMcpState: (body: { enabled?: boolean; port?: number }) =>
req<McpState>('PUT', '/automation/mcp', body),
+27
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand'
import {
api,
type AutomationBudget,
type AutomationPolicy,
type AutomationProfile,
type AutomationSummary,
type AutomationToken,
@@ -27,6 +29,11 @@ interface AutomationState {
profiles: AutomationProfile[]
fingerprint: string
mcp: McpState | null
/** The run budget: what the operator set, what a run gets, and the shipped defaults,
* ceilings and rationale the server serves alongside it. Held here rather than in the
* panel that edits it because the automation editor shows the same numbers as its
* placeholders. */
budget: AutomationBudget | null
loading: boolean
/** Null until the first fetch resolves; false means automation is compiled in
* but the endpoints 404 (--no-automation), which the UI renders as an
@@ -35,6 +42,8 @@ interface AutomationState {
refresh: () => Promise<void>
refreshMcp: () => Promise<void>
refreshBudget: () => Promise<void>
setBudget: (policy: AutomationPolicy) => Promise<void>
refreshScripts: () => Promise<void>
create: (body: AutomationTokenInput) => Promise<string>
update: (id: string, body: Partial<AutomationTokenInput>) => Promise<void>
@@ -55,6 +64,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
profiles: [],
fingerprint: '',
mcp: null,
budget: null,
loading: false,
available: null,
@@ -92,6 +102,23 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
}
},
refreshBudget: async () => {
try {
set({ budget: await api.getAutomationLimits(), scriptsUnavailable: null })
} catch (e) {
// This endpoint 404s through the same guard as the automation list, with the same
// two messages naming --no-automation or --automation-scripting, so it feeds the
// one field rather than a second copy of the same state.
set({ budget: null, scriptsUnavailable: String(e instanceof Error ? e.message : e) })
}
},
/** Throws on a rejected value so the caller can surface which field was refused —
* the server names the field and its ceiling rather than silently clamping. */
setBudget: async (policy) => {
set({ budget: await api.setAutomationLimits(policy) })
},
refreshScripts: async () => {
try {
const d = await api.listScripts()