feat: more clear definition and first base implementation for lua
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
132
AGENTS.md
Normal file
132
AGENTS.md
Normal 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.**
|
||||||
|
|
||||||
@@ -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.
|
- Lua script files as the persisted user script format.
|
||||||
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ config :bds, BDS.Repo,
|
|||||||
config :bds, BDS.Application,
|
config :bds, BDS.Application,
|
||||||
desktop_adapter: :pending_selection
|
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,
|
config :logger, :console,
|
||||||
format: "$time $metadata[$level] $message\n",
|
format: "$time $metadata[$level] $message\n",
|
||||||
metadata: [:request_id]
|
metadata: [:request_id]
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ defmodule BDS.Application do
|
|||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
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]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
|
|||||||
108
lib/bds/scripting.ex
Normal file
108
lib/bds/scripting.ex
Normal 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
|
||||||
121
lib/bds/scripting/job_runner.ex
Normal file
121
lib/bds/scripting/job_runner.ex
Normal 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
|
||||||
78
lib/bds/scripting/job_store.ex
Normal file
78
lib/bds/scripting/job_store.ex
Normal 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
|
||||||
14
lib/bds/scripting/job_supervisor.ex
Normal file
14
lib/bds/scripting/job_supervisor.ex
Normal 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
134
lib/bds/scripting/lua.ex
Normal 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
|
||||||
24
lib/bds/scripting/runtime.ex
Normal file
24
lib/bds/scripting/runtime.ex
Normal 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
|
||||||
3
mix.exs
3
mix.exs
@@ -22,7 +22,8 @@ defmodule BDS.MixProject do
|
|||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:ecto_sql, "~> 3.13"},
|
{:ecto_sql, "~> 3.13"},
|
||||||
{:ecto_sqlite3, "~> 0.21"}
|
{:ecto_sqlite3, "~> 0.21"},
|
||||||
|
{:luerl, "~> 1.5"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
1
mix.lock
1
mix.lock
@@ -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"},
|
"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"},
|
"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"},
|
"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"},
|
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ value ScriptFrontmatter {
|
|||||||
slug: String
|
slug: String
|
||||||
title: String
|
title: String
|
||||||
kind: macro | utility | transform
|
kind: macro | utility | transform
|
||||||
entrypoint: String -- Named Lua function used when invoking the script
|
entrypoint: String -- Default: "render" for macros, "main" otherwise
|
||||||
enabled: Boolean
|
enabled: Boolean
|
||||||
version: Integer
|
version: Integer
|
||||||
created_at: Timestamp
|
created_at: Timestamp
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ entity Script {
|
|||||||
slug: String -- URL-safe identifier
|
slug: String -- URL-safe identifier
|
||||||
title: String
|
title: String
|
||||||
kind: macro | utility | transform
|
kind: macro | utility | transform
|
||||||
entrypoint: String -- Default: "render" for macros
|
entrypoint: String -- Default: "render" for macros, "main" otherwise
|
||||||
enabled: Boolean
|
enabled: Boolean
|
||||||
version: Integer -- Incremented on each update
|
version: Integer -- Incremented on each update
|
||||||
file_path: String -- scripts/{slug}.{extension}
|
file_path: String -- scripts/{slug}.{extension}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
config {
|
config {
|
||||||
script_extension: String = "lua"
|
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 {
|
enum ScriptStatus {
|
||||||
@@ -70,6 +74,44 @@ surface ScriptManagementSurface {
|
|||||||
RebuildScriptsFromFilesRequested(project)
|
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 {
|
invariant UniqueScriptSlug {
|
||||||
for a in Scripts:
|
for a in Scripts:
|
||||||
for b in Scripts:
|
for b in Scripts:
|
||||||
@@ -92,7 +134,7 @@ rule CreateScript {
|
|||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
content: content,
|
content: content,
|
||||||
entrypoint: entrypoint ?? "render",
|
entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
|
||||||
status: draft,
|
status: draft,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -127,7 +169,7 @@ rule CreateAndPublishScript {
|
|||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
content: null,
|
content: null,
|
||||||
entrypoint: entrypoint ?? "render",
|
entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
|
||||||
status: published,
|
status: published,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -158,11 +200,17 @@ rule ExecuteMacro {
|
|||||||
when: MacroExpansionRequested(script, template_context)
|
when: MacroExpansionRequested(script, template_context)
|
||||||
requires: script.kind = macro
|
requires: script.kind = macro
|
||||||
requires: script.enabled = true
|
requires: script.enabled = true
|
||||||
|
requires: script.entrypoint != ""
|
||||||
-- Macro scripts are invoked during template rendering
|
-- Macro scripts are invoked during template rendering
|
||||||
-- via [[slug param1=value1 param2=value2]] syntax in post content
|
-- via [[slug param1=value1 param2=value2]] syntax in post content
|
||||||
-- They receive named parameters and the template context, return HTML
|
-- Unknown macro names are resolved against enabled macro scripts by slug.
|
||||||
-- from a bounded Lua execution environment that exposes only approved
|
-- They receive named parameters plus template_context.env fields that
|
||||||
-- host capabilities
|
-- 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)
|
ensures: MacroOutputProduced(script, html_output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,8 +218,11 @@ rule ExecuteUtility {
|
|||||||
when: RunUtilityRequested(script)
|
when: RunUtilityRequested(script)
|
||||||
requires: script.kind = utility
|
requires: script.kind = utility
|
||||||
requires: script.enabled = true
|
requires: script.enabled = true
|
||||||
-- Runs on-demand from the UI in a bounded Lua execution environment,
|
requires: script.entrypoint != ""
|
||||||
-- produces stdout output
|
-- 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)
|
ensures: UtilityOutputProduced(script, stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +231,35 @@ rule ExecuteTransform {
|
|||||||
-- Transform scripts run sequentially on blogmark deep link data
|
-- Transform scripts run sequentially on blogmark deep link data
|
||||||
-- Input: title, content, tags, categories, source url
|
-- Input: title, content, tags, categories, source url
|
||||||
-- Each transform can modify the data before post creation.
|
-- 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
|
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)
|
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
|
@guidance
|
||||||
-- bds://new-post deep links from browser bookmarks
|
-- 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 {
|
rule RebuildScriptsFromFiles {
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ surface ScriptListItemEntry {
|
|||||||
|
|
||||||
@guarantee CreateDefaults
|
@guarantee CreateDefaults
|
||||||
-- New scripts default to: kind=utility, content='print("new script")',
|
-- New scripts default to: kind=utility, content='print("new script")',
|
||||||
-- entrypoint='render', enabled=true.
|
-- entrypoint='main', enabled=true.
|
||||||
|
|
||||||
@guarantee LiveRefresh
|
@guarantee LiveRefresh
|
||||||
-- Item list refreshes on scripts-changed event.
|
-- Item list refreshes on scripts-changed event.
|
||||||
|
|||||||
103
test/bds/scripting/job_test.exs
Normal file
103
test/bds/scripting/job_test.exs
Normal 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
|
||||||
43
test/bds/scripting/lua_test.exs
Normal file
43
test/bds/scripting/lua_test.exs
Normal 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
|
||||||
Reference in New Issue
Block a user