Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8290cfe
feat: background job system
leonardmq May 28, 2026
90b200d
harness for testing eval running through job
leonardmq May 28, 2026
aed9af9
chore: build annotations
leonardmq May 28, 2026
509e160
feat: support waiting for job
leonardmq May 28, 2026
18d5988
feat: job widget + dialog
leonardmq May 29, 2026
45c17ec
refactor: link in sidebar rail
leonardmq May 29, 2026
fc5f6f6
refactor: job entry in sidebar
leonardmq May 29, 2026
34f10db
fix: guard task.uncancel() for Python 3.10 in job registry tests
leonardmq Jun 4, 2026
180224c
Merge branch 'main' of github.com:Kiln-AI/Kiln into leonard/kil-686-f…
leonardmq Jun 4, 2026
589d8dd
fix: address PR review on background job system
leonardmq Jun 12, 2026
1b19939
feat: typed per-worker progress payload
leonardmq Jun 12, 2026
a14d73f
chore: fmt
leonardmq Jun 15, 2026
ecc3e58
Merge pull request #1463 from Kiln-AI/leonard/kil-686-jobs-typed-payl…
leonardmq Jun 15, 2026
b2ccf5f
Merge branch 'main' of github.com:Kiln-AI/Kiln into leonard/kil-686-f…
leonardmq Jun 15, 2026
8b9199f
feat: rename sidebar "In progress" to "Jobs" with active-state styling
leonardmq Jun 16, 2026
ad9c241
fix: harden job reconcile + offload eval compute_state IO
leonardmq Jun 16, 2026
824edec
fix: keep jobs SSE stream alive past keepalive + close it on shutdown
leonardmq Jun 16, 2026
619a915
refactor: ui redesign for jobs
leonardmq Jun 17, 2026
a440f19
fix: drop unsatisfiable networkidle wait in docs-library e2e test
leonardmq Jun 17, 2026
406ce32
chore: delete spec project
leonardmq Jun 17, 2026
3a33b65
Merge branch 'main' of github.com:Kiln-AI/Kiln into leonard/kil-686-f…
leonardmq Jun 17, 2026
7a63c89
Merge branch 'main' of github.com:Kiln-AI/Kiln into leonard/kil-686-f…
leonardmq Jun 19, 2026
922eff4
refactor: use intro component for job table
leonardmq Jun 24, 2026
47b24c4
refactor: three dots dropdown instead of buttons
leonardmq Jun 24, 2026
aa9de8b
refactor: one string subtitle in modal
leonardmq Jun 24, 2026
424e4fb
refactor: move jobs entry to below App Update Available in sidebar
leonardmq Jun 24, 2026
b2040c3
fix: maybe fix wrapping badge
leonardmq Jun 24, 2026
b254de3
Merge branch 'main' of github.com:Kiln-AI/Kiln into leonard/kil-686-f…
leonardmq Jun 24, 2026
97cf700
refactor: tighten up jobs empty-state copy
leonardmq Jun 24, 2026
d978693
feat: gate Jobs sidebar behind PUBLIC_ENABLE_JOBS flag
leonardmq Jun 24, 2026
53d75bd
chore: remove Run eval example job from jobs system
leonardmq Jun 24, 2026
887bcc0
test: update jobs_table tests for 3-dot dropdown actions
leonardmq Jun 24, 2026
749d951
Merge branch 'main' of github.com:Kiln-AI/Kiln into leonard/kil-686-f…
leonardmq Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/desktop/desktop_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
from app.desktop.studio_server.eval_api import connect_evals_api
from app.desktop.studio_server.finetune_api import connect_fine_tune_api
from app.desktop.studio_server.import_api import connect_import_api
from app.desktop.studio_server.jobs.api import connect_jobs_api
from app.desktop.studio_server.jobs.registry import job_registry
from app.desktop.studio_server.prompt_api import connect_prompt_api
from app.desktop.studio_server.prompt_optimization_job_api import (
connect_prompt_optimization_job_api,
Expand Down Expand Up @@ -111,6 +113,12 @@ async def lifespan(app: FastAPI):
await _start_background_syncs()
yield
finally:
# End open SSE subscriptions so a UI holding the jobs stream open can't
# keep the worker alive (e.g. block a dev-server hot reload). Pure
# observer teardown — jobs keep running. Note uvicorn only reaches
# lifespan shutdown after its graceful-shutdown wait, so the dev server
# also sets timeout_graceful_shutdown to bound that wait.
job_registry.events.shutdown()
try:
await _stop_background_syncs()
finally:
Expand Down Expand Up @@ -146,6 +154,7 @@ def make_app(tk_root: tk.Tk | None = None):
connect_agent_api(app)
connect_dev_tools(app)
connect_chat_api(app)
connect_jobs_api(app)
# Important: webhost must be last, it handles all other URLs
connect_webhost(app)
return app
Expand Down
6 changes: 6 additions & 0 deletions app/desktop/dev_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,10 @@
reload=True,
# Debounce when changing many files (changing branch)
reload_delay=0.1,
# Bound the graceful-shutdown wait on reload. The UI holds the jobs SSE
# stream open; uvicorn waits for in-flight requests to finish BEFORE it
# runs lifespan shutdown (which closes the stream), so without a bound a
# reload would hang on the open SSE. After this many seconds uvicorn
# cancels the lingering request task instead.
timeout_graceful_shutdown=1,
)
6 changes: 5 additions & 1 deletion app/desktop/git_sync/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ def _resolve_endpoint(self, request: Request) -> Callable[..., Any] | None:
return None

