Add batch.exec(callables, timeout=10) — a general-purpose concurrent execution primitive that runs arbitrary Starlark callables in parallel, each in its own thread with independent step budget. Enables extensions to fan out library function calls (frozen exports from lib.require) without decomposing them back into raw http.post parameters. New permission: batch.exec. Max 8 callables, timeout 1-30s. Nesting prohibited via atomic flag. 12 new tests, all pass with -race. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
297 lines
8.1 KiB
Markdown
297 lines
8.1 KiB
Markdown
# Starlark Reference
|
||
|
||
Armature extensions can include Starlark scripts for server-side logic.
|
||
Starlark is a Python-like language designed for configuration and
|
||
embedding — see the [official spec](https://github.com/google/starlark-go).
|
||
|
||
## Sandbox constraints
|
||
|
||
- **No `while` loops** — use `for` with bounded ranges.
|
||
- **No `load()`** — use `lib.require()` for library dependencies.
|
||
- **Max steps:** 1,000,000 bytecode operations per execution.
|
||
- **No filesystem or OS access** — all I/O goes through gated modules.
|
||
- **Deterministic** — same input produces same output (no `random`, no `time`).
|
||
|
||
## Always-available modules
|
||
|
||
These modules are injected into every script with no permission required.
|
||
|
||
### json
|
||
|
||
Standard Starlark JSON module.
|
||
|
||
```python
|
||
data = json.decode('{"key": "value"}')
|
||
text = json.encode({"key": "value"})
|
||
```
|
||
|
||
### settings
|
||
|
||
Read resolved package settings (global → team → user cascade).
|
||
|
||
```python
|
||
val = settings.get("theme", "light")
|
||
# Returns the resolved value, or the default if unset.
|
||
```
|
||
|
||
The cascade respects the `user_overridable` flag from the package manifest.
|
||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for details.
|
||
|
||
### lib
|
||
|
||
Load exported functions from library packages.
|
||
|
||
```python
|
||
helpers = lib.require("my-utils")
|
||
result = helpers.format_date("2026-01-15")
|
||
```
|
||
|
||
Requirements:
|
||
- The library must be declared in your package manifest's `dependencies`.
|
||
- The library must be type `library`, status `active`, tier `starlark`.
|
||
- Circular dependencies are detected and rejected.
|
||
- Results are cached per execution (calling `require` twice returns the
|
||
same object).
|
||
|
||
## Permission-gated modules
|
||
|
||
These modules are only available if the package has the corresponding
|
||
permission granted in **Admin > Packages**.
|
||
|
||
### secrets
|
||
|
||
**Permission:** `secrets.read`
|
||
|
||
Read admin-configured secrets for this package.
|
||
|
||
```python
|
||
api_key = secrets.get("OPENAI_KEY") # str or None
|
||
all_keys = secrets.list() # list of key names
|
||
```
|
||
|
||
Secrets are set in **Admin > Packages > Secrets** and scoped per package.
|
||
|
||
### notifications
|
||
|
||
**Permission:** `notifications.send`
|
||
|
||
Send in-app notifications to users.
|
||
|
||
```python
|
||
notifications.send(
|
||
user_id, # str — target user UUID
|
||
title, # str — notification title
|
||
body="", # str — optional body text
|
||
type="extension.notify" # str — notification type
|
||
)
|
||
```
|
||
|
||
### db
|
||
|
||
**Permission:** `db.read` (queries) or `db.write` (mutations)
|
||
|
||
Read and write extension data tables. All tables are automatically
|
||
namespaced as `ext_{package_id}_{table_name}`.
|
||
|
||
#### Read operations
|
||
|
||
```python
|
||
# Query with filters, ordering, and pagination
|
||
rows = db.query(
|
||
"tasks", # table name (without prefix)
|
||
filters={"status": "open"}, # equality WHERE clauses
|
||
order="-created_at", # column name (prefix - for DESC)
|
||
limit=50, # max 1000
|
||
before={"created_at": ts}, # range: column < value
|
||
after={"created_at": ts}, # range: column > value
|
||
search_like={"title": "%bug%"} # LIKE/ILIKE search
|
||
)
|
||
|
||
# Read from system views (read-only)
|
||
users = db.view("users", filters={"display_name": "Alice"}, limit=10)
|
||
channels = db.view("channels", limit=100)
|
||
|
||
# List all tables owned by this package
|
||
tables = db.list_tables()
|
||
```
|
||
|
||
Available views: `users`, `channels`.
|
||
|
||
#### Aggregate operations
|
||
|
||
```python
|
||
# Count rows matching filters
|
||
count = db.count("tasks", filters={"status": "open"})
|
||
|
||
# Aggregate a column (sum, avg, min, max, count)
|
||
total = db.aggregate("orders", "amount", "sum", filters={"status": "paid"})
|
||
# Returns int, float, or None (if no matching rows)
|
||
|
||
# Batch multiple queries in a single call
|
||
results = db.query_batch([
|
||
{"table": "tasks", "filters": {"status": "open"}, "limit": 10},
|
||
{"table": "logs", "order": "-created_at", "limit": 5},
|
||
])
|
||
# Returns list of result lists. Max 10 queries per batch.
|
||
# Each query spec supports: table (required), filters, order, limit, before, after, search_like
|
||
```
|
||
|
||
#### Write operations
|
||
|
||
```python
|
||
row = db.insert("tasks", {"title": "Fix bug", "status": "open"})
|
||
# Returns the inserted row dict (with generated id, created_at)
|
||
|
||
db.update("tasks", row_id, {"status": "closed"})
|
||
# Returns True on success
|
||
|
||
db.delete("tasks", row_id)
|
||
# Returns True on success
|
||
```
|
||
|
||
### http
|
||
|
||
**Permission:** `api.http`
|
||
|
||
Make outbound HTTP requests.
|
||
|
||
```python
|
||
resp = http.get("https://api.example.com/data", headers={"Authorization": "Bearer ..."})
|
||
resp = http.post(url, body='{"key": "val"}', headers={"Content-Type": "application/json"})
|
||
resp = http.put(url, body="...", headers={})
|
||
resp = http.delete(url, headers={})
|
||
resp = http.request("PATCH", url, body="...", headers={})
|
||
```
|
||
|
||
Response dict:
|
||
|
||
```python
|
||
{
|
||
"status": 200,
|
||
"headers": {"content-type": "application/json"},
|
||
"body": "..." # capped at 1 MB
|
||
}
|
||
```
|
||
|
||
#### Batch requests
|
||
|
||
```python
|
||
responses = http.batch([
|
||
{"method": "GET", "url": "https://api.example.com/a"},
|
||
{"method": "POST", "url": "https://api.example.com/b", "body": "{}", "headers": {"Content-Type": "application/json"}},
|
||
])
|
||
# Returns list of response dicts (same shape as individual calls).
|
||
# Individual failures return {"status": 0, "body": "error: ...", "headers": {}}.
|
||
# Max 10 requests per batch. Dispatched concurrently.
|
||
```
|
||
|
||
**Security:**
|
||
- Private/loopback IPs are blocked (SSRF protection).
|
||
- Packages can declare `network_access.allow` (allowlist) or
|
||
`network_access.block` (blocklist) in their manifest.
|
||
- Max 10 redirects. 10-second timeout. 1 MB response body limit.
|
||
|
||
### realtime
|
||
|
||
**Permission:** `realtime.publish`
|
||
|
||
Publish WebSocket events to subscribed clients.
|
||
|
||
```python
|
||
realtime.publish(
|
||
"my-channel", # channel name
|
||
"item.updated", # event label
|
||
{"id": "abc"} # payload dict (max 7 KB)
|
||
)
|
||
```
|
||
|
||
The payload is automatically tagged with `_pkg: package_id`.
|
||
|
||
### connections
|
||
|
||
**Permission:** `connections.read`
|
||
|
||
Read external connection configurations (secrets are decrypted).
|
||
|
||
```python
|
||
conn = connections.get("postgres", "main-db")
|
||
# Returns dict with id, type, name, scope, plus flattened config fields
|
||
# Returns None if not found
|
||
|
||
all_pg = connections.list("postgres")
|
||
# Returns list of connection dicts
|
||
```
|
||
|
||
Connections are resolved via scope chain: personal → team → global.
|
||
|
||
### workflow
|
||
|
||
**Permission:** `workflow.access`
|
||
|
||
Read workflow definitions and instances (read-only from Starlark;
|
||
mutations go through the HTTP API).
|
||
|
||
```python
|
||
defn = workflow.get_definition(workflow_id)
|
||
# Returns dict: id, name, slug, entry_mode, is_active, version, stages[]
|
||
|
||
inst = workflow.get_instance(instance_id)
|
||
# Returns dict: id, workflow_id, current_stage, status, stage_data, ...
|
||
|
||
instances = workflow.list_instances(workflow_id, status="active")
|
||
# Returns list of instance dicts
|
||
```
|
||
|
||
### batch
|
||
|
||
**Permission:** `batch.exec`
|
||
|
||
Run multiple callables concurrently. Each callable gets its own
|
||
execution thread with an independent step budget.
|
||
|
||
```python
|
||
jira = lib.require("jira-client")
|
||
confluence = lib.require("confluence-client")
|
||
|
||
results, errors = batch.exec([
|
||
lambda: jira.create_issue(issue_data),
|
||
lambda: confluence.create_page(page_data),
|
||
lambda: send_notification(user_id),
|
||
], timeout=15)
|
||
|
||
# results[i] = return value of callables[i], or None on error
|
||
# errors[i] = None on success, or error string on failure
|
||
# All three ran concurrently.
|
||
```
|
||
|
||
**Constraints:**
|
||
|
||
- Max 8 callables per call. Dispatched concurrently via goroutines.
|
||
- `timeout` (optional): 1–30 seconds per branch (default 10).
|
||
- `lib.require()` is not available inside branch callables.
|
||
Load libraries before the `batch.exec` call.
|
||
- `batch.exec()` cannot be called from within a branch (no nesting).
|
||
- `print()` output from branches is discarded.
|
||
|
||
---
|
||
|
||
## Example: automated stage hook
|
||
|
||
A simple hook that reads a setting, queries data, and advances:
|
||
|
||
```python
|
||
def on_run(ctx):
|
||
threshold = settings.get("approval_threshold", 1000)
|
||
amount = ctx["stage_data"].get("amount", 0)
|
||
|
||
if amount > threshold:
|
||
notifications.send(
|
||
ctx["started_by"],
|
||
"High-value submission",
|
||
body="Amount %d exceeds threshold." % amount,
|
||
)
|
||
return {"advance": True, "data": {"needs_review": True}}
|
||
|
||
return {"advance": True, "data": {"needs_review": False}}
|
||
```
|