feat: PLAN step 2 done
This commit is contained in:
4
PLAN.md
4
PLAN.md
@@ -49,8 +49,8 @@ The remaining work needs to proceed from base contracts upward. Later phases sho
|
||||
1. Lock compatibility contracts. Completed 2026-04-25.
|
||||
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
|
||||
|
||||
2. Close engine-level behavior gaps.
|
||||
Finish any remaining save/publish/delete side-effects, translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications so backend behavior is fully spec-complete independent of UI.
|
||||
2. Close engine-level behavior gaps. Completed 2026-04-25.
|
||||
Save/publish/delete side-effects, manual-translation source-post reopening, post-to-media sidecar cleanup, auto-translation task cascades, linked-media translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications are now implemented and covered at the backend layer independent of UI.
|
||||
|
||||
3. Finish the desktop shell primitives.
|
||||
Complete route state, shell command coverage, panel integration, and menu wiring for every sidebar view and editor route so the shell exposes the entire product surface cleanly.
|
||||
|
||||
@@ -101,6 +101,18 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
def sync_media_sidecar(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
media ->
|
||||
project = Projects.get_project!(media.project_id)
|
||||
:ok = write_sidecar(project, media)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def delete_media(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
|
||||
184
lib/bds/posts.ex
184
lib/bds/posts.ex
@@ -5,6 +5,8 @@ defmodule BDS.Posts do
|
||||
|
||||
alias BDS.Frontmatter
|
||||
alias BDS.Embeddings
|
||||
alias BDS.AI
|
||||
alias BDS.Media
|
||||
alias BDS.Metadata
|
||||
alias BDS.Persistence
|
||||
alias BDS.PostLinks
|
||||
@@ -16,6 +18,7 @@ defmodule BDS.Posts do
|
||||
alias BDS.Repo
|
||||
alias BDS.Search
|
||||
alias BDS.Slug
|
||||
alias BDS.Tasks
|
||||
|
||||
def create_post(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
@@ -54,6 +57,7 @@ defmodule BDS.Posts do
|
||||
{:ok, post} ->
|
||||
:ok = Embeddings.sync_post(post)
|
||||
:ok = Search.sync_post(post)
|
||||
:ok = maybe_schedule_auto_translations(post)
|
||||
{:ok, post}
|
||||
|
||||
error ->
|
||||
@@ -84,6 +88,7 @@ defmodule BDS.Posts do
|
||||
:ok = Embeddings.sync_post(updated_post)
|
||||
:ok = PostLinks.sync_post_links(updated_post)
|
||||
:ok = Search.sync_post(updated_post)
|
||||
:ok = maybe_schedule_auto_translations(updated_post)
|
||||
{:ok, updated_post}
|
||||
|
||||
error ->
|
||||
@@ -210,10 +215,12 @@ defmodule BDS.Posts do
|
||||
{:error, :not_found}
|
||||
|
||||
%Post{} = post ->
|
||||
linked_media_ids = linked_media_ids(post.id)
|
||||
delete_post_file(post)
|
||||
:ok = Embeddings.remove_post(post.id)
|
||||
:ok = PostLinks.delete_post_links(post.id)
|
||||
Repo.delete!(post)
|
||||
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
||||
:ok = Search.delete_post(post.id)
|
||||
{:ok, :deleted}
|
||||
end
|
||||
@@ -390,6 +397,7 @@ defmodule BDS.Posts do
|
||||
|> Repo.insert_or_update()
|
||||
|> case do
|
||||
{:ok, saved_translation} ->
|
||||
{:ok, _post} = maybe_reopen_source_post_for_manual_translation(post, attrs)
|
||||
:ok = Search.sync_post(post.id)
|
||||
{:ok, saved_translation}
|
||||
|
||||
@@ -932,6 +940,182 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_reopen_source_post_for_manual_translation(%Post{} = post, attrs) do
|
||||
if attr(attrs, :auto_generated) == true or post.status != :published or post.file_path in [nil, ""] do
|
||||
{:ok, post}
|
||||
else
|
||||
project = Projects.get_project!(post.project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), post.file_path)
|
||||
restored_content = published_post_body(post, full_path)
|
||||
|
||||
post
|
||||
|> Post.changeset(%{
|
||||
status: :draft,
|
||||
content: restored_content,
|
||||
updated_at: Persistence.now_ms()
|
||||
})
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_schedule_auto_translations(%Post{do_not_translate: true}), do: :ok
|
||||
|
||||
defp maybe_schedule_auto_translations(%Post{} = post) do
|
||||
with true <- auto_translation_configured?(),
|
||||
{:ok, metadata} <- Metadata.get_project_metadata(post.project_id) do
|
||||
post
|
||||
|> missing_auto_translation_languages(metadata)
|
||||
|> Enum.each(&queue_post_auto_translation(post, &1))
|
||||
else
|
||||
_other -> :ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp missing_auto_translation_languages(%Post{} = post, metadata) do
|
||||
source_language = normalize_language(post.language || metadata.main_language)
|
||||
|
||||
configured_languages =
|
||||
([metadata.main_language] ++ (metadata.blog_languages || []))
|
||||
|> Enum.map(&normalize_language/1)
|
||||
|> Enum.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.uniq()
|
||||
|
||||
existing_languages =
|
||||
Repo.all(
|
||||
from translation in Translation,
|
||||
where: translation.translation_for == ^post.id,
|
||||
select: translation.language
|
||||
)
|
||||
|
||||
configured_languages
|
||||
|> Enum.reject(&(&1 == source_language or &1 in existing_languages))
|
||||
end
|
||||
|
||||
defp queue_post_auto_translation(%Post{} = post, language) do
|
||||
_ =
|
||||
Tasks.submit_task(
|
||||
"Auto-translate Post to #{language}",
|
||||
fn report ->
|
||||
report.(0.05, "Translating post to #{language}")
|
||||
|
||||
with {:ok, translation} <- AI.translate_post(post.id, language, auto_translation_ai_opts()),
|
||||
{:ok, saved_translation} <-
|
||||
upsert_post_translation(post.id, language, %{
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
content: translation.content,
|
||||
auto_generated: true
|
||||
}) do
|
||||
report.(0.85, "Post translation saved")
|
||||
:ok = queue_media_translation_cascade(post, language)
|
||||
report.(1.0, "Post translation complete")
|
||||
%{post_id: post.id, translation_id: saved_translation.id, language: language}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:error, other}
|
||||
end
|
||||
end,
|
||||
auto_translation_task_attrs(post)
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp queue_media_translation_cascade(%Post{} = post, language) do
|
||||
linked_media_ids(post.id)
|
||||
|> Enum.each(fn media_id ->
|
||||
if media_translation_needed?(media_id, language) do
|
||||
queue_media_translation(post, media_id, language)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp queue_media_translation(%Post{} = post, media_id, language) do
|
||||
_ =
|
||||
Tasks.submit_task(
|
||||
"Auto-translate Media to #{language}",
|
||||
fn report ->
|
||||
report.(0.05, "Translating media to #{language}")
|
||||
|
||||
with {:ok, translation} <- AI.translate_media(media_id, language, auto_translation_ai_opts()),
|
||||
{:ok, saved_translation} <-
|
||||
Media.upsert_media_translation(media_id, language, %{
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
}) do
|
||||
report.(1.0, "Media translation complete")
|
||||
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:error, other}
|
||||
end
|
||||
end,
|
||||
auto_translation_task_attrs(post)
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp media_translation_needed?(media_id, language) do
|
||||
case Repo.get(Media.Media, media_id) do
|
||||
%Media.Media{language: source_language} when source_language not in [nil, ""] and source_language != language ->
|
||||
not Repo.exists?(
|
||||
from translation in Media.Translation,
|
||||
where: translation.translation_for == ^media_id and translation.language == ^language
|
||||
)
|
||||
|
||||
_other ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp auto_translation_task_attrs(%Post{} = post) do
|
||||
%{
|
||||
group_id: post.project_id,
|
||||
group_name: "AI"
|
||||
}
|
||||
end
|
||||
|
||||
defp auto_translation_ai_opts do
|
||||
Application.get_env(:bds, :posts, [])
|
||||
|> Keyword.get(:auto_translation_ai_opts, [])
|
||||
end
|
||||
|
||||
defp auto_translation_configured? do
|
||||
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
||||
|
||||
case AI.get_endpoint(mode) do
|
||||
{:ok, %{url: url, model: model} = endpoint}
|
||||
when is_binary(url) and url != "" and is_binary(model) and model != "" ->
|
||||
mode == :airplane or present?(Map.get(endpoint, :api_key))
|
||||
|
||||
_other ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp linked_media_ids(post_id) do
|
||||
case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
|
||||
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
|
||||
{:error, _reason} -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_deleted_post_media_sidecar(media_id) do
|
||||
case Media.sync_media_sidecar(media_id) do
|
||||
:ok -> :ok
|
||||
{:error, :not_found} -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp present?(value) when is_binary(value), do: String.trim(value) != ""
|
||||
defp present?(value), do: not is_nil(value)
|
||||
|
||||
defp orphan_translation_files(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule BDS.MediaTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Repo
|
||||
|
||||
setup do
|
||||
@@ -117,6 +119,49 @@ defmodule BDS.MediaTest do
|
||||
end)
|
||||
end
|
||||
|
||||
test "deleting a post rewrites linked media sidecars to remove that post id", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "hello media")
|
||||
|
||||
assert {:ok, media} =
|
||||
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Linked Post",
|
||||
content: "Body"
|
||||
})
|
||||
|
||||
now = BDS.Persistence.now_ms()
|
||||
|
||||
Repo.insert_all("post_media", [
|
||||
%{
|
||||
id: Ecto.UUID.generate(),
|
||||
project_id: project.id,
|
||||
post_id: post.id,
|
||||
media_id: media.id,
|
||||
sort_order: 0,
|
||||
created_at: now
|
||||
}
|
||||
])
|
||||
|
||||
assert {:ok, _updated_media} = BDS.Media.update_media(media.id, %{})
|
||||
|
||||
sidecar_before = File.read!(Path.join(temp_dir, media.sidecar_path))
|
||||
assert sidecar_before =~ "linkedPostIds: [\"#{post.id}\"]\n"
|
||||
|
||||
assert {:ok, :deleted} = BDS.Posts.delete_post(post.id)
|
||||
|
||||
refute Repo.exists?(from row in "post_media", where: field(row, :media_id) == ^media.id)
|
||||
|
||||
sidecar_after = File.read!(Path.join(temp_dir, media.sidecar_path))
|
||||
assert sidecar_after =~ "linkedPostIds: []\n"
|
||||
end
|
||||
|
||||
test "rebuild_media_from_files recreates media rows from sidecars", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
|
||||
@@ -1,17 +1,62 @@
|
||||
defmodule BDS.PostTranslationsTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.Media
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
alias BDS.Repo
|
||||
|
||||
defmodule FakeRuntime do
|
||||
def generate(endpoint, request, opts) do
|
||||
test_pid = Keyword.fetch!(opts, :test_pid)
|
||||
send(test_pid, {:runtime_request, endpoint, request})
|
||||
|
||||
case request.operation do
|
||||
:translate_post ->
|
||||
{:ok,
|
||||
%{
|
||||
json: %{
|
||||
"title" => "Hallo Welt",
|
||||
"excerpt" => "Kurze Zusammenfassung",
|
||||
"content" => "# Hallo Welt\n\nUbersetzter Inhalt"
|
||||
},
|
||||
usage: %{input_tokens: 22, output_tokens: 14, cache_read_tokens: 0, cache_write_tokens: 0}
|
||||
}}
|
||||
|
||||
:translate_media ->
|
||||
{:ok,
|
||||
%{
|
||||
json: %{
|
||||
"title" => "Medientitel",
|
||||
"alt" => "Medien Alt",
|
||||
"caption" => "Medien Beschriftung"
|
||||
},
|
||||
usage: %{input_tokens: 12, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0}
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||
:ok = BDS.Tasks.clear_finished()
|
||||
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}")
|
||||
|
||||
File.mkdir_p!(temp_dir)
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
original_posts_config = Application.get_env(:bds, :posts, [])
|
||||
|
||||
on_exit(fn ->
|
||||
File.rm_rf(temp_dir)
|
||||
Application.put_env(:bds, :posts, original_posts_config)
|
||||
_ = BDS.Tasks.clear_finished()
|
||||
end)
|
||||
|
||||
{:ok, project} = BDS.Projects.create_project(%{name: "Translations", data_path: temp_dir})
|
||||
%{project: project, temp_dir: temp_dir}
|
||||
@@ -75,11 +120,119 @@ defmodule BDS.PostTranslationsTest do
|
||||
assert reopened_translation.content == "Neuer Entwurf"
|
||||
assert reopened_translation.updated_at >= published_translation.updated_at
|
||||
|
||||
reopened_source = Posts.get_post!(post.id)
|
||||
assert reopened_source.status == :draft
|
||||
assert reopened_source.content == "Hello world"
|
||||
assert reopened_source.file_path ==
|
||||
String.replace_suffix(published_translation.file_path, ".de.md", ".md")
|
||||
|
||||
assert {:ok, :deleted} = Posts.delete_post_translation(reopened_translation.id)
|
||||
refute File.exists?(translation_path)
|
||||
assert {:ok, []} = Posts.list_post_translations(post.id)
|
||||
end
|
||||
|
||||
test "create_post enqueues and completes auto-translation tasks for missing project languages",
|
||||
%{project: project} do
|
||||
configure_auto_translation_test_runtime()
|
||||
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
main_language: "en",
|
||||
blog_languages: ["en", "de", "fr"]
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Hello World",
|
||||
excerpt: "Short summary",
|
||||
content: "# Hello\n\nSource body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
translations = wait_for_post_translations(post.id, ["de", "fr"])
|
||||
|
||||
assert Enum.map(translations, & &1.language) == ["de", "fr"]
|
||||
assert Enum.all?(translations, &(&1.title == "Hallo Welt"))
|
||||
assert Enum.all?(translations, &(&1.excerpt == "Kurze Zusammenfassung"))
|
||||
assert Enum.all?(translations, &(&1.content == "# Hallo Welt\n\nUbersetzter Inhalt"))
|
||||
|
||||
tasks = wait_for_ai_tasks(2)
|
||||
|
||||
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to de" and &1.status == :completed))
|
||||
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to fr" and &1.status == :completed))
|
||||
assert Enum.all?(tasks, &(&1.group_name == "AI"))
|
||||
end
|
||||
|
||||
test "update_post auto-translates missing languages and cascades linked media translations",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
configure_auto_translation_test_runtime()
|
||||
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
main_language: "en",
|
||||
blog_languages: ["en", "de"]
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Needs Translation",
|
||||
excerpt: "Draft excerpt",
|
||||
content: "Source body",
|
||||
language: "en",
|
||||
do_not_translate: true
|
||||
})
|
||||
|
||||
source_path = Path.join(temp_dir, "linked-media.txt")
|
||||
File.write!(source_path, "linked media")
|
||||
|
||||
assert {:ok, media} =
|
||||
Media.import_media(%{
|
||||
project_id: project.id,
|
||||
source_path: source_path,
|
||||
title: "Source Media",
|
||||
alt: "Source Alt",
|
||||
caption: "Source Caption",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
Repo.insert_all("post_media", [
|
||||
%{
|
||||
id: Ecto.UUID.generate(),
|
||||
project_id: project.id,
|
||||
post_id: post.id,
|
||||
media_id: media.id,
|
||||
sort_order: 0,
|
||||
created_at: BDS.Persistence.now_ms()
|
||||
}
|
||||
])
|
||||
|
||||
assert {:ok, _updated_post} =
|
||||
Posts.update_post(post.id, %{
|
||||
do_not_translate: false,
|
||||
content: "Updated source body"
|
||||
})
|
||||
|
||||
[translation] = wait_for_post_translations(post.id, ["de"])
|
||||
assert translation.language == "de"
|
||||
|
||||
media_translation = wait_for_media_translation(media.id, "de")
|
||||
assert media_translation.title == "Medientitel"
|
||||
assert media_translation.alt == "Medien Alt"
|
||||
assert media_translation.caption == "Medien Beschriftung"
|
||||
|
||||
tasks = wait_for_ai_tasks(2)
|
||||
|
||||
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to de" and &1.status == :completed))
|
||||
|
||||
assert Enum.any?(tasks, fn task ->
|
||||
task.name == "Auto-translate Media to de" and task.status == :completed
|
||||
end)
|
||||
|
||||
assert File.exists?(Path.join(temp_dir, media.file_path <> ".de.meta"))
|
||||
end
|
||||
|
||||
test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
assert {:ok, _metadata} =
|
||||
@@ -145,4 +298,84 @@ defmodule BDS.PostTranslationsTest do
|
||||
assert report.orphan_files == ["posts/2026/04/orphan.fr.md"]
|
||||
assert report.do_not_translate_posts == [ignored_post.id]
|
||||
end
|
||||
|
||||
defp configure_auto_translation_test_runtime do
|
||||
assert {:ok, _endpoint} =
|
||||
AI.put_endpoint(
|
||||
:online,
|
||||
%{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: "online-secret",
|
||||
model: "gpt-4o-mini"
|
||||
}
|
||||
)
|
||||
|
||||
assert :ok = AI.set_airplane_mode(false)
|
||||
assert :ok = AI.put_model_preference(:title, "gpt-4.1-mini")
|
||||
|
||||
Application.put_env(:bds, :posts,
|
||||
auto_translation_ai_opts: [
|
||||
runtime: FakeRuntime,
|
||||
test_pid: self()
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp wait_for_post_translations(post_id, languages, attempts \\ 100)
|
||||
|
||||
defp wait_for_post_translations(_post_id, _languages, 0) do
|
||||
flunk("post translations did not reach expected state")
|
||||
end
|
||||
|
||||
defp wait_for_post_translations(post_id, languages, attempts) do
|
||||
{:ok, translations} = Posts.list_post_translations(post_id)
|
||||
expected = Enum.sort(languages)
|
||||
|
||||
if Enum.sort(Enum.map(translations, & &1.language)) == expected do
|
||||
translations
|
||||
else
|
||||
Process.sleep(20)
|
||||
wait_for_post_translations(post_id, languages, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_for_media_translation(media_id, language, attempts \\ 100)
|
||||
|
||||
defp wait_for_media_translation(_media_id, _language, 0) do
|
||||
flunk("media translation did not reach expected state")
|
||||
end
|
||||
|
||||
defp wait_for_media_translation(media_id, language, attempts) do
|
||||
translation =
|
||||
Repo.one(
|
||||
from translation in BDS.Media.Translation,
|
||||
where: translation.translation_for == ^media_id and translation.language == ^language
|
||||
)
|
||||
|
||||
if translation do
|
||||
translation
|
||||
else
|
||||
Process.sleep(20)
|
||||
wait_for_media_translation(media_id, language, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_for_ai_tasks(count, attempts \\ 100)
|
||||
|
||||
defp wait_for_ai_tasks(_count, 0) do
|
||||
flunk("AI tasks did not reach expected state")
|
||||
end
|
||||
|
||||
defp wait_for_ai_tasks(count, attempts) do
|
||||
tasks =
|
||||
BDS.Tasks.list_tasks()
|
||||
|> Enum.filter(&(&1.group_name == "AI"))
|
||||
|
||||
if length(tasks) >= count and Enum.all?(tasks, &(&1.status == :completed)) do
|
||||
tasks
|
||||
else
|
||||
Process.sleep(20)
|
||||
wait_for_ai_tasks(count, attempts - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user