def _get_manager_for_request(self, request: Request) -> GitSyncManager | None:
"""Extract project_id from URL, resolve to path, return manager if auto-sync enabled."""
"""Extract project_id from URL, resolve to path, return manager if auto-sync enabled.

Keep the project_id -> manager resolution below in sync with the request-free
copy in save_context.get_manager_for_project (used by background job workers).
"""
match = PROJECT_ID_PATTERN.match(request.url.path)
if match is None:
return None
Expand Down
66 changes: 66 additions & 0 deletions app/desktop/git_sync/save_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

from pathlib import Path

from kiln_ai.utils.git_sync_protocols import SaveContext

from app.desktop.git_sync.config import get_git_sync_config, project_path_from_id
from app.desktop.git_sync.git_sync_manager import GitSyncManager
from app.desktop.git_sync.registry import GitSyncRegistry


def get_manager_for_project(project_id: str) -> GitSyncManager | None:
"""Resolve a project_id to its GitSyncManager when auto-sync is active.

Request-free mirror of GitSyncMiddleware._get_manager_for_request (minus the
URL parsing). Returns None for every "not active" branch: the project has no
path, no git-sync config, sync_mode is not "auto", or no clone_path is set.

Config is keyed by project_path; the manager is keyed by clone_path. The
manager is always obtained via GitSyncRegistry.get_or_create so the single
per-clone-path manager (and its executor + non-reentrant write lock) is
shared with the HTTP path.
"""
project_path = project_path_from_id(project_id)
if project_path is None:
return None

config = get_git_sync_config(project_path)
if config is None:
return None

if config["sync_mode"] != "auto":
return None

clone_path = config.get("clone_path")
if clone_path is None:
return None

return GitSyncRegistry.get_or_create(
repo_path=Path(clone_path),
remote_name=config["remote_name"],
pat_token=config.get("pat_token"),
oauth_token=config.get("oauth_token"),
auth_mode=config["auth_mode"],
)


def save_context_for_project(project_id: str, context: str) -> SaveContext | None:
"""Return a SaveContext wrapping writes in manager.atomic_write(context=...),
or None when git sync is not active for this project.

Mirrors build_save_context(request) for callers that have only a project_id
(e.g. background job workers). Runners coalesce None to a no-op context.
"""
manager = get_manager_for_project(project_id)
if manager is None:
return None

bg_sync = GitSyncRegistry.get_background_sync(manager.repo_path)
if bg_sync is not None:
bg_sync.notify_request()

def factory():
return manager.atomic_write(context=context)

return factory
Comment thread
leonardmq marked this conversation as resolved.
219 changes: 219 additions & 0 deletions app/desktop/git_sync/test_save_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
from __future__ import annotations

from contextlib import ExitStack, asynccontextmanager
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from app.desktop.git_sync.config import GitSyncProjectConfig
from app.desktop.git_sync.save_context import (
get_manager_for_project,
save_context_for_project,
)

PROJECT_ID = "project_abc"
PROJECT_PATH = "/tmp/test/project.kiln"
CLONE_PATH = "/tmp/test/clone"


def _auto_config(clone_path: str | None = CLONE_PATH) -> GitSyncProjectConfig:
return GitSyncProjectConfig(
sync_mode="auto",
auth_mode="system_keys",
remote_name="origin",
branch="main",
clone_path=clone_path,
git_url=None,
pat_token=None,
oauth_token=None,
)


def _manual_config() -> GitSyncProjectConfig:
return GitSyncProjectConfig(
sync_mode="manual",
auth_mode="system_keys",
remote_name="origin",
branch="main",
clone_path=CLONE_PATH,
git_url=None,
pat_token=None,
oauth_token=None,
)


class _FakeManager:
"""Minimal AtomicWriteCapable stand-in that records atomic_write calls."""

def __init__(self, repo_path: Path = Path(CLONE_PATH)):
self.repo_path = repo_path
self.calls: list[str] = []
self.entered = False

