kodit 0.4.2__py3-none-any.whl → 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kodit might be problematic. Click here for more details.

Files changed (34) hide show
  1. kodit/_version.py +2 -2
  2. kodit/app.py +6 -1
  3. kodit/application/factories/code_indexing_factory.py +14 -12
  4. kodit/application/factories/reporting_factory.py +10 -5
  5. kodit/application/services/auto_indexing_service.py +28 -32
  6. kodit/application/services/code_indexing_application_service.py +43 -26
  7. kodit/application/services/indexing_worker_service.py +10 -12
  8. kodit/application/services/reporting.py +72 -54
  9. kodit/cli.py +68 -78
  10. kodit/config.py +2 -2
  11. kodit/domain/entities.py +99 -1
  12. kodit/domain/protocols.py +28 -3
  13. kodit/domain/services/index_service.py +11 -9
  14. kodit/domain/services/task_status_query_service.py +19 -0
  15. kodit/domain/value_objects.py +26 -29
  16. kodit/infrastructure/api/v1/dependencies.py +19 -4
  17. kodit/infrastructure/api/v1/routers/indexes.py +45 -0
  18. kodit/infrastructure/api/v1/schemas/task_status.py +39 -0
  19. kodit/infrastructure/cloning/git/working_copy.py +9 -2
  20. kodit/infrastructure/enrichment/local_enrichment_provider.py +41 -30
  21. kodit/infrastructure/mappers/task_status_mapper.py +85 -0
  22. kodit/infrastructure/reporting/db_progress.py +23 -0
  23. kodit/infrastructure/reporting/log_progress.py +5 -33
  24. kodit/infrastructure/reporting/tdqm_progress.py +10 -45
  25. kodit/infrastructure/sqlalchemy/entities.py +61 -0
  26. kodit/infrastructure/sqlalchemy/task_status_repository.py +79 -0
  27. kodit/mcp.py +6 -2
  28. kodit/migrations/env.py +0 -1
  29. kodit/migrations/versions/b9cd1c3fd762_add_task_status.py +77 -0
  30. {kodit-0.4.2.dist-info → kodit-0.4.3.dist-info}/METADATA +1 -1
  31. {kodit-0.4.2.dist-info → kodit-0.4.3.dist-info}/RECORD +34 -28
  32. {kodit-0.4.2.dist-info → kodit-0.4.3.dist-info}/WHEEL +0 -0
  33. {kodit-0.4.2.dist-info → kodit-0.4.3.dist-info}/entry_points.txt +0 -0
  34. {kodit-0.4.2.dist-info → kodit-0.4.3.dist-info}/licenses/LICENSE +0 -0
