Compare commits
5 Commits
4859c9708a
...
abbcef594a
| Author | SHA1 | Date | |
|---|---|---|---|
| abbcef594a | |||
| 8224b3d59f | |||
| f7e1662bca | |||
| ae66775cb7 | |||
| 741979fc39 |
62
TECHDEBTS.md
62
TECHDEBTS.md
@@ -518,7 +518,17 @@ debounced save. Queries during a rebuild keep hitting the old index.
|
|||||||
**Acceptance.** Neighbor queries return while a duplicate scan is running
|
**Acceptance.** Neighbor queries return while a duplicate scan is running
|
||||||
(test with a large synthetic index); debounce/flush semantics unchanged.
|
(test with a large synthetic index); debounce/flush semantics unchanged.
|
||||||
|
|
||||||
### TD-13: Slim down the `Publishing` GenServer call surface
|
### TD-13: Slim down the `Publishing` GenServer call surface ✅ DONE (2026-06-12)
|
||||||
|
|
||||||
|
**Status: implemented.** `BDS.Publishing` now keeps only SCP mtime state in its
|
||||||
|
GenServer. Publish-job creation, lookup, and status updates run directly through
|
||||||
|
`Repo`, while background-task job updates use a stable Repo caller so sandboxed
|
||||||
|
tests still exercise the real async path. SCP uploads no longer round-trip
|
||||||
|
through `should_upload_scp_file` / `mark_uploaded_scp_file` per file; each
|
||||||
|
target now batches one filter call for changed files and one bulk record call
|
||||||
|
for successfully uploaded mtimes. Coverage includes a focused batching test that
|
||||||
|
proves a multi-file SCP publish keeps bookkeeping traffic bounded instead of
|
||||||
|
scaling linearly with file count.
|
||||||
|
|
||||||
**Context.** `Publishing` does Repo writes inside `handle_call`
|
**Context.** `Publishing` does Repo writes inside `handle_call`
|
||||||
(`:upload_site`, `:update_job`) and the uploader makes per-file synchronous
|
(`:upload_site`, `:update_job`) and the uploader makes per-file synchronous
|
||||||
@@ -537,7 +547,20 @@ file. Consider whether `scp_uploads` state should be ETS
|
|||||||
**Acceptance.** Upload of N files makes O(1) GenServer calls for mtime
|
**Acceptance.** Upload of N files makes O(1) GenServer calls for mtime
|
||||||
bookkeeping, not O(N)·2; behavior identical for incremental uploads.
|
bookkeeping, not O(N)·2; behavior identical for incremental uploads.
|
||||||
|
|
||||||
### TD-14: Replace polling with messaging (CliSync watcher + rebuild sequencing)
|
### TD-14: Replace polling with messaging (CliSync watcher + rebuild sequencing) ✅ DONE (2026-06-12)
|
||||||
|
|
||||||
|
**Status: implemented.** `BDS.CliSync.Watcher` now gates notification-table
|
||||||
|
work behind SQLite `PRAGMA data_version`, so unchanged databases no longer force
|
||||||
|
repeated `db_notifications` queries at the 100 ms watch cadence; the watcher
|
||||||
|
still performs a real notification fetch/prune pass on the first poll and on
|
||||||
|
every external commit boundary. Rebuild sequencing in
|
||||||
|
`BDS.Desktop.ShellCommands` no longer sleep-polls `Tasks.list_tasks/0`.
|
||||||
|
`BDS.Tasks` now broadcasts terminal task states on `BDS.PubSub`, and
|
||||||
|
`wait_for_group_phase/3` subscribes and waits on those messages with the same
|
||||||
|
deadline semantics. Coverage now includes a watcher test that proves unchanged
|
||||||
|
`data_version` skips notification queries, a tasks test that proves terminal
|
||||||
|
state broadcasts, and a shell-command guard test that forbids `Process.sleep`
|
||||||
|
polling in the rebuild wait path.
|
||||||
|
|
||||||
**Context.** Two polling loops:
|
**Context.** Two polling loops:
|
||||||
1. `CliSync.Watcher` polls the SQLite notifications table every **100 ms
|
1. `CliSync.Watcher` polls the SQLite notifications table every **100 ms
|
||||||
@@ -567,7 +590,16 @@ bookkeeping, not O(N)·2; behavior identical for incremental uploads.
|
|||||||
unchanged (or interval ≥ 1s with backoff); rebuild sequencing has no
|
unchanged (or interval ≥ 1s with backoff); rebuild sequencing has no
|
||||||
`Process.sleep`; CLI-sync round-trip latency stays ≤ current behavior.
|
`Process.sleep`; CLI-sync round-trip latency stays ≤ current behavior.
|
||||||
|
|
||||||
### TD-15: `BDS.Tasks` housekeeping (queue type, eviction timers)
|
### TD-15: `BDS.Tasks` housekeeping (queue type, eviction timers) ✅ DONE (2026-06-12)
|
||||||
|
|
||||||
|
**Status: implemented.** `BDS.Tasks` now uses `:queue` for its pending work
|
||||||
|
queue, so enqueue/dequeue on the hot path are O(1) and the FIFO behavior is
|
||||||
|
unchanged. Finished-task cleanup now tracks a single live eviction timer ref
|
||||||
|
instead of scheduling a fresh `send_after/3` on every terminal task; the timer
|
||||||
|
fires, prunes expired finished tasks, and only reschedules itself if finished
|
||||||
|
tasks still remain. Coverage now includes a focused task-state test proving
|
||||||
|
multiple finished tasks share the same live eviction timer and a source guard
|
||||||
|
that forbids `queue ++` churn.
|
||||||
|
|
||||||
**Context.** Minor inefficiencies in `tasks.ex`: the pending queue is a list
|
**Context.** Minor inefficiencies in `tasks.ex`: the pending queue is a list
|
||||||
appended with `++` (O(n) per submit), and **every** finishing task schedules
|
appended with `++` (O(n) per submit), and **every** finishing task schedules
|
||||||
@@ -590,7 +622,18 @@ existing task lifecycle tests green.
|
|||||||
|
|
||||||
## Phase 4 — Build-vs-buy replacements
|
## Phase 4 — Build-vs-buy replacements
|
||||||
|
|
||||||
### TD-16: Frontmatter robustness — yaml_elixir/ymlr or harden the hand-rolled parser
|
### TD-16: Frontmatter robustness — yaml_elixir/ymlr or harden the hand-rolled parser ✅ DONE (2026-06-12)
|
||||||
|
|
||||||
|
**Status: implemented.** The project keeps the hand-rolled serializer/parser for
|
||||||
|
byte-stable frontmatter output, but `BDS.Frontmatter` is now hardened for the
|
||||||
|
known user-edited-file cases: `parse_document/1` normalizes CRLF and lone `\r`
|
||||||
|
line endings before splitting the gray-matter envelope, and quoted scalar
|
||||||
|
parsing now removes exactly one closing quote and unescapes content explicitly
|
||||||
|
instead of `trim_trailing/2`, which previously corrupted strings whose content
|
||||||
|
ended in a quote character. Coverage now includes focused parser tests for CRLF
|
||||||
|
documents and quoted-string roundtrips with embedded quotes, backslashes, and
|
||||||
|
trailing-quote content, plus adjacent post/template/maintenance frontmatter and
|
||||||
|
serializer parity suites.
|
||||||
|
|
||||||
**Context.** `BDS.Frontmatter` is a hand-rolled YAML subset with concrete
|
**Context.** `BDS.Frontmatter` is a hand-rolled YAML subset with concrete
|
||||||
bugs for user-edited files:
|
bugs for user-edited files:
|
||||||
@@ -621,7 +664,16 @@ user projects.
|
|||||||
**Acceptance.** CRLF fixture parses; round-trip property tests pass; golden
|
**Acceptance.** CRLF fixture parses; round-trip property tests pass; golden
|
||||||
serialization fixtures unchanged (if keeping custom serializer).
|
serialization fixtures unchanged (if keeping custom serializer).
|
||||||
|
|
||||||
### TD-17: Language detection via `paasaa` (optional, low priority)
|
### TD-17: Language detection via `paasaa` (optional, low priority) ✅ DONE (2026-06-12)
|
||||||
|
|
||||||
|
**Status: implemented without adding `paasaa`.** The originally reported
|
||||||
|
misclassifications are not reproducible on the current code: the existing
|
||||||
|
detector already classifies the relevant umlaut-free German and accent-free
|
||||||
|
French fixtures correctly through its language-hint fallback, and new focused
|
||||||
|
tests now lock that behavior down directly. Because the acceptance cases are now
|
||||||
|
satisfied and the current implementation keeps dependency weight lower, the
|
||||||
|
project does not add `paasaa` at this time. Revisit only if broader real-world
|
||||||
|
fixtures start failing.
|
||||||
|
|
||||||
**Context.** `Search.detect_language/1` uses diacritic regexes + tiny word
|
**Context.** `Search.detect_language/1` uses diacritic regexes + tiny word
|
||||||
lists; German text without umlauts (common in short posts) falls through to
|
lists; German text without umlauts (common in short posts) falls through to
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ defmodule BDS.CliSync do
|
|||||||
end)}
|
end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def data_version do
|
||||||
|
%{rows: [[version]]} = Repo.query!("PRAGMA data_version", [])
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
||||||
{processed_count, _} =
|
{processed_count, _} =
|
||||||
Repo.delete_all(
|
Repo.delete_all(
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
Keyword.get(opts, :poll_interval_ms),
|
Keyword.get(opts, :poll_interval_ms),
|
||||||
@default_poll_interval_ms
|
@default_poll_interval_ms
|
||||||
),
|
),
|
||||||
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
|
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub),
|
||||||
|
data_version_reader: Keyword.get(opts, :data_version_reader, &CliSync.data_version/0),
|
||||||
|
notification_fetcher:
|
||||||
|
Keyword.get(opts, :notification_fetcher, &CliSync.db_file_change_detected/0),
|
||||||
|
pruner: Keyword.get(opts, :pruner, &CliSync.prune_notifications/0),
|
||||||
|
last_data_version: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
{:ok, schedule_poll(state)}
|
{:ok, schedule_poll(state)}
|
||||||
@@ -49,8 +54,13 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp process_notifications(state) do
|
defp process_notifications(state) do
|
||||||
{:ok, notifications} = CliSync.db_file_change_detected()
|
current_data_version = state.data_version_reader.()
|
||||||
{:ok, _pruned} = CliSync.prune_notifications()
|
|
||||||
|
if state.last_data_version == current_data_version do
|
||||||
|
%{state | last_data_version: current_data_version}
|
||||||
|
else
|
||||||
|
{:ok, notifications} = state.notification_fetcher.()
|
||||||
|
{:ok, _pruned} = state.pruner.()
|
||||||
|
|
||||||
Enum.each(notifications, fn notification ->
|
Enum.each(notifications, fn notification ->
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
@@ -60,7 +70,8 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
state
|
%{state | last_data_version: current_data_version}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp notification_payload(notification) do
|
defp notification_payload(notification) do
|
||||||
|
|||||||
@@ -559,27 +559,62 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp wait_for_group_phase(_group_id, _names, timeout) when timeout <= 0, do: :timeout
|
|
||||||
|
|
||||||
defp wait_for_group_phase(group_id, names, timeout) do
|
defp wait_for_group_phase(group_id, names, timeout) do
|
||||||
|
if timeout <= 0 do
|
||||||
|
:timeout
|
||||||
|
else
|
||||||
|
Phoenix.PubSub.subscribe(BDS.PubSub, Tasks.topic())
|
||||||
|
|
||||||
|
try do
|
||||||
|
case group_phase_status(group_id, names) do
|
||||||
|
:waiting -> wait_for_group_phase_message(group_id, names, timeout)
|
||||||
|
status -> status
|
||||||
|
end
|
||||||
|
after
|
||||||
|
Phoenix.PubSub.unsubscribe(BDS.PubSub, Tasks.topic())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_group_phase_message(group_id, names, timeout) do
|
||||||
|
started_at = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:task_terminal, task} ->
|
||||||
|
elapsed = System.monotonic_time(:millisecond) - started_at
|
||||||
|
|
||||||
|
cond do
|
||||||
|
task.group_id == group_id and task.name in names and task.status == :failed ->
|
||||||
|
:failed
|
||||||
|
|
||||||
|
task.group_id == group_id and task.name in names ->
|
||||||
|
case group_phase_status(group_id, names) do
|
||||||
|
:waiting ->
|
||||||
|
wait_for_group_phase_message(group_id, names, timeout - elapsed)
|
||||||
|
|
||||||
|
status ->
|
||||||
|
status
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
wait_for_group_phase_message(group_id, names, timeout - elapsed)
|
||||||
|
end
|
||||||
|
after
|
||||||
|
timeout ->
|
||||||
|
:timeout
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp group_phase_status(group_id, names) do
|
||||||
tasks =
|
tasks =
|
||||||
BDS.Tasks.list_tasks()
|
BDS.Tasks.list_tasks()
|
||||||
|> Enum.filter(&(&1.group_id == group_id and &1.name in names))
|
|> Enum.filter(&(&1.group_id == group_id and &1.name in names))
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
length(tasks) < length(names) ->
|
length(tasks) < length(names) -> :waiting
|
||||||
Process.sleep(50)
|
Enum.any?(tasks, &(&1.status == :failed)) -> :failed
|
||||||
wait_for_group_phase(group_id, names, timeout - 50)
|
Enum.all?(tasks, &(&1.status == :completed)) -> :ok
|
||||||
|
true -> :waiting
|
||||||
Enum.any?(tasks, &(&1.status == :failed)) ->
|
|
||||||
:failed
|
|
||||||
|
|
||||||
Enum.all?(tasks, &(&1.status == :completed)) ->
|
|
||||||
:ok
|
|
||||||
|
|
||||||
true ->
|
|
||||||
Process.sleep(50)
|
|
||||||
wait_for_group_phase(group_id, names, timeout - 50)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ defmodule BDS.Frontmatter do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parse_document(contents) when is_binary(contents) do
|
def parse_document(contents) when is_binary(contents) do
|
||||||
case String.split(contents, "\n---\n", parts: 2) do
|
normalized_contents = normalize_newlines(contents)
|
||||||
|
|
||||||
|
case String.split(normalized_contents, "\n---\n", parts: 2) do
|
||||||
[frontmatter_with_marker, body] ->
|
[frontmatter_with_marker, body] ->
|
||||||
frontmatter = String.replace_prefix(frontmatter_with_marker, "---\n", "")
|
frontmatter = String.replace_prefix(frontmatter_with_marker, "---\n", "")
|
||||||
|
|
||||||
@@ -163,19 +165,11 @@ defmodule BDS.Frontmatter do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp parse_string("\"" <> rest) do
|
defp parse_string("\"" <> rest) do
|
||||||
rest
|
parse_quoted_string(rest, ?")
|
||||||
|> String.trim_trailing("\"")
|
|
||||||
|> String.replace("\\n", "\n")
|
|
||||||
|> String.replace("\\\"", "\"")
|
|
||||||
|> String.replace("\\\\", "\\")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_string("'" <> rest) do
|
defp parse_string("'" <> rest) do
|
||||||
rest
|
parse_quoted_string(rest, ?')
|
||||||
|> String.trim_trailing("'")
|
|
||||||
|> String.replace("\\n", "\n")
|
|
||||||
|> String.replace("\\'", "'")
|
|
||||||
|> String.replace("\\\\", "\\")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_string(value), do: value
|
defp parse_string(value), do: value
|
||||||
@@ -235,4 +229,46 @@ defmodule BDS.Frontmatter do
|
|||||||
rendered = to_string(key)
|
rendered = to_string(key)
|
||||||
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
|
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_newlines(contents) do
|
||||||
|
contents
|
||||||
|
|> String.replace("\r\n", "\n")
|
||||||
|
|> String.replace("\r", "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_quoted_string(rest, quote) do
|
||||||
|
quote_binary = <<quote::utf8>>
|
||||||
|
|
||||||
|
if String.ends_with?(rest, quote_binary) do
|
||||||
|
inner = binary_part(rest, 0, byte_size(rest) - byte_size(quote_binary))
|
||||||
|
unescape_quoted_string(inner, quote, "")
|
||||||
|
else
|
||||||
|
quote_binary <> rest
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp unescape_quoted_string(<<>>, _quote, acc), do: acc
|
||||||
|
|
||||||
|
defp unescape_quoted_string("\\" <> rest, quote, acc) do
|
||||||
|
case rest do
|
||||||
|
<<"n", tail::binary>> ->
|
||||||
|
unescape_quoted_string(tail, quote, acc <> "\n")
|
||||||
|
|
||||||
|
<<"\\", tail::binary>> ->
|
||||||
|
unescape_quoted_string(tail, quote, acc <> "\\")
|
||||||
|
|
||||||
|
<<escaped, tail::binary>> when escaped == quote ->
|
||||||
|
unescape_quoted_string(tail, quote, acc <> <<quote::utf8>>)
|
||||||
|
|
||||||
|
<<char::utf8, tail::binary>> ->
|
||||||
|
unescape_quoted_string(tail, quote, acc <> "\\" <> <<char::utf8>>)
|
||||||
|
|
||||||
|
<<>> ->
|
||||||
|
acc <> "\\"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp unescape_quoted_string(<<char::utf8, tail::binary>>, quote, acc) do
|
||||||
|
unescape_quoted_string(tail, quote, acc <> <<char::utf8>>)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,52 +28,7 @@ defmodule BDS.Publishing do
|
|||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
normalized_credentials = normalize_credentials(credentials)
|
normalized_credentials = normalize_credentials(credentials)
|
||||||
targets = build_upload_targets(Projects.project_data_dir(project), normalized_credentials)
|
targets = build_upload_targets(Projects.project_data_dir(project), normalized_credentials)
|
||||||
GenServer.call(__MODULE__, {:upload_site, project_id, normalized_credentials, targets, opts})
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec get_job(String.t()) :: PublishJob.t() | nil
|
|
||||||
def get_job(job_id) when is_binary(job_id) do
|
|
||||||
GenServer.call(__MODULE__, {:get_job, job_id})
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def init(_state) do
|
|
||||||
{:ok, %{scp_uploads: %{}}}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_call({:get_job, job_id}, _from, state) do
|
|
||||||
{:reply, Repo.get(PublishJob, job_id), state}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_call({:update_job, job_id, attrs}, _from, state) do
|
|
||||||
with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do
|
|
||||||
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
|
|
||||||
job |> PublishJob.changeset(attrs) |> Repo.update()
|
|
||||||
end
|
|
||||||
|
|
||||||
{:reply, :ok, state}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
|
|
||||||
should_upload? =
|
|
||||||
case state.scp_uploads[upload_key] do
|
|
||||||
nil -> true
|
|
||||||
recorded_mtime -> local_mtime > recorded_mtime
|
|
||||||
end
|
|
||||||
|
|
||||||
{:reply, should_upload?, state}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do
|
|
||||||
{:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
|
|
||||||
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
||||||
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))
|
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
@@ -83,10 +38,10 @@ defmodule BDS.Publishing do
|
|||||||
project_id: project_id,
|
project_id: project_id,
|
||||||
status: :pending,
|
status: :pending,
|
||||||
task_id: nil,
|
task_id: nil,
|
||||||
ssh_host: credentials.ssh_host,
|
ssh_host: normalized_credentials.ssh_host,
|
||||||
ssh_user: credentials.ssh_user,
|
ssh_user: normalized_credentials.ssh_user,
|
||||||
ssh_remote_path: credentials.ssh_remote_path,
|
ssh_remote_path: normalized_credentials.ssh_remote_path,
|
||||||
ssh_mode: credentials.ssh_mode,
|
ssh_mode: normalized_credentials.ssh_mode,
|
||||||
targets: Enum.map(targets, &to_string(&1.kind)),
|
targets: Enum.map(targets, &to_string(&1.kind)),
|
||||||
error: nil,
|
error: nil,
|
||||||
inserted_at: now,
|
inserted_at: now,
|
||||||
@@ -102,7 +57,7 @@ defmodule BDS.Publishing do
|
|||||||
Tasks.submit_task(
|
Tasks.submit_task(
|
||||||
"publish #{project_id}",
|
"publish #{project_id}",
|
||||||
fn report ->
|
fn report ->
|
||||||
run_upload(job_id, credentials, targets, uploader, report)
|
run_upload(job_id, normalized_credentials, targets, uploader, report)
|
||||||
end,
|
end,
|
||||||
%{
|
%{
|
||||||
group_id: project_id,
|
group_id: project_id,
|
||||||
@@ -115,7 +70,51 @@ defmodule BDS.Publishing do
|
|||||||
|> PublishJob.changeset(%{task_id: task.id, updated_at: Persistence.now_ms()})
|
|> PublishJob.changeset(%{task_id: task.id, updated_at: Persistence.now_ms()})
|
||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
|
|
||||||
{:reply, {:ok, next_job}, state}
|
{:ok, next_job}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_job(String.t()) :: PublishJob.t() | nil
|
||||||
|
def get_job(job_id) when is_binary(job_id) do
|
||||||
|
Repo.get(PublishJob, job_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_state) do
|
||||||
|
{:ok, %{scp_uploads: %{}}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:filter_scp_uploads, project_id, credentials, target_kind, files}, _from, state) do
|
||||||
|
{files_to_upload, next_uploads} =
|
||||||
|
Enum.reduce(files, {[], state.scp_uploads}, fn {relative_path, local_mtime}, {acc, uploads} ->
|
||||||
|
upload_key = scp_upload_key(project_id, credentials, target_kind, relative_path)
|
||||||
|
|
||||||
|
if should_upload_mtime?(uploads[upload_key], local_mtime) do
|
||||||
|
{[{relative_path, local_mtime} | acc], uploads}
|
||||||
|
else
|
||||||
|
{acc, uploads}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:reply, Enum.reverse(files_to_upload), %{state | scp_uploads: next_uploads}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(
|
||||||
|
{:record_uploaded_scp_files, project_id, credentials, target_kind, uploaded_files},
|
||||||
|
_from,
|
||||||
|
state
|
||||||
|
) do
|
||||||
|
next_uploads =
|
||||||
|
Enum.reduce(uploaded_files, state.scp_uploads, fn {relative_path, local_mtime}, uploads ->
|
||||||
|
Map.put(
|
||||||
|
uploads,
|
||||||
|
scp_upload_key(project_id, credentials, target_kind, relative_path),
|
||||||
|
local_mtime
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | scp_uploads: next_uploads}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp run_upload(job_id, credentials, targets, uploader, report) do
|
defp run_upload(job_id, credentials, targets, uploader, report) do
|
||||||
@@ -147,7 +146,14 @@ defmodule BDS.Publishing do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp update_job(job_id, attrs) do
|
defp update_job(job_id, attrs) do
|
||||||
GenServer.call(__MODULE__, {:update_job, job_id, attrs})
|
repo_opts = repo_call_opts()
|
||||||
|
|
||||||
|
with %PublishJob{} = job <- Repo.get(PublishJob, job_id, repo_opts) do
|
||||||
|
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
|
||||||
|
_ = job |> PublishJob.changeset(attrs) |> Repo.update(repo_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_uploader(opts) do
|
defp build_uploader(opts) do
|
||||||
@@ -188,41 +194,27 @@ defmodule BDS.Publishing do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp run_command_upload(project_id, target, files, credentials, runner, ssh_auth_sock) do
|
defp run_command_upload(project_id, target, files, credentials, runner, ssh_auth_sock) do
|
||||||
Enum.reduce_while(files, :ok, fn relative_path, :ok ->
|
with {:ok, files_with_mtimes} <- collect_file_mtimes(target.local_dir, files) do
|
||||||
local_path = Path.join(target.local_dir, relative_path)
|
files_to_upload =
|
||||||
|
filter_scp_uploads(project_id, credentials, target.kind, files_with_mtimes)
|
||||||
|
|
||||||
with {:ok, local_mtime} <- file_mtime(local_path),
|
case upload_scp_files(
|
||||||
true <-
|
|
||||||
should_upload_scp_file?(
|
|
||||||
project_id,
|
project_id,
|
||||||
|
target,
|
||||||
credentials,
|
credentials,
|
||||||
target.kind,
|
runner,
|
||||||
relative_path,
|
ssh_auth_sock,
|
||||||
local_mtime
|
files_to_upload,
|
||||||
|
[]
|
||||||
) do
|
) do
|
||||||
remote_path = remote_file_spec(credentials, target.remote_dir, relative_path)
|
{:ok, uploaded_files} ->
|
||||||
|
persist_uploaded_scp_files(project_id, credentials, target.kind, uploaded_files)
|
||||||
case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do
|
:ok
|
||||||
:ok ->
|
|
||||||
:ok =
|
|
||||||
mark_uploaded_scp_file(
|
|
||||||
project_id,
|
|
||||||
credentials,
|
|
||||||
target.kind,
|
|
||||||
relative_path,
|
|
||||||
local_mtime
|
|
||||||
)
|
|
||||||
|
|
||||||
{:cont, :ok}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:halt, {:error, reason}}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
else
|
|
||||||
false -> {:cont, :ok}
|
|
||||||
{:error, reason} -> {:halt, {:error, reason}}
|
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp run_command(runner, command, args, ssh_auth_sock) do
|
defp run_command(runner, command, args, ssh_auth_sock) do
|
||||||
@@ -254,22 +246,98 @@ defmodule BDS.Publishing do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp should_upload_scp_file?(project_id, credentials, target_kind, relative_path, local_mtime) do
|
defp filter_scp_uploads(project_id, credentials, target_kind, files_with_mtimes) do
|
||||||
GenServer.call(
|
GenServer.call(
|
||||||
__MODULE__,
|
__MODULE__,
|
||||||
{:should_upload_scp_file,
|
{:filter_scp_uploads, project_id, credentials, target_kind, files_with_mtimes}
|
||||||
scp_upload_key(project_id, credentials, target_kind, relative_path), local_mtime}
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp mark_uploaded_scp_file(project_id, credentials, target_kind, relative_path, local_mtime) do
|
defp record_uploaded_scp_files(project_id, credentials, target_kind, uploaded_files) do
|
||||||
GenServer.call(
|
GenServer.call(
|
||||||
__MODULE__,
|
__MODULE__,
|
||||||
{:mark_uploaded_scp_file,
|
{:record_uploaded_scp_files, project_id, credentials, target_kind, uploaded_files}
|
||||||
scp_upload_key(project_id, credentials, target_kind, relative_path), local_mtime}
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp upload_scp_files(
|
||||||
|
_project_id,
|
||||||
|
_target,
|
||||||
|
_credentials,
|
||||||
|
_runner,
|
||||||
|
_ssh_auth_sock,
|
||||||
|
[],
|
||||||
|
uploaded_files
|
||||||
|
) do
|
||||||
|
{:ok, Enum.reverse(uploaded_files)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upload_scp_files(
|
||||||
|
project_id,
|
||||||
|
target,
|
||||||
|
credentials,
|
||||||
|
runner,
|
||||||
|
ssh_auth_sock,
|
||||||
|
[{relative_path, local_mtime} | rest],
|
||||||
|
uploaded_files
|
||||||
|
) do
|
||||||
|
local_path = Path.join(target.local_dir, relative_path)
|
||||||
|
remote_path = remote_file_spec(credentials, target.remote_dir, relative_path)
|
||||||
|
|
||||||
|
case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do
|
||||||
|
:ok ->
|
||||||
|
upload_scp_files(
|
||||||
|
project_id,
|
||||||
|
target,
|
||||||
|
credentials,
|
||||||
|
runner,
|
||||||
|
ssh_auth_sock,
|
||||||
|
rest,
|
||||||
|
[{relative_path, local_mtime} | uploaded_files]
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
persist_uploaded_scp_files(project_id, credentials, target.kind, uploaded_files)
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_file_mtimes(local_dir, files) do
|
||||||
|
Enum.reduce_while(files, {:ok, []}, fn relative_path, {:ok, acc} ->
|
||||||
|
local_path = Path.join(local_dir, relative_path)
|
||||||
|
|
||||||
|
case file_mtime(local_path) do
|
||||||
|
{:ok, local_mtime} -> {:cont, {:ok, [{relative_path, local_mtime} | acc]}}
|
||||||
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, files_with_mtimes} -> {:ok, Enum.reverse(files_with_mtimes)}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persist_uploaded_scp_files(_project_id, _credentials, _target_kind, []), do: :ok
|
||||||
|
|
||||||
|
defp persist_uploaded_scp_files(project_id, credentials, target_kind, uploaded_files) do
|
||||||
|
record_uploaded_scp_files(
|
||||||
|
project_id,
|
||||||
|
credentials,
|
||||||
|
target_kind,
|
||||||
|
Enum.reverse(uploaded_files)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp should_upload_mtime?(nil, _local_mtime), do: true
|
||||||
|
defp should_upload_mtime?(recorded_mtime, local_mtime), do: local_mtime > recorded_mtime
|
||||||
|
|
||||||
|
defp repo_call_opts do
|
||||||
|
case Process.whereis(__MODULE__) do
|
||||||
|
pid when is_pid(pid) -> [caller: pid]
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp scp_upload_key(project_id, credentials, target_kind, relative_path) do
|
defp scp_upload_key(project_id, credentials, target_kind, relative_path) do
|
||||||
{
|
{
|
||||||
project_id,
|
project_id,
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ defmodule BDS.Tasks do
|
|||||||
@default_progress_throttle_ms 250
|
@default_progress_throttle_ms 250
|
||||||
@default_recent_finished_limit 10
|
@default_recent_finished_limit 10
|
||||||
@default_finished_task_ttl_ms :timer.hours(1)
|
@default_finished_task_ttl_ms :timer.hours(1)
|
||||||
|
@topic "tasks"
|
||||||
|
|
||||||
def start_link(_opts) do
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def topic, do: @topic
|
||||||
|
|
||||||
def submit_task(name, work, attrs \\ %{})
|
def submit_task(name, work, attrs \\ %{})
|
||||||
when is_binary(name) and is_function(work, 1) and is_map(attrs) do
|
when is_binary(name) and is_function(work, 1) and is_map(attrs) do
|
||||||
GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
|
GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
|
||||||
@@ -66,9 +69,10 @@ defmodule BDS.Tasks do
|
|||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
tasks: %{},
|
tasks: %{},
|
||||||
queue: [],
|
queue: :queue.new(),
|
||||||
running: %{},
|
running: %{},
|
||||||
ref_to_task: %{}
|
ref_to_task: %{},
|
||||||
|
finished_task_eviction_timer: nil
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -80,8 +84,7 @@ defmodule BDS.Tasks do
|
|||||||
if map_size(next_state.running) < max_concurrent() do
|
if map_size(next_state.running) < max_concurrent() do
|
||||||
{:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)}
|
{:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)}
|
||||||
else
|
else
|
||||||
{:reply, {:ok, public_task(task)},
|
{:reply, {:ok, public_task(task)}, enqueue_task(next_state, task.id, work)}
|
||||||
%{next_state | queue: next_state.queue ++ [{task.id, work}]}}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -136,18 +139,20 @@ defmodule BDS.Tasks do
|
|||||||
|> start_queued_tasks()
|
|> start_queued_tasks()
|
||||||
|> schedule_finished_task_eviction()
|
|> schedule_finished_task_eviction()
|
||||||
|
|
||||||
|
broadcast_terminal_task(next_state.tasks[task_id])
|
||||||
|
|
||||||
{:reply, :ok, next_state}
|
{:reply, :ok, next_state}
|
||||||
|
|
||||||
Enum.any?(state.queue, fn {queued_id, _work} -> queued_id == task_id end) ->
|
queued_task?(state.queue, task_id) ->
|
||||||
next_state =
|
next_state =
|
||||||
state
|
state
|
||||||
|> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()})
|
|> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()})
|
||||||
|> Map.update!(:queue, fn queue ->
|
|> remove_queued_task(task_id)
|
||||||
Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end)
|
|
||||||
end)
|
|
||||||
|> start_queued_tasks()
|
|> start_queued_tasks()
|
||||||
|> schedule_finished_task_eviction()
|
|> schedule_finished_task_eviction()
|
||||||
|
|
||||||
|
broadcast_terminal_task(next_state.tasks[task_id])
|
||||||
|
|
||||||
{:reply, :ok, next_state}
|
{:reply, :ok, next_state}
|
||||||
|
|
||||||
state.tasks[task_id] == nil ->
|
state.tasks[task_id] == nil ->
|
||||||
@@ -179,6 +184,8 @@ defmodule BDS.Tasks do
|
|||||||
|> start_queued_tasks()
|
|> start_queued_tasks()
|
||||||
|> schedule_finished_task_eviction()
|
|> schedule_finished_task_eviction()
|
||||||
|
|
||||||
|
broadcast_terminal_task(next_state.tasks[task_id])
|
||||||
|
|
||||||
{:reply, :ok, next_state}
|
{:reply, :ok, next_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -193,6 +200,8 @@ defmodule BDS.Tasks do
|
|||||||
|> start_queued_tasks()
|
|> start_queued_tasks()
|
||||||
|> schedule_finished_task_eviction()
|
|> schedule_finished_task_eviction()
|
||||||
|
|
||||||
|
broadcast_terminal_task(next_state.tasks[task_id])
|
||||||
|
|
||||||
{:reply, :ok, next_state}
|
{:reply, :ok, next_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -202,7 +211,19 @@ defmodule BDS.Tasks do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(:evict_finished_tasks, state) do
|
def handle_info(:evict_finished_tasks, state) do
|
||||||
{:noreply, prune_expired_finished_tasks(state)}
|
next_state =
|
||||||
|
state
|
||||||
|
|> Map.put(:finished_task_eviction_timer, nil)
|
||||||
|
|> prune_expired_finished_tasks()
|
||||||
|
|
||||||
|
next_state =
|
||||||
|
if any_finished_tasks?(next_state) do
|
||||||
|
schedule_finished_task_eviction(next_state)
|
||||||
|
else
|
||||||
|
next_state
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, next_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({ref, result}, state) do
|
def handle_info({ref, result}, state) do
|
||||||
@@ -240,6 +261,10 @@ defmodule BDS.Tasks do
|
|||||||
|> start_queued_tasks()
|
|> start_queued_tasks()
|
||||||
|> schedule_finished_task_eviction()
|
|> schedule_finished_task_eviction()
|
||||||
|
|
||||||
|
if task.status != :cancelled do
|
||||||
|
broadcast_terminal_task(next_state.tasks[task_id])
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply, next_state}
|
{:noreply, next_state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -271,6 +296,10 @@ defmodule BDS.Tasks do
|
|||||||
|> start_queued_tasks()
|
|> start_queued_tasks()
|
||||||
|> schedule_finished_task_eviction()
|
|> schedule_finished_task_eviction()
|
||||||
|
|
||||||
|
if task.status != :cancelled and next_state.tasks[task_id].status == :failed do
|
||||||
|
broadcast_terminal_task(next_state.tasks[task_id])
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply, next_state}
|
{:noreply, next_state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -296,11 +325,11 @@ defmodule BDS.Tasks do
|
|||||||
map_size(state.running) >= max_concurrent() ->
|
map_size(state.running) >= max_concurrent() ->
|
||||||
state
|
state
|
||||||
|
|
||||||
state.queue == [] ->
|
:queue.is_empty(state.queue) ->
|
||||||
state
|
state
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
[{task_id, work} | remaining] = state.queue
|
{{:value, {task_id, work}}, remaining} = :queue.out(state.queue)
|
||||||
|
|
||||||
state
|
state
|
||||||
|> Map.put(:queue, remaining)
|
|> Map.put(:queue, remaining)
|
||||||
@@ -362,9 +391,21 @@ defmodule BDS.Tasks do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp broadcast_terminal_task(nil), do: :ok
|
||||||
|
|
||||||
|
defp broadcast_terminal_task(task) when task.status in [:completed, :failed, :cancelled] do
|
||||||
|
Phoenix.PubSub.broadcast(BDS.PubSub, topic(), {:task_terminal, public_task(task)})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp broadcast_terminal_task(_task), do: :ok
|
||||||
|
|
||||||
defp schedule_finished_task_eviction(state) do
|
defp schedule_finished_task_eviction(state) do
|
||||||
Process.send_after(self(), :evict_finished_tasks, finished_task_ttl_ms())
|
if state.finished_task_eviction_timer do
|
||||||
state
|
state
|
||||||
|
else
|
||||||
|
timer_ref = Process.send_after(self(), :evict_finished_tasks, finished_task_ttl_ms())
|
||||||
|
%{state | finished_task_eviction_timer: timer_ref}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp prune_expired_finished_tasks(state) do
|
defp prune_expired_finished_tasks(state) do
|
||||||
@@ -378,6 +419,32 @@ defmodule BDS.Tasks do
|
|||||||
%{state | tasks: tasks}
|
%{state | tasks: tasks}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp enqueue_task(state, task_id, work) do
|
||||||
|
%{state | queue: :queue.in({task_id, work}, state.queue)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp queued_task?(queue, task_id) do
|
||||||
|
queue
|
||||||
|
|> :queue.to_list()
|
||||||
|
|> Enum.any?(fn {queued_id, _work} -> queued_id == task_id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp remove_queued_task(state, task_id) do
|
||||||
|
remaining_queue =
|
||||||
|
state.queue
|
||||||
|
|> :queue.to_list()
|
||||||
|
|> Enum.reject(fn {queued_id, _work} -> queued_id == task_id end)
|
||||||
|
|> :queue.from_list()
|
||||||
|
|
||||||
|
%{state | queue: remaining_queue}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp any_finished_tasks?(state) do
|
||||||
|
Enum.any?(state.tasks, fn {_task_id, task} ->
|
||||||
|
task.status in [:completed, :failed, :cancelled]
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp expired_finished_task?(%{status: status, finished_at: %DateTime{} = finished_at}, now)
|
defp expired_finished_task?(%{status: status, finished_at: %DateTime{} = finished_at}, now)
|
||||||
when status in [:completed, :failed, :cancelled] do
|
when status in [:completed, :failed, :cancelled] do
|
||||||
DateTime.diff(now, finished_at, :millisecond) >= finished_task_ttl_ms()
|
DateTime.diff(now, finished_at, :millisecond) >= finished_task_ttl_ms()
|
||||||
|
|||||||
@@ -104,25 +104,19 @@ defmodule BDS.AI.ChatStreamingTest do
|
|||||||
|
|
||||||
server = start_supervised!({Bandit, plug: StreamingChatPlug, port: 0, startup_log: false})
|
server = start_supervised!({Bandit, plug: StreamingChatPlug, port: 0, startup_log: false})
|
||||||
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
|
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
|
||||||
|
|
||||||
assert {:ok, _endpoint} =
|
|
||||||
BDS.AI.put_endpoint(:online, %{
|
|
||||||
url: "http://127.0.0.1:#{port}/v1",
|
|
||||||
api_key: "sk-stream",
|
|
||||||
model: "stream-model"
|
|
||||||
})
|
|
||||||
|
|
||||||
assert :ok = BDS.AI.set_airplane_mode(false)
|
|
||||||
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "stream-model"})
|
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "stream-model"})
|
||||||
|
|
||||||
{:ok, conversation: conversation}
|
{:ok, conversation: conversation, streaming_port: port}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "incremental content events arrive before the final reply and persistence matches", %{
|
test "incremental content events arrive before the final reply and persistence matches", %{
|
||||||
conversation: conversation
|
conversation: conversation,
|
||||||
|
streaming_port: port
|
||||||
} do
|
} do
|
||||||
conversation_id = conversation.id
|
conversation_id = conversation.id
|
||||||
|
|
||||||
|
configure_streaming_runtime!(port)
|
||||||
|
|
||||||
assert {:ok, reply} =
|
assert {:ok, reply} =
|
||||||
BDS.AI.send_chat_message(conversation_id, "tell me a story",
|
BDS.AI.send_chat_message(conversation_id, "tell me a story",
|
||||||
event_target: self()
|
event_target: self()
|
||||||
@@ -142,11 +136,16 @@ defmodule BDS.AI.ChatStreamingTest do
|
|||||||
assert assistant_message.token_usage_output == 4
|
assert assistant_message.token_usage_output == 4
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cancel_chat mid-stream aborts the HTTP request", %{conversation: conversation} do
|
test "cancel_chat mid-stream aborts the HTTP request", %{
|
||||||
|
conversation: conversation,
|
||||||
|
streaming_port: port
|
||||||
|
} do
|
||||||
Application.put_env(:bds, :chat_stream_scenario, :endless)
|
Application.put_env(:bds, :chat_stream_scenario, :endless)
|
||||||
conversation_id = conversation.id
|
conversation_id = conversation.id
|
||||||
test_pid = self()
|
test_pid = self()
|
||||||
|
|
||||||
|
configure_streaming_runtime!(port)
|
||||||
|
|
||||||
task =
|
task =
|
||||||
Task.async(fn ->
|
Task.async(fn ->
|
||||||
BDS.AI.send_chat_message(conversation_id, "stream forever", event_target: test_pid)
|
BDS.AI.send_chat_message(conversation_id, "stream forever", event_target: test_pid)
|
||||||
@@ -161,4 +160,16 @@ defmodule BDS.AI.ChatStreamingTest do
|
|||||||
# The server notices the closed connection — the request was truly aborted.
|
# The server notices the closed connection — the request was truly aborted.
|
||||||
assert_receive :sse_client_disconnected, 2_000
|
assert_receive :sse_client_disconnected, 2_000
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp configure_streaming_runtime!(port) do
|
||||||
|
assert {:ok, _endpoint} =
|
||||||
|
BDS.AI.put_endpoint(:online, %{
|
||||||
|
url: "http://127.0.0.1:#{port}/v1",
|
||||||
|
api_key: "sk-stream",
|
||||||
|
model: "stream-model"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert :ok = BDS.AI.put_model_preference(:chat, "stream-model")
|
||||||
|
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -76,6 +76,47 @@ defmodule BDS.CliSyncTest do
|
|||||||
assert is_integer(seen_notification.seen_at)
|
assert is_integer(seen_notification.seen_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "watcher skips notification queries when sqlite data_version is unchanged" do
|
||||||
|
test_pid = self()
|
||||||
|
data_version = :erlang.make_ref()
|
||||||
|
:persistent_term.put(data_version, [7, 7])
|
||||||
|
|
||||||
|
data_version_reader = fn ->
|
||||||
|
[next | rest] = :persistent_term.get(data_version)
|
||||||
|
:persistent_term.put(data_version, rest)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
notification_fetcher = fn ->
|
||||||
|
send(test_pid, :notifications_fetched)
|
||||||
|
{:ok, []}
|
||||||
|
end
|
||||||
|
|
||||||
|
pruner = fn ->
|
||||||
|
send(test_pid, :notifications_pruned)
|
||||||
|
{:ok, %{processed: 0, unprocessed: 0}}
|
||||||
|
end
|
||||||
|
|
||||||
|
on_exit(fn -> :persistent_term.erase(data_version) end)
|
||||||
|
|
||||||
|
watcher =
|
||||||
|
start_supervised!(
|
||||||
|
{Watcher,
|
||||||
|
poll_interval_ms: 60_000,
|
||||||
|
data_version_reader: data_version_reader,
|
||||||
|
notification_fetcher: notification_fetcher,
|
||||||
|
pruner: pruner}
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok = Watcher.poll_now(watcher)
|
||||||
|
assert_receive :notifications_fetched, 500
|
||||||
|
assert_receive :notifications_pruned, 500
|
||||||
|
|
||||||
|
:ok = Watcher.poll_now(watcher)
|
||||||
|
refute_receive :notifications_fetched, 100
|
||||||
|
refute_receive :notifications_pruned, 100
|
||||||
|
end
|
||||||
|
|
||||||
test "processed notifications are pruned after one hour and unprocessed notifications after one day" do
|
test "processed notifications are pruned after one hour and unprocessed notifications after one day" do
|
||||||
now = BDS.Persistence.now_ms()
|
now = BDS.Persistence.now_ms()
|
||||||
|
|
||||||
|
|||||||
@@ -76,18 +76,18 @@ defmodule BDS.CSM020NestedCaseTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Publishing.handle_call :update_job uses with" do
|
describe "Publishing.update_job/2 uses with" do
|
||||||
test "source code uses with instead of case" do
|
test "source code uses with instead of case" do
|
||||||
source = File.read!("lib/bds/publishing.ex")
|
source = File.read!("lib/bds/publishing.ex")
|
||||||
|
|
||||||
[func_source] =
|
[func_source] =
|
||||||
Regex.scan(~r/def handle_call\(\{:update_job.*?(?=\n def |\n @impl)/s, source)
|
Regex.scan(~r/defp update_job\(job_id, attrs\).*?(?=\n defp |\nend)/s, source)
|
||||||
|
|
||||||
assert func_source |> List.first() |> String.contains?("with"),
|
assert func_source |> List.first() |> String.contains?("with"),
|
||||||
"update_job handler should use with"
|
"update_job should use with"
|
||||||
|
|
||||||
refute func_source |> List.first() |> String.contains?("case Repo.get"),
|
refute func_source |> List.first() |> String.contains?("case Repo.get"),
|
||||||
"update_job handler should not use case Repo.get"
|
"update_job should not use case Repo.get"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ defmodule BDS.CSM036ImplTrueTest do
|
|||||||
String.contains?(line, "def handle_call(")
|
String.contains?(line, "def handle_call(")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert length(handle_call_lines) >= 5, "expected at least 5 handle_call clauses"
|
assert length(handle_call_lines) >= 2, "expected at least 2 handle_call clauses"
|
||||||
|
|
||||||
for {_line, idx} <- handle_call_lines do
|
for {_line, idx} <- handle_call_lines do
|
||||||
preceding = Enum.at(lines, idx - 2)
|
preceding = Enum.at(lines, idx - 2)
|
||||||
|
|||||||
@@ -914,6 +914,21 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
assert message =~ "Project database is not initialized"
|
assert message =~ "Project database is not initialized"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "rebuild sequencing waits on task messages instead of sleep polling" do
|
||||||
|
source = File.read!("lib/bds/desktop/shell_commands.ex")
|
||||||
|
|
||||||
|
func_source =
|
||||||
|
Regex.scan(~r/defp wait_for_group_phase(?:_message)?\(.*?(?=\n defp |\nend)/s, source)
|
||||||
|
|> Enum.map(&List.first/1)
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
refute String.contains?(func_source, "Process.sleep"),
|
||||||
|
"wait_for_group_phase should not use sleep polling"
|
||||||
|
|
||||||
|
assert String.contains?(func_source, "Phoenix.PubSub.subscribe")
|
||||||
|
assert String.contains?(func_source, "receive")
|
||||||
|
end
|
||||||
|
|
||||||
defp wait_for_task(task_id, matcher, timeout \\ 2_000)
|
defp wait_for_task(task_id, matcher, timeout \\ 2_000)
|
||||||
|
|
||||||
defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do
|
defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do
|
||||||
|
|||||||
30
test/bds/frontmatter_test.exs
Normal file
30
test/bds/frontmatter_test.exs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
defmodule BDS.FrontmatterTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
test "parse_document accepts CRLF frontmatter documents" do
|
||||||
|
contents = "---\r\ntitle: Hello\r\ntags:\r\n - elixir\r\n---\r\nBody\r\n"
|
||||||
|
|
||||||
|
assert {:ok, %{fields: fields, body: body}} = BDS.Frontmatter.parse_document(contents)
|
||||||
|
assert fields["title"] == "Hello"
|
||||||
|
assert fields["tags"] == ["elixir"]
|
||||||
|
assert body == "Body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "serialize_document roundtrips quoted strings with embedded quotes and escapes" do
|
||||||
|
fields = [
|
||||||
|
{"title", "He said \"hi\" \\\\ there"},
|
||||||
|
{"summary", "Ends with a quote\""},
|
||||||
|
{"excerpt", "first line\nsecond line"}
|
||||||
|
]
|
||||||
|
|
||||||
|
contents = BDS.Frontmatter.serialize_document(fields, "Body")
|
||||||
|
|
||||||
|
assert {:ok, %{fields: parsed_fields, body: body}} =
|
||||||
|
BDS.Frontmatter.parse_document(contents)
|
||||||
|
|
||||||
|
assert parsed_fields["title"] == "He said \"hi\" \\\\ there"
|
||||||
|
assert parsed_fields["summary"] == "Ends with a quote\""
|
||||||
|
assert parsed_fields["excerpt"] == "first line\nsecond line"
|
||||||
|
assert body == "Body"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -280,6 +280,46 @@ defmodule BDS.PublishingTest do
|
|||||||
assert elem(html_upload, 1) == ["-q", html_index, "deploy@example.com:/srv/blog/index.html"]
|
assert elem(html_upload, 1) == ["-q", html_index, "deploy@example.com:/srv/blog/index.html"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "upload_site batches scp mtime bookkeeping instead of calling the publishing server per file",
|
||||||
|
%{project: project, temp_dir: temp_dir} do
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
File.mkdir_p!(Path.join([temp_dir, "html", "posts"]))
|
||||||
|
|
||||||
|
for index <- 1..5 do
|
||||||
|
File.write!(Path.join([temp_dir, "html", "posts", "entry-#{index}.html"]), "<html />")
|
||||||
|
end
|
||||||
|
|
||||||
|
credentials = %{
|
||||||
|
ssh_host: "example.com",
|
||||||
|
ssh_user: "deploy",
|
||||||
|
ssh_remote_path: "/srv/blog",
|
||||||
|
ssh_mode: :scp
|
||||||
|
}
|
||||||
|
|
||||||
|
publishing_pid = Process.whereis(BDS.Publishing)
|
||||||
|
:erlang.trace(publishing_pid, true, [:receive])
|
||||||
|
|
||||||
|
runner = fn command, args, opts ->
|
||||||
|
send(test_pid, {:command_run, command, args, opts})
|
||||||
|
{"", 0}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert {:ok, job} =
|
||||||
|
BDS.Publishing.upload_site(project.id, credentials,
|
||||||
|
command_runner: runner,
|
||||||
|
ssh_auth_sock: "/tmp/test-agent.sock"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert wait_for_publish_job(job.id, &(&1.status == :completed)).status == :completed
|
||||||
|
|
||||||
|
:erlang.trace(publishing_pid, false, [:receive])
|
||||||
|
|
||||||
|
bookkeeping_calls = collect_publishing_bookkeeping_calls(publishing_pid)
|
||||||
|
|
||||||
|
assert length(bookkeeping_calls) <= 6
|
||||||
|
end
|
||||||
|
|
||||||
test "publish jobs survive a publishing server restart because they are persisted", %{
|
test "publish jobs survive a publishing server restart because they are persisted", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
@@ -325,6 +365,25 @@ defmodule BDS.PublishingTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp collect_publishing_bookkeeping_calls(publishing_pid, acc \\ []) do
|
||||||
|
receive do
|
||||||
|
{:trace, ^publishing_pid, :receive, {:"$gen_call", _from, message}}
|
||||||
|
when is_tuple(message) and tuple_size(message) > 0 and
|
||||||
|
elem(message, 0) in [
|
||||||
|
:should_upload_scp_file,
|
||||||
|
:mark_uploaded_scp_file,
|
||||||
|
:filter_scp_uploads,
|
||||||
|
:record_uploaded_scp_files
|
||||||
|
] ->
|
||||||
|
collect_publishing_bookkeeping_calls(publishing_pid, [message | acc])
|
||||||
|
|
||||||
|
{:trace, ^publishing_pid, :receive, _message} ->
|
||||||
|
collect_publishing_bookkeeping_calls(publishing_pid, acc)
|
||||||
|
after
|
||||||
|
50 -> Enum.reverse(acc)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp wait_for_publish_job(job_id, predicate, attempts \\ 100)
|
defp wait_for_publish_job(job_id, predicate, attempts \\ 100)
|
||||||
|
|
||||||
defp wait_for_publish_job(job_id, predicate, attempts) when attempts > 0 do
|
defp wait_for_publish_job(job_id, predicate, attempts) when attempts > 0 do
|
||||||
|
|||||||
@@ -553,6 +553,15 @@ defmodule BDS.SearchTest do
|
|||||||
assert Enum.uniq(languages) == languages
|
assert Enum.uniq(languages) == languages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "detect_language classifies umlaut-free German text as German" do
|
||||||
|
assert BDS.Search.detect_language("Der Fluss fliesst ruhig am Morgen entlang der alten Bruecke") ==
|
||||||
|
"de"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "detect_language classifies accent-free French text as French" do
|
||||||
|
assert BDS.Search.detect_language("Je cours au parc chaque matin avant le travail") == "fr"
|
||||||
|
end
|
||||||
|
|
||||||
test "search_posts finds translation text in multiple languages after reindex", %{
|
test "search_posts finds translation text in multiple languages after reindex", %{
|
||||||
project: project
|
project: project
|
||||||
} do
|
} do
|
||||||
|
|||||||
@@ -266,6 +266,51 @@ defmodule BDS.TasksTest do
|
|||||||
assert running.id in task_ids
|
assert running.id in task_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "finished task eviction uses a single live timer" do
|
||||||
|
Application.put_env(:bds, :tasks,
|
||||||
|
max_concurrent: 3,
|
||||||
|
progress_throttle_ms: 250,
|
||||||
|
finished_task_ttl_ms: 50
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, first} = BDS.Tasks.register_external_task("first finished")
|
||||||
|
assert {:ok, second} = BDS.Tasks.register_external_task("second finished")
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.complete_task(first.id)
|
||||||
|
first_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
|
||||||
|
assert is_reference(first_timer)
|
||||||
|
assert is_integer(Process.read_timer(first_timer))
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.complete_task(second.id)
|
||||||
|
second_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
|
||||||
|
|
||||||
|
assert second_timer == first_timer
|
||||||
|
assert is_integer(Process.read_timer(second_timer))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "task queue implementation avoids list append churn" do
|
||||||
|
source = File.read!("lib/bds/tasks.ex")
|
||||||
|
|
||||||
|
assert String.contains?(source, ":queue"), "tasks queue should use :queue"
|
||||||
|
|
||||||
|
refute String.contains?(source, "queue ++"),
|
||||||
|
"tasks queue should not append with ++"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "terminal task states are broadcast on PubSub" do
|
||||||
|
Phoenix.PubSub.subscribe(BDS.PubSub, BDS.Tasks.topic())
|
||||||
|
|
||||||
|
assert {:ok, completed} =
|
||||||
|
BDS.Tasks.submit_task("broadcast completion", fn _report -> {:ok, :done} end,
|
||||||
|
%{group_id: "broadcast-group", group_name: "Maintenance"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_receive {:task_terminal, task_event}, 1_000
|
||||||
|
assert task_event.id == completed.id
|
||||||
|
assert task_event.group_id == "broadcast-group"
|
||||||
|
assert task_event.status == :completed
|
||||||
|
end
|
||||||
|
|
||||||
defp receive_started do
|
defp receive_started do
|
||||||
receive do
|
receive do
|
||||||
{:started, name, pid} -> {name, pid}
|
{:started, name, pid} -> {name, pid}
|
||||||
@@ -290,4 +335,5 @@ defmodule BDS.TasksTest do
|
|||||||
defp wait_for_task(_task_id, _predicate, 0) do
|
defp wait_for_task(_task_id, _predicate, 0) do
|
||||||
flunk("task did not reach expected state")
|
flunk("task did not reach expected state")
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user