Documentation
¶
Overview ¶
Package scriptlayer is the MCP surface of the managed-script feature: the manage_script tool and everything it needs to resolve, authorize, edit, validate, and dry-run a script.
It owns the assembly (the Postgres-backed script store) and the tool, and it depends on pkg/script for the domain rules and internal/platform/scriptrun for the engine. Nothing here decides what Starlark means and nothing here re-implements the edit gate; both live one layer down, so the tool is a translation from MCP arguments into domain calls.
The MCP server is captured at RegisterTool rather than at construction: the store must exist early, while the server exists only once the platform has assembled it, and run_draft needs that server to open its in-memory session.
Index ¶
Constants ¶
const ( // DefaultWaitSeconds is how long run_script waits for a run to finish when // the caller names no window. DefaultWaitSeconds = 120 // MaxWaitSeconds caps the wait. Past it the tool answers with the run id and // a pending status rather than holding a request open for the ten minutes a // run is allowed to take. MaxWaitSeconds = 300 )
Waiting policy for run_script.
const DialectContract = `Managed scripts are written in Starlark: Python-shaped syntax, deliberately smaller. WHAT IS AVAILABLE platform.query(sql, connection=..., params={}) Run read-only SQL. Returns {"columns": [...], "rows": [...], "row_count": n}; rows are dicts keyed by column name. It is the read tool, so a statement that modifies state — INSERT, UPDATE, DELETE, CREATE, DROP — is refused by it, and the write tool is reached with platform.call("trino_execute", {...}). Use :name placeholders and pass the values in params; the platform quotes them by type. Never build SQL by string concatenation. A date binds as a quoted string, so compare it against a DATE column as "DATE :day", which renders the standard date literal DATE '2026-08-12'. A query whose result is truncated by the row cap FAILS rather than returning a partial answer: aggregate in SQL, or narrow the query. A SQL DECIMAL column arrives in the rows as a STRING, not a number, so pass it through float() before arithmetic: sum([float(r["total"]) for r in rows]). platform.export(name, rows, format="csv", destination="portal", key=None) Declare an output. rows is a list of dicts serialized in the declared format, or a string body written verbatim so a script can compose a document: an HTML or JSX dashboard, a prose report, a hand-assembled markdown page. Formats: csv, json, markdown, text, html, jsx. csv and json require rows, so a data feed stays well-formed by construction; html and jsx take only a string body; markdown and text accept either. name is the output's identity across runs: the same name from the same script is one portal asset, and every run adds a version of it, so a dashboard keeps its identity instead of a new asset appearing every morning. destination says where the output goes. The default, "portal", is that versioned asset. A destination the deployment configures for a bucket delivers the same bytes to an external system instead; the script names only the destination, and the connection, bucket and prefix come from the configuration. Exporting one result to both is two calls with one name. key is the object key beneath a bucket destination's configured prefix ("2026/08/sales.csv"); it defaults to the output name plus the format's extension, and the portal takes no key because it stores its own objects. destination and key must be passed BY NAME. Only name, rows and format may be positional, because where a script writes has to be readable from its source. In a draft run this writes nothing, wherever it was addressed, and reports the shape and size the output would have: the content is serialized in the declared format to measure it, so the size is the one a real run writes. platform.publish_data(name, data) Refresh the data region of an existing dashboard without touching its markup. name is the same output identity platform.export uses, and must already be an html, jsx, or markdown document of this script's; data is a dict or list, serialized as JSON and spliced into the interior of the ONE element matching ` + script.DataRegionSelector + ` — conventionally <script type="application/json" id="data">...</script>, whose content the dashboard's own code reads and renders (a markdown document carries the island as a raw-HTML block). The write is a new version of the asset, so every refresh is a self-contained as-of snapshot; a document without the marked region fails the run rather than being written anywhere else. Publish the presentation once with platform.export(name, body, format="html") (or "jsx" or "markdown"), then let the schedule refresh only the numbers: the layout can be edited in the asset like any document, with no script change needed. Zero rows is your decision, as with any export: publish the empty structure or fail(). In a draft run this writes nothing and reports the payload size it would splice. platform.call(tool, args={}) Call any platform tool by name and get its structured result. This is the same mechanism the three helpers above are built on, with the tool left to you, and it is how a script reaches everything else the platform can do: writing a table with trino_execute, fetching an external API server-side with api_invoke_endpoint, reading an object with s3_get_object, capturing a memory, updating the catalog. A run acts on what its author owns: it authenticates as script:<name> and carries the author's address, so it can refresh or patch a dashboard the author owns. An asset merely SHARED with them is not inherited. A script may call every tool ITS AUTHOR may call. Each call is authorized by the persona filter at the moment it is made, presenting the roles you held when you saved the version, so a tool your persona does not allow is refused in the persona filter's own words. There is no separate script allowlist to consult. Prefer the three helpers where they apply. They are not a restriction you are working around: platform.query pushes the row cap down into the query and FAILS a truncated result, which a raw trino_query call hands you to notice yourself, and platform.export records what it wrote on the run, which a write made by tool call does not appear in. The result byte cap applies to every call. A tool that answers with plain text rather than a structured object arrives as {"text": "..."}; decode it yourself if it is JSON. args is a dict of the tool's own arguments, passed through unchanged: platform.call("trino_execute", {"connection": "warehouse", "sql": "INSERT INTO t VALUES (1)"}). Name the tool with a string literal, and write the args dict out in the call. validate reads both, which is how a reader learns what a script reaches without reading the Starlark: a computed tool name is reported as a gap in the tool list, and a computed args dict as a gap in the connection list. run_script and manage_script run_draft are refused from inside a run. A run executes one at a time, so a script waiting on a run it started would wait on the worker running it. Give the second script its own schedule. print(...) Goes to the run log (capped; anything larger is an export). run.run_id, run.fire_time, run.params["name"] The frozen run record. A parameter is typed string, int, float, bool, date, enum or connection. Declare a connection parameter for a connection the caller chooses rather than a string: the surfaces that ask for one offer the connections this script may reach, and a name outside them is refused where it was entered instead of failing the run. json.encode / json.decode / json.indent date.of, date.parse, date.format, date.add_days, date.add_months, date.diff_days, date.start_of_month, date.weekday All dates are YYYY-MM-DD strings. date.format uses YYYY, MM and DD tokens. sum(iterable, start=0) Adds numbers left to right. Starlark's own universe has no sum, so the platform predeclares it; a non-number element is refused by position rather than concatenated. The Starlark built-ins: len, range, sorted, min, max, enumerate, zip, str, int, float, dict, list, set, any, all, fail, and the string, list and dict methods (including "{}".format(x) and "%d" % x). WHAT IS NOT, AND WHAT TO WRITE INSTEAD import There is no module system. json and date are already here. try / except Errors fail the run by design, so the failure is recorded rather than swallowed. Check first, or call fail("why"). while Unbounded loops are off so a script's cost is readable from its source. Loop over a list, or do it in SQL. recursion Off, for the same reason. Flatten it into a loop. f"..." Use "{}".format(x) or "%s" % x. class Use dicts for structured values and functions for behavior. datetime / now() There is no clock. Reading one would make the run unreproducible; the fire time is pinned on run.fire_time. random There is no randomness, for the same reason. open / requests There is no filesystem and no direct network. The platform is the only outside world a script has: reach an external API through a configured connection with platform.call("api_invoke_endpoint", {...}). credentials Never in the source. Name a connection; the platform holds its credentials and authorizes the call. WHAT DETERMINISTIC MEANS HERE Same script version + same parameters + same underlying data produce the same output. The warehouse still changes between runs, and that is the point of re-running: the promise is that the SCRIPT contributes no variation of its own. THE LOOP create -> validate -> run_draft -> patch -> validate -> run_draft. validate parses and reports the capabilities, the tools platform.call names, the connections, and the destinations the script's OUTPUTS go to; run_draft executes it under your own identity with nothing persisted. Both act on the source you send with the call, and on the saved version when you send none: a save is immediately the version run_script executes and a schedule fires, so sending the edit is how you try it without making it live. validate also reports a destination this deployment does not declare, which the run would otherwise refuse only after your queries had already run.`
DialectContract is the help command's body: what a script is, what is predeclared, and what a Python instinct will reach for and not find. It is exported for the built-in knowledge pages (#1390): the authoring page derives its dialect section from this constant, so the two cannot drift.
const ToolNameManageScript = "manage_script"
ToolNameManageScript is the MCP tool name of the script-management tool, exported for composition roots that bind UI apps to it.
const ToolNameRunScript = "run_script"
ToolNameRunScript is the MCP tool name of the platform-execution tool, exported for composition roots that bind UI apps to it.
const ToolNameShowScripts = "show_scripts"
ToolNameShowScripts is the MCP tool name of the presentation-only trigger that opens the portal's script pages for the human.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// DB backs the script store; nil leaves the store nil and manage_script
// unregistered (there is nowhere to keep a script).
DB *sql.DB
// Store, when non-nil, is used directly instead of building a Postgres
// store from DB. Production passes DB and leaves this nil.
Store script.Store
// Runs is the run queue run_script enqueues onto and the run history the
// run commands read. nil leaves run_script unregistered, which is the
// correct shape for a deployment that cannot execute scripts at all.
Runs script.RunStore
// AdminPersona is the persona name that grants authority over every
// script; matched against the caller's persona in each command.
AdminPersona string
// PortalURL is the deployment's public portal address, used by show_scripts
// to name where the script pages are. Empty leaves the tool registered and
// linkless: a deployment that has not been told its own address cannot be
// given one by guessing.
PortalURL string
// Destinations is the deployment's configured bucket destinations, which a
// draft run resolves platform.export names against exactly as a platform
// run does.
Destinations []script.Destination
}
Config carries the resolved values the owner needs to assemble the script layer. The caller translates its own config into this shape so this package stays free of the platform's config types.
type Handle ¶
type Handle struct {
// contains filtered or unexported fields
}
Handle owns the assembled script layer. All accessors are nil-safe, so a deployment without a database holds a Handle that registers nothing.
func (*Handle) IndexProducer ¶ added in v1.122.0
IndexProducer returns the write-path index-job producer behind the managed- script store, or nil on a deployment with no database. The composition root hands it to the index queue, which binds it once the scripts consumer is registered; until then, and forever where no worker runs, NotifyWrite is a no-op and the reconciler is the only route to the index.
func (*Handle) RegisterTool ¶
RegisterTool registers manage_script; where the deployment can execute saved scripts, run_script; and the presentation-only show_scripts. It also captures the server the two run paths open their in-memory sessions against. No-op on a nil Handle or a no-database deployment (there is nowhere to keep a script).