kodit/cli.py CHANGED
@@ -183,56 +183,52 @@ async def _index_local(
183
183
 
184
184
  # Get database session
185
185
  db = await app_context.get_db()
186
- async with db.session_factory() as session:
187
- service = create_cli_code_indexing_application_service(
188
- app_context=app_context,
189
- session=session,
190
- session_factory=db.session_factory,
191
- )
192
- index_query_service = IndexQueryService(
193
- index_repository=create_index_repository(
194
- session_factory=db.session_factory
195
- ),
196
- fusion_service=ReciprocalRankFusionService(),
197
- )
198
-
199
- if auto_index:
200
- sources = await _handle_auto_index(app_context, sources)
201
- if not sources:
202
- return
203
-
204
- if sync:
205
- await _handle_sync(service, index_query_service, sources)
206
- return
186
+ service = create_cli_code_indexing_application_service(
187
+ app_context=app_context,
188
+ session_factory=db.session_factory,
189
+ )
190
+ index_query_service = IndexQueryService(
191
+ index_repository=create_index_repository(session_factory=db.session_factory),
192
+ fusion_service=ReciprocalRankFusionService(),
193
+ )
207
194
 
195
+ if auto_index:
196
+ sources = await _handle_auto_index(app_context, sources)
208
197
  if not sources:
209
- await _handle_list_indexes(index_query_service)
210
198
  return
211
199
 
212
- # Handle source indexing
213
- for source in sources:
214
- if Path(source).is_file():
215
- msg = "File indexing is not implemented yet"
216
- raise click.UsageError(msg)
217
-
218
- # Index source with progress
219
- log_event("kodit.cli.index.create")
220
-
221
- # Create a lazy progress callback that only shows progress when needed
222
- index = await service.create_index_from_uri(source)
223
-
224
- # Create a new progress callback for the indexing operations
225
- try:
226
- await service.run_index(index)
227
- except EmptySourceError as e:
228
- log.exception("Empty source error", error=e)
229
- msg = f"""{e}. This could mean:
200
+ if sync:
201
+ await _handle_sync(service, index_query_service, sources)
202
+ return
203
+
204
+ if not sources:
205
+ await _handle_list_indexes(index_query_service)
206
+ return
207
+
208
+ # Handle source indexing
209
+ for source in sources:
210
+ if Path(source).is_file():
211
+ msg = "File indexing is not implemented yet"
212
+ raise click.UsageError(msg)
213
+
214
+ # Index source with progress
215
+ log_event("kodit.cli.index.create")
216
+
217
+ # Create a lazy progress callback that only shows progress when needed
218
+ index = await service.create_index_from_uri(source)
219
+
220
+ # Create a new progress callback for the indexing operations
221
+ try:
222
+ await service.run_index(index)
223
+ except EmptySourceError as e:
224
+ log.exception("Empty source error", error=e)
225
+ msg = f"""{e}. This could mean:
230
226
  • The repository contains no supported file types
231
227
  • All files are excluded by ignore patterns
232
228
  • The files contain no extractable code snippets
233
229
  Please check the repository contents and try again.
234
230
  """
235
- click.echo(msg)
231
+ click.echo(msg)
236
232
 
237
233
 
238
234
  async def _index_remote(
@@ -319,35 +315,33 @@ async def _search_local( # noqa: PLR0913
319
315
 
320
316
  # Get database session
321
317
  db = await app_context.get_db()
322
- async with db.session_factory() as session:
323
- service = create_cli_code_indexing_application_service(
324
- app_context=app_context,
325
- session=session,
326
- session_factory=db.session_factory,
327
- )
318
+ service = create_cli_code_indexing_application_service(
319
+ app_context=app_context,
320
+ session_factory=db.session_factory,
321
+ )
328
322
 
329
- filters = _parse_filters(
330
- language, author, created_after, created_before, source_repo
331
- )
323
+ filters = _parse_filters(
324
+ language, author, created_after, created_before, source_repo
325
+ )
332
326
 
333
- snippets = await service.search(
334
- MultiSearchRequest(
335
- keywords=keywords,
336
- code_query=code_query,
337
- text_query=text_query,
338
- top_k=top_k,
339
- filters=filters,
340
- )
327
+ snippets = await service.search(
328
+ MultiSearchRequest(
329
+ keywords=keywords,
330
+ code_query=code_query,
331
+ text_query=text_query,
332
+ top_k=top_k,
333
+ filters=filters,
341
334
  )
335
+ )
342
336
 
343
- if len(snippets) == 0:
344
- click.echo("No snippets found")
345
- return
337
+ if len(snippets) == 0:
338
+ click.echo("No snippets found")
339
+ return
346
340
 
347
- if output_format == "text":
348
- click.echo(MultiSearchResult.to_string(snippets))
349
- elif output_format == "json":
350
- click.echo(MultiSearchResult.to_jsonlines(snippets))
341
+ if output_format == "text":
342
+ click.echo(MultiSearchResult.to_string(snippets))
343
+ elif output_format == "json":
344
+ click.echo(MultiSearchResult.to_jsonlines(snippets))
351
345
 
352
346
 
353
347
  async def _search_remote( # noqa: PLR0913
@@ -785,19 +779,15 @@ async def snippets(
785
779
  # Local mode
786
780
  log_event("kodit.cli.show.snippets")
787
781
  db = await app_context.get_db()
788
- async with db.session_factory() as session:
789
- service = create_cli_code_indexing_application_service(
790
- app_context=app_context,
791
- session=session,
792
- session_factory=db.session_factory,
793
- )
794
- snippets = await service.list_snippets(
795
- file_path=by_path, source_uri=by_source
796
- )
797
- if output_format == "text":
798
- click.echo(MultiSearchResult.to_string(snippets))
799
- elif output_format == "json":
800
- click.echo(MultiSearchResult.to_jsonlines(snippets))
782
+ service = create_cli_code_indexing_application_service(
783
+ app_context=app_context,
784
+ session_factory=db.session_factory,
785
+ )
786
+ snippets = await service.list_snippets(file_path=by_path, source_uri=by_source)
787
+ if output_format == "text":
788
+ click.echo(MultiSearchResult.to_string(snippets))
789
+ elif output_format == "json":
790
+ click.echo(MultiSearchResult.to_jsonlines(snippets))
801
791
  else:
802
792
  # Remote mode - not supported
803
793
  click.echo("⚠️ Warning: 'show snippets' is not implemented in remote mode")
kodit/config.py CHANGED
@@ -305,7 +305,7 @@ class AppContext(BaseSettings):
305
305
  with_app_context = click.make_pass_decorator(AppContext)
306
306
 
307
307
 
308
- def wrap_async(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
308
+ def wrap_async[T](f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
309
309
  """Decorate async Click commands.
310
310
 
311
311
  This decorator wraps an async function to run it with asyncio.run().
@@ -326,7 +326,7 @@ def wrap_async(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
326
326
  return wrapper
327
327
 
328
328
 
329
- def with_session(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
329
+ def with_session[T](f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
330
330
  """Provide a database session to CLI commands."""
331
331
 
332
332
  @wraps(f)
kodit/domain/entities.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import shutil
4
4
  from dataclasses import dataclass
5
- from datetime import datetime
5
+ from datetime import UTC, datetime
6
6
  from pathlib import Path
7
7
  from typing import Any, Protocol
8
8
  from urllib.parse import urlparse, urlunparse
@@ -12,10 +12,13 @@ from pydantic import AnyUrl, BaseModel
12
12
  from kodit.domain.value_objects import (
13
13
  FileProcessingStatus,
14
14
  QueuePriority,
15
+ ReportingState,
15
16
  SnippetContent,
16
17
  SnippetContentType,
17
18
  SourceType,
19
+ TaskOperation,
18
20
  TaskType,
21
+ TrackableType,
19
22
  )
20
23
  from kodit.utils.path_utils import path_from_uri
21
24
 
@@ -321,3 +324,98 @@ class Task(BaseModel):
321
324
  priority=priority.value,
322
325
  payload={"index_id": index_id},
323
326
  )
327
+
328
+
329
+ class TaskStatus(BaseModel):
330
+ """Task status domain entity."""
331
+
332
+ id: str
333
+ state: ReportingState
334
+ operation: TaskOperation
335
+ message: str = ""
336
+
337
+ created_at: datetime = datetime.now(UTC)
338
+ updated_at: datetime = datetime.now(UTC)
339
+ total: int = 0
340
+ current: int = 0
341
+
342
+ error: str | None = None
343
+ parent: "TaskStatus | None" = None
344
+ trackable_id: int | None = None
345
+ trackable_type: TrackableType | None = None
346
+
347
+ @staticmethod
348
+ def create(
349
+ operation: TaskOperation,
350
+ parent: "TaskStatus | None" = None,
351
+ trackable_type: TrackableType | None = None,
352
+ trackable_id: int | None = None,
353
+ ) -> "TaskStatus":
354
+ """Create a task status."""
355
+ return TaskStatus(
356
+ id=TaskStatus._create_id(operation, trackable_type, trackable_id),
357
+ operation=operation,
358
+ parent=parent,
359
+ trackable_type=trackable_type,
360
+ trackable_id=trackable_id,
361
+ state=ReportingState.STARTED,
362
+ )
363
+
364
+ @staticmethod
365
+ def _create_id(
366
+ step: TaskOperation,
367
+ trackable_type: TrackableType | None = None,
368
+ trackable_id: int | None = None,
369
+ ) -> str:
370
+ """Create a unique id for a task."""
371
+ result = []
372
+ # Nice to be prefixed by tracking information if it exists
373
+ if trackable_type:
374
+ result.append(str(trackable_type))
375
+ if trackable_id:
376
+ result.append(str(trackable_id))
377
+ result.append(str(step))
378
+ return "-".join(result)
379
+
380
+ @property
381
+ def completion_percent(self) -> float:
382
+ """Calculate the percentage of completion."""
383
+ if self.total == 0:
384
+ return 0.0
385
+ return min(100.0, max(0.0, (self.current / self.total) * 100.0))
386
+
387
+ def skip(self, message: str) -> None:
388
+ """Skip the task."""
389
+ self.state = ReportingState.SKIPPED
390
+ self.message = message
391
+
392
+ def fail(self, error: str) -> None:
393
+ """Fail the task."""
394
+ self.state = ReportingState.FAILED
395
+ self.error = error
396
+
397
+ def set_total(self, total: int) -> None:
398
+ """Set the total for the step."""
399
+ self.total = total
400
+
401
+ def set_current(self, current: int, message: str | None = None) -> None:
402
+ """Progress the step."""
403
+ self.state = ReportingState.IN_PROGRESS
404
+ self.current = current
405
+ if message:
406
+ self.message = message
407
+
408
+ def set_tracking_info(
409
+ self, trackable_id: int, trackable_type: TrackableType
410
+ ) -> None:
411
+ """Set the tracking info."""
412
+ self.trackable_id = trackable_id
413
+ self.trackable_type = trackable_type
414
+
415
+ def complete(self) -> None:
416
+ """Complete the task."""
417
+ if ReportingState.is_terminal(self.state):
418
+ return # Already in terminal state
419
+
420
+ self.state = ReportingState.COMPLETED
421
+ self.current = self.total # Ensure progress shows 100%
kodit/domain/protocols.py CHANGED
@@ -5,8 +5,15 @@ from typing import Protocol
5
5
 
6
6
  from pydantic import AnyUrl
7
7
 
8
- from kodit.domain.entities import Index, Snippet, SnippetWithContext, Task, WorkingCopy
9
- from kodit.domain.value_objects import MultiSearchRequest, Progress, TaskType
8
+ from kodit.domain.entities import (
9
+ Index,
10
+ Snippet,
11
+ SnippetWithContext,
12
+ Task,
13
+ TaskStatus,
14
+ WorkingCopy,
15
+ )
16
+ from kodit.domain.value_objects import MultiSearchRequest, TaskType
10
17
 
11
18
 
12
19
  class TaskRepository(Protocol):
@@ -95,6 +102,24 @@ class IndexRepository(Protocol):
95
102
  class ReportingModule(Protocol):
96
103
  """Reporting module."""
97
104
 
98
- def on_change(self, step: Progress) -> None:
105
+ async def on_change(self, progress: TaskStatus) -> None:
99
106
  """On step changed."""
100
107
  ...
108
+
109
+
110
+ class TaskStatusRepository(Protocol):
111
+ """Repository interface for persisting progress state only."""
112
+
113
+ async def save(self, status: TaskStatus) -> None:
114
+ """Save a progress state."""
115
+ ...
116
+
117
+ async def load_with_hierarchy(
118
+ self, trackable_type: str, trackable_id: int
119
+ ) -> list[TaskStatus]:
120
+ """Load progress states with IDs and parent IDs from database."""
121
+ ...
122
+
123
+ async def delete(self, status: TaskStatus) -> None:
124
+ """Delete a progress state."""
125
+ ...
@@ -126,9 +126,9 @@ class IndexDomainService:
126
126
 
127
127
  # Calculate snippets for each language
128
128
  slicer = Slicer()
129
- step.set_total(len(lang_files_map.keys()))
129
+ await step.set_total(len(lang_files_map.keys()))
130
130
  for i, (lang, lang_files) in enumerate(lang_files_map.items()):
131
- step.set_current(i)
131
+ await step.set_current(i, f"Extracting snippets for {lang}")
132
132
  s = slicer.extract_snippets(lang_files, language=lang)
133
133
  index.snippets.extend(s)
134
134
 
@@ -142,10 +142,10 @@ class IndexDomainService:
142
142
  """Enrich snippets with AI-generated summaries."""
143
143
  reporting_step = reporting_step or create_noop_operation()
144
144
  if not snippets or len(snippets) == 0:
145
- reporting_step.skip("No snippets to enrich")
145
+ await reporting_step.skip("No snippets to enrich")
146
146
  return snippets
147
147
 
148
- reporting_step.set_total(len(snippets))
148
+ await reporting_step.set_total(len(snippets))
149
149
  snippet_map = {snippet.id: snippet for snippet in snippets if snippet.id}
150
150
 
151
151
  enrichment_request = EnrichmentIndexRequest(
@@ -162,7 +162,9 @@ class IndexDomainService:
162
162
  snippet_map[result.snippet_id].add_summary(result.text)
163
163
 
164
164
  processed += 1
165
- reporting_step.set_current(processed)
165
+ await reporting_step.set_current(
166
+ processed, f"Enriching snippets for {processed} snippets"
167
+ )
166
168
 
167
169
  return list(snippet_map.values())
168
170
 
@@ -222,12 +224,12 @@ class IndexDomainService:
222
224
 
223
225
  # Setup reporter
224
226
  processed = 0
225
- step.set_total(num_files_to_process)
227
+ await step.set_total(num_files_to_process)
226
228
 
227
229
  # First check to see if any files have been deleted
228
230
  for file_path in deleted_file_paths:
229
231
  processed += 1
230
- step.set_current(processed)
232
+ await step.set_current(processed, f"Deleting file {file_path}")
231
233
  previous_files_map[
232
234
  file_path
233
235
  ].file_processing_status = domain_entities.FileProcessingStatus.DELETED
@@ -235,7 +237,7 @@ class IndexDomainService:
235
237
  # Then check to see if there are any new files
236
238
  for file_path in new_file_paths:
237
239
  processed += 1
238
- step.set_current(processed)
240
+ await step.set_current(processed, f"Adding new file {file_path}")
239
241
  try:
240
242
  working_copy.files.append(
241
243
  await metadata_extractor.extract(file_path=file_path)
@@ -247,7 +249,7 @@ class IndexDomainService:
247
249
  # Finally check if there are any modified files
248
250
  for file_path in modified_file_paths:
249
251
  processed += 1
250
- step.set_current(processed)
252
+ await step.set_current(processed, f"Modifying file {file_path}")
251
253
  try:
252
254
  previous_file = previous_files_map[file_path]
253
255
  new_file = await metadata_extractor.extract(file_path=file_path)
@@ -0,0 +1,19 @@
1
+ """Domain service for querying task status."""
2
+
3
+ from kodit.domain.entities import TaskStatus
4
+ from kodit.domain.protocols import TaskStatusRepository
5
+ from kodit.domain.value_objects import TrackableType
6
+
7
+
8
+ class TaskStatusQueryService:
9
+ """Query service for task status information."""
10
+
11
+ def __init__(self, repository: TaskStatusRepository) -> None:
12
+ """Initialize the task status query service."""
13
+ self._repository = repository
14
+
15
+ async def get_index_status(self, index_id: int) -> list[TaskStatus]:
16
+ """Get the status of tasks for a specific index."""
17
+ return await self._repository.load_with_hierarchy(
18
+ trackable_type=TrackableType.INDEX.value, trackable_id=index_id
19
+ )
@@ -1,7 +1,7 @@
1
1
  """Pure domain value objects and DTOs."""
2
2
 
3
3
  import json
4
- from dataclasses import dataclass, replace
4
+ from dataclasses import dataclass
5
5
  from datetime import datetime
6
6
  from enum import Enum, IntEnum, StrEnum
7
7
  from pathlib import Path
@@ -676,37 +676,34 @@ class ReportingState(StrEnum):
676
676
  FAILED = "failed"
677
677
  SKIPPED = "skipped"
678
678
 
679
+ @staticmethod
680
+ def is_terminal(state: "ReportingState") -> bool:
681
+ """Check if a state is completed."""
682
+ return state in [
683
+ ReportingState.COMPLETED,
684
+ ReportingState.FAILED,
685
+ ReportingState.SKIPPED,
686
+ ]
679
687
 
680
- @dataclass(frozen=True)
681
- class Progress:
682
- """Immutable representation of a step's state."""
683
-
684
- name: str
685
- state: ReportingState
686
- message: str = ""
687
- error: BaseException | None = None
688
- total: int = 0
689
- current: int = 0
690
688
 
691
- @property
692
- def completion_percent(self) -> float:
693
- """Calculate the percentage of completion."""
694
- if self.total == 0:
695
- return 0.0
696
- return min(100.0, max(0.0, (self.current / self.total) * 100.0))
689
+ class TrackableType(StrEnum):
690
+ """Trackable type."""
697
691
 
698
- def with_error(self, error: BaseException) -> "Progress":
699
- """Return a new snapshot with updated error."""
700
- return replace(self, error=error)
692
+ INDEX = "indexes"
701
693
 
702
- def with_total(self, total: int) -> "Progress":
703
- """Return a new snapshot with updated total."""
704
- return replace(self, total=total)
705
694
 
706
- def with_progress(self, current: int) -> "Progress":
707
- """Return a new snapshot with updated progress."""
708
- return replace(self, current=current)
695
+ class TaskOperation(StrEnum):
696
+ """Task operation."""
709
697
 
710
- def with_state(self, state: ReportingState, message: str = "") -> "Progress":
711
- """Return a new snapshot with updated state."""
712
- return replace(self, state=state, message=message)
698
+ ROOT = "kodit.root"
699
+ CREATE_INDEX = "kodit.index.create"
700
+ RUN_INDEX = "kodit.index.run"
701
+ REFRESH_WORKING_COPY = "kodit.index.run.refresh_working_copy"
702
+ DELETE_OLD_SNIPPETS = "kodit.index.run.delete_old_snippets"
703
+ EXTRACT_SNIPPETS = "kodit.index.run.extract_snippets"
704
+ CREATE_BM25_INDEX = "kodit.index.run.create_bm25_index"
705
+ CREATE_CODE_EMBEDDINGS = "kodit.index.run.create_code_embeddings"
706
+ ENRICH_SNIPPETS = "kodit.index.run.enrich_snippets"
707
+ CREATE_TEXT_EMBEDDINGS = "kodit.index.run.create_text_embeddings"
708
+ UPDATE_INDEX_TIMESTAMP = "kodit.index.run.update_index_timestamp"
709
+ CLEAR_FILE_PROCESSING_STATUSES = "kodit.index.run.clear_file_processing_statuses"
@@ -15,8 +15,12 @@ from kodit.application.services.code_indexing_application_service import (
15
15
  from kodit.application.services.queue_service import QueueService
16
16
  from kodit.config import AppContext
17
17
  from kodit.domain.services.index_query_service import IndexQueryService
18
+ from kodit.domain.services.task_status_query_service import TaskStatusQueryService
18
19
  from kodit.infrastructure.indexing.fusion_service import ReciprocalRankFusionService
19
20
  from kodit.infrastructure.sqlalchemy.index_repository import create_index_repository
21
+ from kodit.infrastructure.sqlalchemy.task_status_repository import (
22
+ create_task_status_repository,
23
+ )
20
24
 
21
25
 
22
26
  def get_app_context(request: Request) -> AppContext:
@@ -70,13 +74,10 @@ IndexQueryServiceDep = Annotated[IndexQueryService, Depends(get_index_query_serv
70
74
 
71
75
  async def get_indexing_app_service(
72
76
  app_context: AppContextDep,
73
- session: DBSessionDep,
74
77
  session_factory: DBSessionFactoryDep,
75
78
  ) -> CodeIndexingApplicationService:
76
79
  """Get indexing application service dependency."""
77
- return create_server_code_indexing_application_service(
78
- app_context, session, session_factory
79
- )
80
+ return create_server_code_indexing_application_service(app_context, session_factory)
80
81
 
81
82
 
82
83
  IndexingAppServiceDep = Annotated[
@@ -94,3 +95,17 @@ async def get_queue_service(
94
95
 
95
96
 
96
97
  QueueServiceDep = Annotated[QueueService, Depends(get_queue_service)]
98
+
99
+
100
+ async def get_task_status_query_service(
101
+ session_factory: DBSessionFactoryDep,
102
+ ) -> TaskStatusQueryService:
103
+ """Get task status query service dependency."""
104
+ return TaskStatusQueryService(
105
+ repository=create_task_status_repository(session_factory=session_factory)
106
+ )
107
+
108
+
109
+ TaskStatusQueryServiceDep = Annotated[
110
+ TaskStatusQueryService, Depends(get_task_status_query_service)
111
+ ]
@@ -9,6 +9,7 @@ from kodit.infrastructure.api.v1.dependencies import (
9
9
  IndexingAppServiceDep,
10
10
  IndexQueryServiceDep,
11
11
  QueueServiceDep,
12
+ TaskStatusQueryServiceDep,
12
13
  )
13
14
  from kodit.infrastructure.api.v1.schemas.index import (
14
15
  IndexAttributes,
@@ -18,6 +19,11 @@ from kodit.infrastructure.api.v1.schemas.index import (
18
19
  IndexListResponse,
19
20
  IndexResponse,
20
21
  )
22
+ from kodit.infrastructure.api.v1.schemas.task_status import (
23
+ TaskStatusAttributes,
24
+ TaskStatusData,
25
+ TaskStatusListResponse,
26
+ )
21
27
 
22
28
  router = APIRouter(
23
29
  prefix="/api/v1/indexes",
@@ -103,6 +109,45 @@ async def get_index(
103
109
  )
104
110
 
105
111
 
112
+ @router.get(
113
+ "/{index_id}/status",
114
+ responses={404: {"description": "Index not found"}},
115
+ )
116
+ async def get_index_status(
117
+ index_id: int,
118
+ query_service: IndexQueryServiceDep,
119
+ status_service: TaskStatusQueryServiceDep,
120
+ ) -> TaskStatusListResponse:
121
+ """Get the status of tasks for an index."""
122
+ # Verify the index exists
123
+ index = await query_service.get_index_by_id(index_id)
124
+ if not index:
125
+ raise HTTPException(status_code=404, detail="Index not found")
126
+
127
+ # Get all task statuses for this index
128
+ progress_trackers = await status_service.get_index_status(index_id)
129
+
130
+ # Convert progress trackers to API response format
131
+ task_statuses = []
132
+ for _i, status in enumerate(progress_trackers):
133
+ task_statuses.append(
134
+ TaskStatusData(
135
+ id=status.id,
136
+ attributes=TaskStatusAttributes(
137
+ step=status.operation,
138
+ state=status.state,
139
+ progress=status.completion_percent,
140
+ total=status.total,
141
+ current=status.current,
142
+ created_at=status.created_at,
143
+ updated_at=status.updated_at,
144
+ ),
145
+ )
146
+ )
147
+
148
+ return TaskStatusListResponse(data=task_statuses)
149
+
150
+
106
151
  @router.delete(
107
152
  "/{index_id}", status_code=204, responses={404: {"description": "Index not found"}}
108
153
  )
@@ -0,0 +1,39 @@
1
+ """JSON:API schemas for task status operations."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class TaskStatusAttributes(BaseModel):
9
+ """Task status attributes for JSON:API responses."""
10
+
11
+ step: str = Field(..., description="Name of the task/operation")
12
+ state: str = Field(..., description="Current state of the task")
13
+ progress: float = Field(
14
+ default=0.0, ge=0.0, le=100.0, description="Progress percentage (0-100)"
15
+ )
16
+ total: int = Field(default=0, description="Total number of items to process")
17
+ current: int = Field(default=0, description="Current number of items processed")
18
+ created_at: datetime | None = Field(default=None, description="Task start time")
19
+ updated_at: datetime | None = Field(default=None, description="Last update time")
20
+
21
+
22
+ class TaskStatusData(BaseModel):
23
+ """Task status data for JSON:API responses."""
24
+
25
+ type: str = "task_status"
26
+ id: str
27
+ attributes: TaskStatusAttributes
28
+
29
+
30
+ class TaskStatusResponse(BaseModel):
31
+ """JSON:API response for single task status."""
32
+
33
+ data: TaskStatusData
34
+
35
+
36
+ class TaskStatusListResponse(BaseModel):
37
+ """JSON:API response for task status list."""
38
+
39
+ data: list[TaskStatusData]