feat: more clear definition and first base implementation for lua

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-23 12:05:12 +02:00
parent 3f5744308c
commit a449778b44
18 changed files with 859 additions and 16 deletions

132
AGENTS.md Normal file
View File

@@ -0,0 +1,132 @@
# Agents Instructions for Elixir rewrite of the Blogging Desktop Server (bDS2)
This document provides context and best practices for GitHub Copilot when working on this blogging application.
## Plan Mode
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
- At the end of each plan, give me a list of unresolved questions to answer, if any.
## Commits
- commit messages are short - one sentence. do not write long articles.
- pull requests are more verbose and especially give reasoning for changes
## Important facts
- published posts don't have body in the database, the body content is only in the file
- functionality you implement have to be tied to UI
- UI you implement has to be tied to functionality
- you must use ecto to generate migrations and snapshots
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
- there are two areas of localization, you sometimes need both (menus for example)
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
- when changing the spec, validate the spec with the available command line tool.
---
## ⚠️ MANDATORY: Test-First Development
**STOP!** Before writing ANY implementation code, you MUST:
1. **Write a failing test first** that describes the expected behavior
2. **Run the test** to confirm it fails (Red)
3. **Write minimal code** to make the test pass (Green)
4. **Refactor** while keeping tests green
> **No code without tests. No exceptions.**
>
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
> Mock only external dependencies (database, filesystem), never the class under test.
---
## ⚠️ MANDATORY: Fix All Test Failures
**You MUST investigate and fix ALL test failures before completing any task.**
- Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work
- Run the full test suite (`npm test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.**
---
## ⚠️ MANDATORY: Remove Unused Code
**Never keep unused code around. Always delete it completely.**
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
- Do NOT comment out code "for later" - use version control history
- Do NOT skip tests for removed functionality - delete them
- Do NOT leave dead code paths, unused imports, or orphaned functions
- When refactoring, actively look for and remove any code that becomes unused
> **Delete unused code immediately. No exceptions.**
---
## ⚠️ MANDATORY: Build Verification After Code Changes
**You MUST run the full build after making code changes.**
- Run the build after any code modifications
- Fix ALL build errors before considering the task complete
- Build errors indicate issues
- The build must complete successfully before the task is complete
> **Successful build required. No exceptions.**
---
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
- Preview HTML must reference only local/package-bundled assets
- Generated HTML must not include CDN-hosted JS/CSS libraries
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
---
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
**All user-facing text MUST follow proper i18n patterns.**
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
- Store UI copy in language resources and resolve text through i18n helpers/hooks
- UI language MUST come from the operating system locale
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
- Keep i18n usage consistent in both renderer UI and render/preview output
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
- English fallback is allowed only when the requested locale is unsupported by available locale files
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
> **No hardcoded user-facing text. No exceptions.**
---
## ⚠️ MANDATORY: Keep API Bindings and API Docs in Sync
**Whenever any app API is added, removed, or changed, you MUST update the script API bridge and API documentation in the same change set.**
- Update the API contract/bindings used by embedded scripts
- Regenerate and commit `API.md`
- Ensure every API entry documents:
- Parameter names, types, and required/optional status
- Return type/response specification
- At least one sample script call
- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place
- Keep docs sync tests passing (documentation and generator output must match)
> **No API contract drift between app APIs, script bindings, and API.md. No exceptions.**

View File

@@ -30,7 +30,9 @@ The reason is host fit, not language fashion: Lua has a better embedding story f
- Lua script files as the persisted user script format.
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
- Bounded script execution for user-authored code.
- Bounded but long-running script execution for user-authored code, with explicit progress reporting through host APIs.
The initial runtime baseline in this repository uses a dedicated Elixir scripting boundary with a Luerl-backed Lua adapter. The goal is to keep scripting integration native to the BEAM while making sandboxing and host capability exposure explicit at the application boundary.
This keeps the scripting surface lightweight and aligned with the Elixir host application. Python remains a possible integration boundary for specialized tasks, but it is no longer the default scripting model for the rewrite.

View File

@@ -12,6 +12,13 @@ config :bds, BDS.Repo,
config :bds, BDS.Application,
desktop_adapter: :pending_selection
config :bds, :scripting,
runtime: BDS.Scripting.Lua,
timeout: 300_000,
max_reductions: 5_000_000,
job_timeout: :infinity,
job_max_reductions: :none
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]

View File

@@ -6,7 +6,10 @@ defmodule BDS.Application do
@impl true
def start(_type, _args) do
children = [
BDS.Repo
BDS.Repo,
BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor
]
opts = [strategy: :one_for_one, name: BDS.Supervisor]

108
lib/bds/scripting.ex Normal file
View File

@@ -0,0 +1,108 @@
defmodule BDS.Scripting do
@moduledoc """
Facade for the configured user-script runtime.
"""
alias BDS.Scripting.Runtime
@type job_status :: :queued | :running | :completed | :failed | :cancelled
@type job_snapshot :: %{
id: String.t(),
status: job_status(),
progress: map(),
result: term() | nil,
error: term() | nil,
inserted_at: DateTime.t(),
started_at: DateTime.t() | nil,
finished_at: DateTime.t() | nil
}
@spec runtime() :: module()
def runtime do
Application.fetch_env!(:bds, :scripting)
|> Keyword.fetch!(:runtime)
end
@spec validate(String.t()) :: :ok | {:error, term()}
def validate(source) when is_binary(source) do
runtime().validate(source)
end
@spec execute(String.t(), String.t(), [term()], [Runtime.execution_option()]) ::
{:ok, term()} | {:error, term()}
def execute(source, entrypoint, args \\ [], opts \\ [])
when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do
runtime().execute(source, entrypoint, args, opts)
end
@spec start_job(String.t(), String.t(), [term()], [Runtime.execution_option()]) ::
{:ok, job_snapshot()} | {:error, term()}
def start_job(source, entrypoint, args \\ [], opts \\ [])
when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do
job_id = "script-job-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
job = %{
id: job_id,
status: :queued,
progress: %{},
result: nil,
error: nil,
inserted_at: DateTime.utc_now(),
started_at: nil,
finished_at: nil
}
:ok = BDS.Scripting.JobStore.put_job(job)
child_spec =
{BDS.Scripting.JobRunner,
job_id: job_id,
runtime: runtime(),
source: source,
entrypoint: entrypoint,
args: args,
opts: batch_job_defaults(opts)}
case DynamicSupervisor.start_child(BDS.Scripting.JobSupervisor, child_spec) do
{:ok, _pid} -> {:ok, BDS.Scripting.JobStore.fetch_job!(job_id)}
{:error, reason} ->
:ok =
BDS.Scripting.JobStore.update_job(job_id, %{
status: :failed,
error: reason,
finished_at: DateTime.utc_now()
})
{:error, reason}
end
end
@spec get_job(String.t()) :: job_snapshot() | nil
def get_job(job_id) when is_binary(job_id) do
BDS.Scripting.JobStore.fetch_job(job_id)
end
@spec cancel_job(String.t()) :: :ok | {:error, :not_found | :not_running}
def cancel_job(job_id) when is_binary(job_id) do
case BDS.Scripting.JobStore.runner_for(job_id) do
nil ->
case BDS.Scripting.JobStore.fetch_job(job_id) do
nil -> {:error, :not_found}
_job -> {:error, :not_running}
end
pid -> BDS.Scripting.JobRunner.cancel(pid)
end
end
defp batch_job_defaults(opts) do
config = Application.fetch_env!(:bds, :scripting)
defaults = [
timeout: Keyword.get(config, :job_timeout, :infinity),
max_reductions: Keyword.get(config, :job_max_reductions, :none)
]
Keyword.merge(defaults, opts)
end
end

View File

@@ -0,0 +1,121 @@
defmodule BDS.Scripting.JobRunner do
@moduledoc false
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
def cancel(pid) when is_pid(pid) do
GenServer.call(pid, :cancel)
end
@impl true
def init(opts) do
state = %{
job_id: Keyword.fetch!(opts, :job_id),
runtime: Keyword.fetch!(opts, :runtime),
source: Keyword.fetch!(opts, :source),
entrypoint: Keyword.fetch!(opts, :entrypoint),
args: Keyword.get(opts, :args, []),
opts: Keyword.get(opts, :opts, []),
task_pid: nil,
task_ref: nil,
completed?: false,
cancelled?: false
}
Process.flag(:trap_exit, true)
:ok = BDS.Scripting.JobStore.attach_runner(state.job_id, self())
{:ok, state, {:continue, :start_job}}
end
@impl true
def handle_continue(:start_job, state) do
:ok =
BDS.Scripting.JobStore.update_job(state.job_id, %{
status: :running,
started_at: DateTime.utc_now()
})
runner = self()
task =
Task.Supervisor.async_nolink(BDS.Scripting.TaskSupervisor, fn ->
state.runtime.execute(
state.source,
state.entrypoint,
state.args,
Keyword.put(state.opts, :on_progress, fn event ->
send(runner, {:job_progress, event})
end)
)
end)
{:noreply, %{state | task_pid: task.pid, task_ref: task.ref}}
end
@impl true
def handle_call(:cancel, _from, state) do
if is_pid(state.task_pid) do
Process.exit(state.task_pid, :kill)
end
:ok =
BDS.Scripting.JobStore.update_job(state.job_id, %{
status: :cancelled,
finished_at: DateTime.utc_now()
})
:ok = BDS.Scripting.JobStore.detach_runner(state.job_id)
{:stop, :normal, :ok, %{state | cancelled?: true}}
end
@impl true
def handle_info({:job_progress, progress}, state) do
:ok = BDS.Scripting.JobStore.update_job(state.job_id, %{progress: progress})
{:noreply, state}
end
def handle_info({ref, result}, %{task_ref: ref} = state) do
Process.demonitor(ref, [:flush])
unless state.cancelled? do
attrs =
case result do
{:ok, value} ->
%{status: :completed, result: value, finished_at: DateTime.utc_now()}
{:error, reason} ->
%{status: :failed, error: reason, finished_at: DateTime.utc_now()}
end
:ok = BDS.Scripting.JobStore.update_job(state.job_id, attrs)
:ok = BDS.Scripting.JobStore.detach_runner(state.job_id)
end
{:stop, :normal, %{state | completed?: true}}
end
def handle_info({:DOWN, ref, :process, _pid, reason}, %{task_ref: ref} = state) do
cond do
state.completed? or state.cancelled? ->
{:stop, :normal, state}
reason == :normal ->
{:noreply, state}
true ->
:ok =
BDS.Scripting.JobStore.update_job(state.job_id, %{
status: :failed,
error: reason,
finished_at: DateTime.utc_now()
})
:ok = BDS.Scripting.JobStore.detach_runner(state.job_id)
{:stop, :normal, state}
end
end
end

View File

@@ -0,0 +1,78 @@
defmodule BDS.Scripting.JobStore do
@moduledoc false
use GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def put_job(job) when is_map(job) do
GenServer.call(__MODULE__, {:put_job, job})
end
def update_job(job_id, attrs) when is_binary(job_id) and is_map(attrs) do
GenServer.call(__MODULE__, {:update_job, job_id, attrs})
end
def attach_runner(job_id, pid) when is_binary(job_id) and is_pid(pid) do
GenServer.call(__MODULE__, {:attach_runner, job_id, pid})
end
def detach_runner(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:detach_runner, job_id})
end
def fetch_job(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:fetch_job, job_id})
end
def fetch_job!(job_id) when is_binary(job_id) do
case fetch_job(job_id) do
nil -> raise KeyError, key: job_id, term: :jobs
job -> job
end
end
def runner_for(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:runner_for, job_id})
end
@impl true
def init(_state) do
{:ok, %{jobs: %{}, runners: %{}}}
end
@impl true
def handle_call({:put_job, %{id: job_id} = job}, _from, state) do
next_state = put_in(state, [:jobs, job_id], job)
{:reply, :ok, next_state}
end
def handle_call({:update_job, job_id, attrs}, _from, state) do
next_state = update_in(state, [:jobs, job_id], fn
nil -> nil
job -> Map.merge(job, attrs)
end)
{:reply, :ok, next_state}
end
def handle_call({:attach_runner, job_id, pid}, _from, state) do
next_state = put_in(state, [:runners, job_id], pid)
{:reply, :ok, next_state}
end
def handle_call({:detach_runner, job_id}, _from, state) do
next_state = update_in(state.runners, &Map.delete(&1, job_id))
{:reply, :ok, %{state | runners: next_state}}
end
def handle_call({:fetch_job, job_id}, _from, state) do
{:reply, Map.get(state.jobs, job_id), state}
end
def handle_call({:runner_for, job_id}, _from, state) do
{:reply, Map.get(state.runners, job_id), state}
end
end

View File

@@ -0,0 +1,14 @@
defmodule BDS.Scripting.JobSupervisor do
@moduledoc false
use DynamicSupervisor
def start_link(_opts) do
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end

134
lib/bds/scripting/lua.ex Normal file
View File

@@ -0,0 +1,134 @@
defmodule BDS.Scripting.Lua do
@moduledoc """
Lua runtime adapter backed by Luerl.
Execution starts from a sandboxed Lua state. Host capabilities are explicit
and opt-in.
"""
@behaviour BDS.Scripting.Runtime
@impl true
def validate(source) when is_binary(source) do
case :luerl.load(source, :luerl_sandbox.init()) do
{:ok, _chunk, _state} ->
:ok
{:error, errors, warnings} ->
{:error, {:compile_error, %{errors: errors, warnings: warnings}}}
{:lua_error, error, _state} ->
{:error, {:lua_error, error}}
end
end
@impl true
def execute(source, entrypoint, args, opts)
when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do
with {:ok, state} <- initial_state(opts),
{:ok, state} <- put_args(state, args),
{:ok, result, _state} <- run_entrypoint(source, entrypoint, state, opts) do
{:ok, unwrap_result(result)}
end
end
defp initial_state(opts) do
state = :luerl_sandbox.init()
capabilities = Keyword.get(opts, :capabilities, %{})
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
{:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)),
{:ok, state} <- install_capabilities(state, capabilities) do
{:ok, state}
end
end
defp install_progress_callback(state, nil), do: {:ok, state}
defp install_progress_callback(state, callback) when is_function(callback, 1) do
progress_function = fn args, current_state ->
decoded_args = :luerl.decode_list(args, current_state)
progress_event =
case decoded_args do
[payload | _] when is_map(payload) -> payload
[payload | _] -> normalize_progress_payload(payload)
[] -> %{}
end
callback.(progress_event)
:luerl.encode_list([true], current_state)
end
case :luerl.set_table_keys_dec(["bds", "report_progress"], progress_function, state) do
{:ok, next_state} -> {:ok, next_state}
error -> {:error, {:progress_callback_install_failed, error}}
end
end
defp install_progress_callback(_state, callback), do: {:error, {:invalid_progress_callback, callback}}
defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state}
defp install_capabilities(state, capabilities) when is_map(capabilities) do
Enum.reduce_while(capabilities, {:ok, state}, fn {name, function}, {:ok, current_state} ->
path = ["bds", to_string(name)]
case :luerl.set_table_keys_dec(path, function, current_state) do
{:ok, next_state} -> {:cont, {:ok, next_state}}
error -> {:halt, {:error, {:capability_install_failed, path, error}}}
end
end)
end
defp install_capabilities(_state, capabilities), do: {:error, {:invalid_capabilities, capabilities}}
defp normalize_progress_payload(payload) when is_list(payload) do
if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do
Map.new(payload, fn {key, value} -> {to_string(key), value} end)
else
%{value: payload}
end
end
defp normalize_progress_payload(payload), do: %{value: payload}
defp put_args(state, args) do
case Luerl.set_table_keys_dec(state, ["__bds_args__"], args) do
{:ok, next_state} -> {:ok, next_state}
error -> {:error, {:argument_encoding_failed, error}}
end
end
defp run_entrypoint(source, entrypoint, state, opts) do
script =
IO.iodata_to_binary([
source,
"\nreturn ",
entrypoint,
"(table.unpack(__bds_args__))\n"
])
case :luerl_sandbox.run(script, sandbox_flags(opts), state) do
{:ok, result, next_state} -> {:ok, result, next_state}
{:lua_error, error, _state} -> {:error, {:lua_error, error}}
{:error, {:reductions, count}} -> {:error, {:reductions_exceeded, count}}
{:error, :timeout} -> {:error, :timeout}
{:error, reason} -> {:error, reason}
end
end
defp sandbox_flags(opts) do
config = Application.fetch_env!(:bds, :scripting)
%{
max_time: Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)),
max_reductions: Keyword.get(opts, :max_reductions, Keyword.fetch!(config, :max_reductions)),
spawn_opts: Keyword.get(opts, :spawn_opts, [])
}
end
defp unwrap_result([]), do: nil
defp unwrap_result([value]), do: value
defp unwrap_result(values), do: values
end

View File

@@ -0,0 +1,24 @@
defmodule BDS.Scripting.Runtime do
@moduledoc """
Behaviour for user-script runtimes hosted by bDS.
The runtime boundary is intentionally narrow: syntax validation and
bounded entrypoint execution.
"""
@type source :: String.t()
@type entrypoint :: String.t()
@type args :: [term()]
@type progress_event :: map()
@type progress_callback :: (progress_event() -> any())
@type execution_option ::
{:timeout, non_neg_integer() | :infinity}
| {:max_reductions, pos_integer() | :none}
| {:spawn_opts, [term()]}
| {:on_progress, progress_callback()}
| {:capabilities, map()}
@callback validate(source()) :: :ok | {:error, term()}
@callback execute(source(), entrypoint(), args(), [execution_option()]) ::
{:ok, term()} | {:error, term()}
end

View File

@@ -22,7 +22,8 @@ defmodule BDS.MixProject do
defp deps do
[
{:ecto_sql, "~> 3.13"},
{:ecto_sqlite3, "~> 0.21"}
{:ecto_sqlite3, "~> 0.21"},
{:luerl, "~> 1.5"}
]
end

View File

@@ -7,5 +7,6 @@
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
}

View File

@@ -202,7 +202,7 @@ value ScriptFrontmatter {
slug: String
title: String
kind: macro | utility | transform
entrypoint: String -- Named Lua function used when invoking the script
entrypoint: String -- Default: "render" for macros, "main" otherwise
enabled: Boolean
version: Integer
created_at: Timestamp

View File

@@ -151,7 +151,7 @@ entity Script {
slug: String -- URL-safe identifier
title: String
kind: macro | utility | transform
entrypoint: String -- Default: "render" for macros
entrypoint: String -- Default: "render" for macros, "main" otherwise
enabled: Boolean
version: Integer -- Incremented on each update
file_path: String -- scripts/{slug}.{extension}

View File

@@ -8,6 +8,10 @@
config {
script_extension: String = "lua"
macro_timeout: Duration = 10.seconds
transform_max_toasts_per_script: Integer = 5
transform_max_toasts_total: Integer = 20
transform_max_toast_length: Integer = 300
}
enum ScriptStatus {
@@ -70,6 +74,44 @@ surface ScriptManagementSurface {
RebuildScriptsFromFilesRequested(project)
}
surface ScriptRuntimeSurface {
facing _: ScriptRuntime
provides:
ValidateScript(source)
ExecuteScriptRequested(script, entrypoint, args, progress_sink)
@guarantee SandboxedExecution
-- User-authored Lua executes from a sandboxed runtime state.
-- Filesystem mutation, process control, package loading, and other
-- unrestricted host capabilities are unavailable unless explicitly
-- re-exposed by the host application.
@guarantee ExplicitHostCapabilities
-- Host-provided functions are exposed only through an explicit bds.*
-- capability table, never through ambient global access.
@guarantee MacroTimeout
-- Macro execution has a short timeout budget of config.macro_timeout.
@guarantee ManagedBatchExecution
-- Utility and transform scripts execute as managed jobs.
-- The contract does not define a fixed wall-clock limit for those
-- jobs because batch work can legitimately scale with project size.
-- Progress reporting, operator cancellation, and host orchestration
-- govern their lifecycle instead of a fixed timeout.
@guarantee ProgressFeedback
-- Long-running utility and transform scripts may emit progress updates
-- through explicit host APIs during execution.
-- Progress reporting is cooperative and flows through the supplied
-- progress sink rather than ambient global side effects.
@guarantee BatchCancellation
-- Managed utility and transform jobs can be cancelled by the host
-- operator boundary.
}
invariant UniqueScriptSlug {
for a in Scripts:
for b in Scripts:
@@ -92,7 +134,7 @@ rule CreateScript {
title: title,
kind: kind,
content: content,
entrypoint: entrypoint ?? "render",
entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
status: draft,
enabled: true,
version: 1,
@@ -127,7 +169,7 @@ rule CreateAndPublishScript {
title: title,
kind: kind,
content: null,
entrypoint: entrypoint ?? "render",
entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
status: published,
enabled: true,
version: 1,
@@ -158,11 +200,17 @@ rule ExecuteMacro {
when: MacroExpansionRequested(script, template_context)
requires: script.kind = macro
requires: script.enabled = true
requires: script.entrypoint != ""
-- Macro scripts are invoked during template rendering
-- via [[slug param1=value1 param2=value2]] syntax in post content
-- They receive named parameters and the template context, return HTML
-- from a bounded Lua execution environment that exposes only approved
-- host capabilities
-- Unknown macro names are resolved against enabled macro scripts by slug.
-- They receive named parameters plus template_context.env fields that
-- include isPreview, mainLanguage, languagePrefix, hook, source.kind,
-- and translations.
-- They return HTML and run sequentially with config.macro_timeout per
-- invocation.
-- Macro failures degrade to empty output for that invocation and do not
-- abort rendering of the surrounding page.
ensures: MacroOutputProduced(script, html_output)
}
@@ -170,8 +218,11 @@ rule ExecuteUtility {
when: RunUtilityRequested(script)
requires: script.kind = utility
requires: script.enabled = true
-- Runs on-demand from the UI in a bounded Lua execution environment,
-- produces stdout output
requires: script.entrypoint != ""
-- Utility scripts commonly perform long-running data manipulation work.
-- They are manually started by an operator action, run as managed jobs,
-- may issue host-backed API calls, may emit progress during execution,
-- and may be cancelled by the operator.
ensures: UtilityOutputProduced(script, stdout)
}
@@ -180,14 +231,35 @@ rule ExecuteTransform {
-- Transform scripts run sequentially on blogmark deep link data
-- Input: title, content, tags, categories, source url
-- Each transform can modify the data before post creation.
-- Execution uses the same bounded Lua host API contract as other scripts.
-- Execution uses the same managed job host API contract as other batch
-- scripts and may report progress while mass-processing remote or local
-- content.
let transforms = Scripts where kind = transform and enabled = true
for t in ordered_by(transforms, s => s.slug):
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
requires: t.entrypoint != ""
ensures: TransformApplied(t, data)
@guarantee TransformTrigger
-- Transform scripts are triggered automatically by blogmark import.
-- Each script receives the current post candidate plus a context with
-- source='blogmark' and the originating URL.
@guarantee TransformPipelineContinuation
-- Transform errors are captured per script and do not roll back the
-- last valid post state produced by earlier transforms.
-- The pipeline continues with subsequent enabled transforms.
@guarantee TransformToastBudget
-- Transform scripts may emit toast feedback.
-- At most config.transform_max_toasts_per_script toasts are accepted
-- from any one transform, with a total budget of
-- config.transform_max_toasts_total across the pipeline.
-- Individual toast messages are truncated to
-- config.transform_max_toast_length characters.
@guidance
-- bds://new-post deep links from browser bookmarks
-- Max 5 toast notifications per script, 20 total
-- Ordering is deterministic: updated_at, then slug, then id
}
rule RebuildScriptsFromFiles {

View File

@@ -338,7 +338,7 @@ surface ScriptListItemEntry {
@guarantee CreateDefaults
-- New scripts default to: kind=utility, content='print("new script")',
-- entrypoint='render', enabled=true.
-- entrypoint='main', enabled=true.
@guarantee LiveRefresh
-- Item list refreshes on scripts-changed event.

View File

@@ -0,0 +1,103 @@
defmodule BDS.Scripting.JobTest do
use ExUnit.Case, async: false
defmodule FakeRuntime do
@behaviour BDS.Scripting.Runtime
@impl true
def validate(_source), do: :ok
@impl true
def execute(_source, _entrypoint, _args, opts) do
if callback = Keyword.get(opts, :on_progress) do
callback.(%{"phase" => "started", "current" => 1, "total" => 2})
end
Process.sleep(50)
{:ok, "done"}
end
end
defmodule BlockingRuntime do
@behaviour BDS.Scripting.Runtime
@impl true
def validate(_source), do: :ok
@impl true
def execute(_source, _entrypoint, _args, opts) do
if callback = Keyword.get(opts, :on_progress) do
callback.(%{"phase" => "started", "current" => 1, "total" => 2})
end
receive do
:never -> :ok
end
end
end
setup do
original = Application.fetch_env!(:bds, :scripting)
on_exit(fn ->
Application.put_env(:bds, :scripting, original)
end)
:ok
end
test "runs long-lived script jobs asynchronously and tracks progress" do
Application.put_env(:bds, :scripting,
runtime: FakeRuntime,
timeout: 300_000,
max_reductions: 5_000_000,
job_timeout: :infinity,
job_max_reductions: :none
)
assert {:ok, job} = BDS.Scripting.start_job("irrelevant", "main")
assert job.status in [:queued, :running]
running_job = wait_for_job(job.id, &(&1.status == :running and &1.progress == %{"phase" => "started", "current" => 1, "total" => 2}))
assert running_job.started_at != nil
completed_job = wait_for_job(job.id, &(&1.status == :completed))
assert completed_job.result == "done"
assert completed_job.finished_at != nil
end
test "cancels managed script jobs" do
Application.put_env(:bds, :scripting,
runtime: BlockingRuntime,
timeout: 300_000,
max_reductions: 5_000_000,
job_timeout: :infinity,
job_max_reductions: :none
)
assert {:ok, job} = BDS.Scripting.start_job("irrelevant", "main")
_running_job = wait_for_job(job.id, &(&1.status == :running))
assert :ok = BDS.Scripting.cancel_job(job.id)
cancelled_job = wait_for_job(job.id, &(&1.status == :cancelled))
assert cancelled_job.finished_at != nil
end
defp wait_for_job(job_id, predicate, attempts \\ 50)
defp wait_for_job(job_id, predicate, attempts) when attempts > 0 do
job = BDS.Scripting.get_job(job_id)
if predicate.(job) do
job
else
Process.sleep(20)
wait_for_job(job_id, predicate, attempts - 1)
end
end
defp wait_for_job(_job_id, _predicate, 0) do
flunk("job did not reach expected state")
end
end

View File

@@ -0,0 +1,43 @@
defmodule BDS.Scripting.LuaTest do
use ExUnit.Case, async: true
test "validates Lua source" do
assert :ok = BDS.Scripting.validate("function main() return 42 end")
end
test "rejects invalid Lua source" do
assert {:error, {:compile_error, _details}} = BDS.Scripting.validate("function main(")
end
test "executes the configured entrypoint in a sandboxed Lua runtime" do
source = "function main(a, b) return a + b end"
assert {:ok, 42} = BDS.Scripting.execute(source, "main", [19, 23])
end
test "exposes progress reporting through the host boundary" do
parent = self()
source = """
function main()
bds.report_progress({phase = 'fetch', current = 1, total = 2})
bds.report_progress({phase = 'write', current = 2, total = 2})
return 'done'
end
"""
callback = fn progress -> send(parent, {:progress, progress}) end
assert {:ok, "done"} = BDS.Scripting.execute(source, "main", [], on_progress: callback)
assert_receive {:progress, %{"phase" => "fetch", "current" => 1, "total" => 2}}
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
end
test "enforces reduction limits" do
source = "function main() while true do end end"
assert {:error, {:reductions_exceeded, _count}} =
BDS.Scripting.execute(source, "main", [], timeout: 1_000, max_reductions: 100)
end
end