diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d43d72..4c535929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `POST /sources/{id}/retry` no longer returns `400 "Source is not associated with any notebooks"` for every source; it now queries the `reference` graph edge by its `in`/`out` columns instead of a non-existent `source` column (#861) + ## [1.9.0] - 2026-06-02 ### Added diff --git a/api/routers/sources.py b/api/routers/sources.py index 9c4b7452..15c53ca3 100644 --- a/api/routers/sources.py +++ b/api/routers/sources.py @@ -842,10 +842,15 @@ async def retry_source_processing(source_id: str): ) # Continue with retry if we can't check status - # Get notebooks that this source belongs to - query = "SELECT notebook FROM reference WHERE source = $source_id" - references = await repo_query(query, {"source_id": source_id}) - notebook_ids = [str(ref["notebook"]) for ref in references] + # Get notebooks that this source belongs to. `reference` is a graph edge + # (RELATE source->reference->notebook), so it only has `in`/`out` columns — + # there is no `source`/`notebook` column. Mirror the working query at the + # source-list path above. See issue #861. + references = await repo_query( + "SELECT VALUE out FROM reference WHERE in = $source_id", + {"source_id": ensure_record_id(source.id or source_id)}, + ) + notebook_ids = [str(nb_id) for nb_id in references] if references else [] if not notebook_ids: raise HTTPException( diff --git a/tests/test_sources_api.py b/tests/test_sources_api.py index f33cf6bd..c18ea426 100644 --- a/tests/test_sources_api.py +++ b/tests/test_sources_api.py @@ -138,5 +138,57 @@ class TestAsyncSourceAssetPersistence: assert source.asset is None +class TestRetrySourceProcessing: + """POST /sources/{id}/retry must find a source's notebooks via the reference + edge's in/out columns, not a non-existent `source` column (#861).""" + + @pytest.mark.asyncio + @patch("api.routers.sources.CommandService.submit_command_job", new_callable=AsyncMock) + @patch("api.routers.sources.repo_query", new_callable=AsyncMock) + @patch("api.routers.sources.Source.get", new_callable=AsyncMock) + async def test_retry_finds_notebooks_and_requeues( + self, mock_get, mock_repo_query, mock_submit, client + ): + source = MagicMock() + source.id = "source:1" + source.command = None + source.title = "My source" + source.topics = [] + source.full_text = None + source.asset = MagicMock(file_path=None, url="https://example.com/post") + source.save = AsyncMock() + source.get_embedded_chunks = AsyncMock(return_value=0) + mock_get.return_value = source + + # The corrected query returns the linked notebook(s) + mock_repo_query.return_value = ["notebook:1"] + mock_submit.return_value = "123" + + response = client.post("/api/sources/source:1/retry") + + assert response.status_code == 200 + # Regression guard: must query the reference edge by its `in` column + called_query = mock_repo_query.await_args.args[0] + assert "WHERE in = $source_id" in called_query + assert "SELECT VALUE out FROM reference" in called_query + + @pytest.mark.asyncio + @patch("api.routers.sources.repo_query", new_callable=AsyncMock) + @patch("api.routers.sources.Source.get", new_callable=AsyncMock) + async def test_retry_400_only_when_truly_unlinked( + self, mock_get, mock_repo_query, client + ): + source = MagicMock() + source.id = "source:1" + source.command = None + mock_get.return_value = source + mock_repo_query.return_value = [] # genuinely no notebooks + + response = client.post("/api/sources/source:1/retry") + + assert response.status_code == 400 + assert "not associated with any notebooks" in response.json()["detail"] + + if __name__ == "__main__": pytest.main([__file__, "-v"])