@asynccontextmanager
async def atomic_write(self, context: str):
self.calls.append(context)
self.entered = True
yield


def _patch_resolution(project_path, config, manager=None, bg_sync=None):
"""Patch the config + registry calls used by the helper.

project_path_from_id and get_git_sync_config are looked up in the
save_context module namespace, so patch them there.
"""
stack = ExitStack()
stack.enter_context(
patch(
"app.desktop.git_sync.save_context.project_path_from_id",
return_value=project_path,
)
)
stack.enter_context(
patch(
"app.desktop.git_sync.save_context.get_git_sync_config",
return_value=config,
)
)
stack.enter_context(
patch(
"app.desktop.git_sync.save_context.GitSyncRegistry.get_or_create",
return_value=manager,
)
)
stack.enter_context(
patch(
"app.desktop.git_sync.save_context.GitSyncRegistry.get_background_sync",
return_value=bg_sync,
)
)
return stack


# -- None branches -----------------------------------------------------------


def test_returns_none_when_no_project_path():
with _patch_resolution(project_path=None, config=None):
assert save_context_for_project(PROJECT_ID, context="ctx") is None
assert get_manager_for_project(PROJECT_ID) is None


def test_returns_none_when_no_git_sync_config():
with _patch_resolution(project_path=PROJECT_PATH, config=None):
assert save_context_for_project(PROJECT_ID, context="ctx") is None
assert get_manager_for_project(PROJECT_ID) is None


def test_returns_none_when_sync_mode_not_auto():
with _patch_resolution(project_path=PROJECT_PATH, config=_manual_config()):
assert save_context_for_project(PROJECT_ID, context="ctx") is None
assert get_manager_for_project(PROJECT_ID) is None


def test_returns_none_when_clone_path_missing():
with _patch_resolution(
project_path=PROJECT_PATH, config=_auto_config(clone_path=None)
):
assert save_context_for_project(PROJECT_ID, context="ctx") is None
assert get_manager_for_project(PROJECT_ID) is None


# -- active branches ---------------------------------------------------------


def test_get_manager_uses_registry_with_config_values():
manager = _FakeManager()
with (
patch(
"app.desktop.git_sync.save_context.project_path_from_id",
return_value=PROJECT_PATH,
),
patch(
"app.desktop.git_sync.save_context.get_git_sync_config",
return_value=_auto_config(),
),
patch(
"app.desktop.git_sync.save_context.GitSyncRegistry.get_or_create",
return_value=manager,
) as mock_get_or_create,
):
result = get_manager_for_project(PROJECT_ID)

assert result is manager
mock_get_or_create.assert_called_once_with(
repo_path=Path(CLONE_PATH),
remote_name="origin",
pat_token=None,
oauth_token=None,
auth_mode="system_keys",
)


async def test_save_context_enters_atomic_write_with_label():
manager = _FakeManager()
with _patch_resolution(
project_path=PROJECT_PATH, config=_auto_config(), manager=manager
):
save_context = save_context_for_project(PROJECT_ID, context="eval job e1/r1")

assert save_context is not None
assert manager.entered is False # built lazily, not yet entered

async with save_context():
pass

assert manager.calls == ["eval job e1/r1"]


def test_save_context_notifies_background_sync():
manager = _FakeManager()
bg_sync = MagicMock()
with _patch_resolution(
project_path=PROJECT_PATH,
config=_auto_config(),
manager=manager,
bg_sync=bg_sync,
):
save_context = save_context_for_project(PROJECT_ID, context="ctx")

assert save_context is not None
bg_sync.notify_request.assert_called_once()


def test_save_context_no_background_sync_is_fine():
manager = _FakeManager()
with _patch_resolution(
project_path=PROJECT_PATH,
config=_auto_config(),
manager=manager,
bg_sync=None,
):
save_context = save_context_for_project(PROJECT_ID, context="ctx")

assert save_context is not None


# -- error propagation -------------------------------------------------------


def test_propagates_when_config_lookup_raises():
# A corrupt/raising config lookup must surface (failing the job) rather than
# be swallowed to None, which would silently skip commits for an auto-sync
# project — the very bug this resolver exists to prevent.
with (
patch(
"app.desktop.git_sync.save_context.project_path_from_id",
return_value=PROJECT_PATH,
),
patch(
"app.desktop.git_sync.save_context.get_git_sync_config",
side_effect=RuntimeError("corrupt config"),
),
):
with pytest.raises(RuntimeError, match="corrupt config"):
get_manager_for_project(PROJECT_ID)
with pytest.raises(RuntimeError, match="corrupt config"):
save_context_for_project(PROJECT_ID, context="ctx")
Empty file.
Loading
Loading