mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Surfaced run budget configuration settings in the UI
This commit is contained in:
1 parent
b8cc32062d
commit
e018c7fcb0
19 files changed
+1361
-169
No files matched your search
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} · {run.tokenName} · {run.trigger} · {run.bundle} · {run.durationMs}ms
|
||||
· {r.calls} SDK call{r.calls === 1 ? '' : 's'}
|
||||
{r.sendCalls > 0 ? ` (${r.sendCalls} sending)` : ''}
|
||||
· {r.calls}
|
||||
{r.budget?.maxCalls ? `/${r.budget.maxCalls}` : ''} SDK call{r.calls === 1 ? '' : 's'}
|
||||
{r.sendCalls > 0
|
||||
? ` (${r.sendCalls}${r.budget?.maxSendCalls ? `/${r.budget.maxSendCalls}` : ''} sending)`
|
||||
: ''}
|
||||
</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">
|
||||
≤ {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’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’s own; type over it and your number stands, however much
|
||||
higher. A row showing <span className="font-mono">≤</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’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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 · {effective.memoryMb} MB ·{' '}
|
||||
{effective.maxCalls} calls · {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
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user