codepulse-engine 0.1.0__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.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,120 @@
1
+ """
2
+ Phase 0.8 -- .gitignore / .codepulseignore discovery and ignore-rule handling.
3
+
4
+ Loads a repository's root-level `.gitignore` and `.codepulseignore` files
5
+ (if present) and answers whether a given repository-relative path should be
6
+ excluded from indexing, before that path is ever read or parsed.
7
+
8
+ Semantics (per CodePulse_Strategy_and_Roadmap.md §8/§16 and roadmap sprint
9
+ item 0.8 / backlog item B6 -- "Ignored files never appear in the index"):
10
+
11
+ - `.gitignore` is respected using standard gitignore pattern syntax
12
+ (§16: "Respect `.gitignore` during file discovery").
13
+ - `.codepulseignore` uses the "same syntax" (§16) for CodePulse-specific
14
+ exclusions "beyond git's own" -- i.e. ADDITIVE to `.gitignore`, never a
15
+ replacement/override of it.
16
+ - The two files are evaluated INDEPENDENTLY, each with its own negation
17
+ patterns resolved only within itself, and a path is excluded if EITHER
18
+ file's ruleset matches it. A negation (`!pattern`) in one file cannot
19
+ reach across and un-ignore something the other file matches -- this
20
+ keeps ".codepulseignore extends .gitignore" unambiguous instead of
21
+ inventing cross-file negation semantics the roadmap doesn't specify.
22
+ - Only the REPOSITORY-ROOT `.gitignore`/`.codepulseignore` are read.
23
+ Nested, per-directory ignore files (as real git supports) are NOT
24
+ implemented here -- the roadmap doesn't call for it, and it's a
25
+ materially larger feature than "respect the repository's ignore
26
+ rules." Documented limitation (see CODEPULSE_PROGRESS.md).
27
+ - An absent ignore file is treated as an empty ruleset -- not an error.
28
+ - If a *present* ignore file cannot be read, or contains a pattern
29
+ `pathspec` cannot parse, that file's ENTIRE ruleset is treated as
30
+ absent (empty) for this indexing run, and a warning is logged naming
31
+ the file and the error. Indexing proceeds rather than aborting, but
32
+ the failure is not silently swallowed, and a malformed file never
33
+ causes MORE or FEWER exclusions than "this file contributed nothing."
34
+ - The ignore files themselves (`.gitignore`, `.codepulseignore`) are not
35
+ given any special exemption from these rules, but in practice they
36
+ already never become indexed `FileModel`/`SymbolModel` rows regardless
37
+ -- no `LanguageParser` recognizes them (see `parser/factory.py`), and
38
+ `IndexingEngine.index_directory` only persists files a parser accepts.
39
+ - No file-size or file-type caps are implemented here (§8 lists them
40
+ alongside ignore-file handling, but they're a distinct, not-yet-built
41
+ concern -- out of this milestone's scope).
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ import os
47
+
48
+ import pathspec
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ GITIGNORE_FILENAME = ".gitignore"
53
+ CODEPULSEIGNORE_FILENAME = ".codepulseignore"
54
+
55
+
56
+ def _load_spec(repo_path: str, filename: str) -> pathspec.PathSpec | None:
57
+ """
58
+ Loads one root-level ignore file into a `PathSpec` using gitwildmatch
59
+ syntax. Returns None if the file doesn't exist, can't be read, or
60
+ contains a pattern that can't be parsed (logging a warning in the
61
+ latter two cases) -- callers treat None as "this file contributes no
62
+ rules," never as an error to propagate.
63
+ """
64
+ file_path = os.path.join(repo_path, filename)
65
+ if not os.path.isfile(file_path):
66
+ return None
67
+
68
+ try:
69
+ with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
70
+ lines = fh.readlines()
71
+ except OSError as e:
72
+ logger.warning(
73
+ "Could not read %s: %s -- its rules are not applied for this indexing run.",
74
+ file_path,
75
+ e,
76
+ )
77
+ return None
78
+
79
+ try:
80
+ return pathspec.PathSpec.from_lines("gitignore", lines)
81
+ except Exception as e:
82
+ logger.warning(
83
+ "Could not parse %s (malformed pattern): %s -- its rules are not applied "
84
+ "for this indexing run.",
85
+ file_path,
86
+ e,
87
+ )
88
+ return None
89
+
90
+
91
+ class RepositoryIgnoreRules:
92
+ """
93
+ Combined `.gitignore` + `.codepulseignore` rules for one repository
94
+ root. Loaded ONCE per indexing operation (see module docstring for
95
+ precedence/combination semantics) and reused for every discovered path
96
+ -- callers should construct one instance per `index_directory()` call,
97
+ not re-parse the ignore files per file/directory.
98
+ """
99
+
100
+ def __init__(self, repo_path: str) -> None:
101
+ self.repo_path = os.path.abspath(repo_path)
102
+ self._gitignore_spec = _load_spec(self.repo_path, GITIGNORE_FILENAME)
103
+ self._codepulseignore_spec = _load_spec(self.repo_path, CODEPULSEIGNORE_FILENAME)
104
+
105
+ def is_ignored(self, rel_path: str, is_dir: bool = False) -> bool:
106
+ """
107
+ `rel_path` must be repository-root-relative, using forward slashes
108
+ (the same path representation the rest of the indexing pipeline
109
+ uses for `FileParseResult`/`Symbol`/DB rows). A trailing slash is
110
+ added automatically for directories, matching gitwildmatch's
111
+ convention for directory-only patterns (e.g. `build/`).
112
+ """
113
+ rel_path = rel_path.replace("\\", "/").lstrip("/")
114
+ match_path = rel_path.rstrip("/") + "/" if is_dir else rel_path
115
+
116
+ if self._gitignore_spec is not None and self._gitignore_spec.match_file(match_path):
117
+ return True
118
+ if self._codepulseignore_spec is not None and self._codepulseignore_spec.match_file(match_path):
119
+ return True
120
+ return False
@@ -0,0 +1,169 @@
1
+ """
2
+ Phase 1.5 — Ingestion safety: path allow-list, SSRF check, size caps
3
+ (roadmap §37 item 1.5; §15 Security Architecture's "Repository-ingestion
4
+ safety (the dangerous endpoint)" paragraph; backlog B10, depends on B8).
5
+
6
+ Roadmap text this milestone implements, verbatim (§15):
7
+ "for local-path ingestion, restrict to an explicit allow-listed root
8
+ directory (configurable), reject `..`-traversal and symlink escapes;
9
+ for Git-URL ingestion, validate the URL scheme (`https`/`ssh` only),
10
+ resolve and check the target host isn't a private/link-local IP (SSRF
11
+ prevention), and enforce a repository size/file-count cap before
12
+ beginning a full clone."
13
+ Plus §15's "Resource limits: max files per repo, max file size parsed
14
+ [...]".
15
+
16
+ Scope, precisely (do not infer requirements from later milestones):
17
+ - `validate_local_path`: resolves a requested local path (following
18
+ symlinks, collapsing `..`) and, if one or more allow-listed roots are
19
+ configured, rejects it unless the resolved path is contained in one
20
+ of them. Catches both textual `..`-traversal and symlink-escape
21
+ attempts the same way, because both are neutralized by resolution
22
+ before the containment check ever runs.
23
+ - `validate_git_url`: validates a git URL's scheme is `https`/`ssh`,
24
+ resolves its host, and rejects it if any resolved address is
25
+ private/loopback/link-local/reserved/multicast (SSRF prevention).
26
+ This is a real, independently testable safety check -- see the note
27
+ below on why it exists even though this codebase has no git-clone
28
+ implementation yet.
29
+ - File-count and per-file-size caps are enforced inside
30
+ `IndexingEngine.index_directory()` itself (not here -- see that
31
+ method's own comments), because the file list they check against is
32
+ only available after the discovery walk `index_directory()` already
33
+ performs; duplicating that walk here just to pre-count would be
34
+ wasteful and is exactly the kind of "second identity system"/
35
+ duplicated-logic this project's established house style avoids.
36
+ `IngestionSafetyError` (this module) is the one exception type both
37
+ that engine-level check and this module's own boundary checks raise,
38
+ so `codepulse/api/app.py` has a single, consistent type to catch.
39
+
40
+ IMPORTANT scope note on `validate_git_url`: this codebase has **no git
41
+ clone/fetch implementation at all** (grep-confirmed: no `subprocess`,
42
+ `GitPython`, or network-clone code exists anywhere). `IndexRepoRequest
43
+ .source` (`"local"`/`"github"`/`"gitlab"`) has always been accepted but
44
+ never actually branched on -- every prior phase's `/v1/repositories/index`
45
+ treated `req.url` as a local filesystem path regardless of `source`,
46
+ silently indexing zero files for a real git URL. Implementing actual
47
+ git-URL cloning is a much larger, unrequested feature no sprint item in
48
+ this roadmap assigns a milestone to; it is NOT this milestone's job to
49
+ invent it. What Phase 1.5 *does* own, literally, is the "SSRF check" --
50
+ so this module implements and tests that check as a real, standalone
51
+ safety primitive (ready for whenever a real fetch path exists), and
52
+ `codepulse/api/app.py` now honestly rejects a non-local `source` (after
53
+ still validating the URL for safety first) with HTTP 501, rather than
54
+ continuing to silently mis-process it as a phantom local path.
55
+
56
+ Deliberately NOT in scope for Phase 1.5 (do not infer these belong here):
57
+ - Actually cloning/fetching a git repository (see above).
58
+ - Request-body size caps (roadmap §15 lists this as a separate
59
+ "Resource limit" alongside file/repo caps; FastAPI/Starlette/uvicorn
60
+ request-body size limits are a server/ASGI-layer configuration
61
+ concern, not application code this milestone adds).
62
+ - Max concurrent indexing jobs "per tenant" (roadmap §15) -- Phase 1.3
63
+ already added a *per-repository* advisory one-active-job lock; a
64
+ global/per-tenant concurrent-job cap is a distinct, unrequested
65
+ control this milestone does not add.
66
+ - A structured `{"error": {...}}` response envelope -- Phase 1.6.
67
+ - A dedicated security test suite -- Phase 1.7.
68
+
69
+ This module lives outside `codepulse/indexer/` and `codepulse/api/`
70
+ deliberately -- a top-level module, mirroring `codepulse/auth.py`,
71
+ `codepulse/jobs.py`, and `codepulse/rate_limit.py`'s placement, per the
72
+ roadmap's "control plane"/API-boundary grouping of these safety concerns,
73
+ reusable identically by a future CLI or MCP surface.
74
+ """
75
+ from __future__ import annotations
76
+
77
+ import ipaddress
78
+ import os
79
+ import socket
80
+ from pathlib import Path
81
+ from urllib.parse import urlparse
82
+
83
+ ALLOWED_GIT_URL_SCHEMES = ("https", "ssh")
84
+
85
+
86
+ class IngestionSafetyError(Exception):
87
+ """
88
+ Raised for any ingestion request rejected on safety grounds: a local
89
+ path outside the configured allow-list (or escaping it via `..`/a
90
+ symlink), an unsafe git URL scheme, an SSRF-unsafe resolved host, or
91
+ (raised by `IndexingEngine.index_directory` itself, not this module)
92
+ a repository exceeding the configured file-count cap.
93
+ """
94
+
95
+
96
+ def validate_local_path(path: str, allowed_roots: list[str]) -> str:
97
+ """
98
+ Resolves `path` (following symlinks, collapsing `..`) and, if
99
+ `allowed_roots` is non-empty, rejects it unless the resolved path is
100
+ contained in (or equal to) one of them. Returns the resolved,
101
+ canonical path on success.
102
+
103
+ An empty `allowed_roots` means "unrestricted" -- see
104
+ `CodePulseConfig.ingestion_allowed_roots`'s docstring for why that is
105
+ this project's deliberate default, preserving the local-first
106
+ zero-configuration experience.
107
+ """
108
+ resolved = str(Path(path).resolve())
109
+
110
+ if allowed_roots:
111
+ for root in allowed_roots:
112
+ resolved_root = str(Path(root).resolve())
113
+ if resolved == resolved_root or resolved.startswith(resolved_root + os.sep):
114
+ break
115
+ else:
116
+ raise IngestionSafetyError(
117
+ f"Path '{path}' resolves to '{resolved}', which is outside every "
118
+ f"configured ingestion_allowed_roots entry"
119
+ )
120
+
121
+ # Checked AFTER the allow-list on purpose: reporting "does not exist"
122
+ # for a path outside the allowed roots would let a caller probe which
123
+ # paths exist outside them. A nonexistent path used to be accepted and
124
+ # silently created an empty phantom repository (2026-10-02 audit).
125
+ if not os.path.isdir(resolved):
126
+ raise IngestionSafetyError(f"Path '{path}' is not an existing directory")
127
+
128
+ return resolved
129
+
130
+
131
+ def validate_git_url(url: str) -> None:
132
+ """
133
+ Validates a git URL's scheme and resolved host for SSRF safety.
134
+ Raises `IngestionSafetyError` if the scheme isn't https/ssh, the host
135
+ can't be resolved, or any resolved address is
136
+ private/loopback/link-local/reserved/multicast/unspecified.
137
+ """
138
+ parsed = urlparse(url)
139
+
140
+ if parsed.scheme not in ALLOWED_GIT_URL_SCHEMES:
141
+ raise IngestionSafetyError(
142
+ f"Unsupported git URL scheme '{parsed.scheme}' -- only "
143
+ f"{'/'.join(ALLOWED_GIT_URL_SCHEMES)} are allowed"
144
+ )
145
+
146
+ host = parsed.hostname
147
+ if not host:
148
+ raise IngestionSafetyError(f"Git URL '{url}' has no resolvable host")
149
+
150
+ try:
151
+ resolved = socket.getaddrinfo(host, None)
152
+ except socket.gaierror as e:
153
+ raise IngestionSafetyError(f"Could not resolve host '{host}': {e}") from e
154
+
155
+ for family, _, _, _, sockaddr in resolved:
156
+ ip = ipaddress.ip_address(sockaddr[0])
157
+ if (
158
+ ip.is_private
159
+ or ip.is_loopback
160
+ or ip.is_link_local
161
+ or ip.is_reserved
162
+ or ip.is_multicast
163
+ or ip.is_unspecified
164
+ ):
165
+ raise IngestionSafetyError(
166
+ f"Host '{host}' resolves to '{sockaddr[0]}', a "
167
+ f"private/loopback/link-local/reserved address -- rejected "
168
+ f"as an SSRF risk"
169
+ )
codepulse/jobs.py ADDED
@@ -0,0 +1,257 @@
1
+ """
2
+ Phase 1.3 — IndexJob state machine + /v1/jobs/{id} (roadmap §37 item 1.3;
3
+ §8 "Concurrency/failure handling"; §21; backlog B9).
4
+
5
+ Scope, precisely (do not infer requirements from later milestones):
6
+ - Create a real, DB-backed job row for one indexing run, in the initial
7
+ `queued` state.
8
+ - Transition it through the roadmap §8 state machine: `queued ->
9
+ running -> succeeded|failed`. `cancelled` is a valid schema value
10
+ (see `IndexJobModel`) but this module exposes no way to reach it --
11
+ no cancel endpoint/flow exists yet.
12
+ - An "advisory lock" (roadmap §8's own wording) preventing a second
13
+ concurrent indexing run for the same repository: `get_active_job`
14
+ lets a caller check for -- and refuse to start alongside -- an
15
+ existing `queued`/`running` job for that repository. This is an
16
+ application-level check, not a hard database constraint: this
17
+ project runs indexing as in-process background tasks, not multiple
18
+ concurrent worker processes, so a stronger lock is not needed at this
19
+ scale, and the roadmap itself only calls for "advisory."
20
+ - A read path (`get_job`) returning everything `GET /v1/jobs/{job_id}`
21
+ needs to report real, measured status -- never a fabricated/estimated
22
+ one.
23
+
24
+ Deliberately NOT in scope for Phase 1.3 (reserved for later milestones --
25
+ do not infer these belong here):
26
+ - A heartbeat-based crash reaper reclaiming a `running` job whose worker
27
+ died (roadmap §17 mentions this as a *reliability* mechanism, but no
28
+ sprint item assigns it a specific milestone; this project has no
29
+ subprocess/worker infrastructure for a "crashed worker" to even be a
30
+ distinct case from "this process's background task raised" -- the
31
+ latter IS handled here, via `mark_failed`, since `run_indexing`'s own
32
+ try/except in `api/app.py` calls it on any exception). Backend-hardening
33
+ addition (not a roadmap sprint item): `reap_stale_jobs` below IS that
34
+ reaper, added later -- see its own docstring for why a startup-time
35
+ reconciliation, not a heartbeat/timeout, is the correct mechanism for
36
+ this project's actual single-process architecture.
37
+ - `RepositorySnapshot` / git commit tracking / incremental indexing
38
+ (roadmap §9, explicitly Phase 3 sprint items 3.1-3.5) -- this module
39
+ has no concept of a snapshot; `IndexingEngine.index_directory()` is
40
+ still a full re-index every time, unchanged by this milestone.
41
+ - A cancel endpoint/flow, job expiry/cleanup, rate limiting (Phase 1.4),
42
+ ingestion safety (Phase 1.5), a structured error envelope (Phase 1.6).
43
+
44
+ This module lives outside `codepulse/indexer/` deliberately, mirroring
45
+ `codepulse/auth.py`'s placement: `IndexingEngine.index_directory()` has no
46
+ knowledge of jobs at all and is not modified by this milestone -- the
47
+ caller (currently `codepulse/api/app.py`'s background task) is entirely
48
+ responsible for calling `mark_running`/`mark_succeeded`/`mark_failed`
49
+ around its own call to `index_directory()`. It is a top-level module
50
+ (not under `codepulse/api/`) so a future CLI or MCP surface that also
51
+ triggers indexing (roadmap: "REST, CLI, and MCP must call the same
52
+ underlying intelligence functions") can reuse it identically, exactly as
53
+ `codepulse/auth.py` is already structured for reuse beyond REST alone.
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ from dataclasses import dataclass
59
+ from datetime import datetime
60
+ from typing import Any
61
+
62
+ from codepulse.storage.db import IndexJobModel, StorageManager
63
+
64
+ ACTIVE_STATUSES = ("queued", "running")
65
+ TERMINAL_STATUSES = ("succeeded", "failed", "cancelled")
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class JobInfo:
70
+ """
71
+ A snapshot of one IndexJob row's state, safe to hold after its
72
+ originating session has closed (see `StorageManager.SessionLocal`'s
73
+ `expire_on_commit=True` default -- the same reason
74
+ `codepulse.auth.CreatedAPIKey` is a frozen dataclass rather than a raw
75
+ ORM object, established in Phase 1.1).
76
+ """
77
+
78
+ id: int
79
+ repo_id: int
80
+ status: str
81
+ created_at: datetime
82
+ started_at: datetime | None
83
+ finished_at: datetime | None
84
+ error_message: str | None
85
+ result: dict[str, Any] | None
86
+
87
+
88
+ def _to_job_info(job: IndexJobModel) -> JobInfo:
89
+ return JobInfo(
90
+ id=job.id,
91
+ repo_id=job.repo_id,
92
+ status=job.status,
93
+ created_at=job.created_at,
94
+ started_at=job.started_at,
95
+ finished_at=job.finished_at,
96
+ error_message=job.error_message,
97
+ result=json.loads(job.result_summary) if job.result_summary else None,
98
+ )
99
+
100
+
101
+ def get_active_job(storage: StorageManager, repo_id: int) -> JobInfo | None:
102
+ """
103
+ Roadmap §8: "one active job per repository (advisory lock on
104
+ repo_id)". Returns the most recent `queued`/`running` job for
105
+ `repo_id`, if any -- callers use this to refuse starting a second
106
+ concurrent indexing run for the same repository, rather than letting
107
+ two runs write conflicting data at once.
108
+ """
109
+ session = storage.get_session()
110
+ try:
111
+ job = (
112
+ session.query(IndexJobModel)
113
+ .filter(IndexJobModel.repo_id == repo_id, IndexJobModel.status.in_(ACTIVE_STATUSES))
114
+ .order_by(IndexJobModel.id.desc())
115
+ .first()
116
+ )
117
+ return _to_job_info(job) if job else None
118
+ finally:
119
+ session.close()
120
+
121
+
122
+ def create_job(storage: StorageManager, repo_id: int) -> JobInfo:
123
+ """Creates a new IndexJob row in the initial `queued` state."""
124
+ session = storage.get_session()
125
+ try:
126
+ job = IndexJobModel(repo_id=repo_id, status="queued", created_at=datetime.utcnow())
127
+ session.add(job)
128
+ session.commit()
129
+ return _to_job_info(job)
130
+ finally:
131
+ session.close()
132
+
133
+
134
+ def mark_running(storage: StorageManager, job_id: int) -> None:
135
+ """Transitions a job `queued -> running`, recording `started_at`."""
136
+ session = storage.get_session()
137
+ try:
138
+ job = session.query(IndexJobModel).filter(IndexJobModel.id == job_id).first()
139
+ if job is not None:
140
+ job.status = "running"
141
+ job.started_at = datetime.utcnow()
142
+ session.commit()
143
+ finally:
144
+ session.close()
145
+ # Phase 7.4 (roadmap §37 item 7.4; §18: "indexing_jobs_total{status}"):
146
+ # real state-transition counter, incremented at the exact moment
147
+ # this transition actually happens -- not a synthetic/estimated value.
148
+ from codepulse.observability import indexing_jobs_total
149
+ indexing_jobs_total.labels(status="running").inc()
150
+
151
+
152
+ def mark_succeeded(storage: StorageManager, job_id: int, result: dict[str, Any]) -> None:
153
+ """
154
+ Transitions a job `running -> succeeded`, recording `finished_at` and
155
+ `result` -- `result` must be the real, measured return value of
156
+ `IndexingEngine.index_directory()` (never an estimated/fabricated
157
+ summary), per this project's "no fabricated metrics" invariant.
158
+ """
159
+ session = storage.get_session()
160
+ try:
161
+ job = session.query(IndexJobModel).filter(IndexJobModel.id == job_id).first()
162
+ if job is not None:
163
+ job.status = "succeeded"
164
+ job.finished_at = datetime.utcnow()
165
+ job.result_summary = json.dumps(result)
166
+ session.commit()
167
+ finally:
168
+ session.close()
169
+ from codepulse.observability import indexing_jobs_total
170
+ indexing_jobs_total.labels(status="succeeded").inc()
171
+
172
+
173
+ def mark_failed(storage: StorageManager, job_id: int, error: str) -> None:
174
+ """Transitions a job `running -> failed`, recording `finished_at` and `error`."""
175
+ session = storage.get_session()
176
+ try:
177
+ job = session.query(IndexJobModel).filter(IndexJobModel.id == job_id).first()
178
+ if job is not None:
179
+ job.status = "failed"
180
+ job.finished_at = datetime.utcnow()
181
+ job.error_message = error
182
+ session.commit()
183
+ finally:
184
+ session.close()
185
+ from codepulse.observability import indexing_jobs_total
186
+ indexing_jobs_total.labels(status="failed").inc()
187
+
188
+
189
+ def reap_stale_jobs(storage: StorageManager) -> list[int]:
190
+ """
191
+ Backend-hardening addition (not a roadmap sprint item) -- closes the
192
+ one remaining gap this module's own docstring has disclosed since
193
+ Phase 1.3: a `running` job whose background task is killed by a real
194
+ process crash used to stay `running` forever, permanently blocking
195
+ `get_active_job`'s advisory lock for that repository (every future
196
+ indexing attempt would see an "active" job that can never finish and
197
+ refuse to start).
198
+
199
+ Deliberately a STARTUP-TIME reconciliation, not a heartbeat/timeout
200
+ mechanism: this project runs indexing as in-process `BackgroundTasks`
201
+ coroutines (roadmap §8's own "advisory lock... this project runs
202
+ indexing as in-process background tasks, not multiple concurrent
203
+ worker processes" framing, already established for `get_active_job`),
204
+ with no persisted "which process owns this job" tracking and no
205
+ heartbeat column anywhere in the schema. In that architecture, a job
206
+ still `queued`/`running` at the moment a NEW `IndexingEngine` is
207
+ constructed can only be a leftover from a PREVIOUS process that no
208
+ longer exists -- the current process could not have created it (it
209
+ just started), and no other process could be concurrently progressing
210
+ it (this project has no distributed worker pool). A timeout-based
211
+ heuristic would need a new column, a migration, and an arbitrary
212
+ staleness threshold that either reaps a genuinely slow-but-healthy
213
+ job too early or a genuinely crashed one too late; this mechanism
214
+ needs neither and cannot produce a false positive for THIS
215
+ architecture's actual failure mode.
216
+
217
+ Reuses `mark_failed` (not a hand-rolled column update) so this stays
218
+ the single source of truth for what a `-> failed` transition means,
219
+ including its own existing `indexing_jobs_total{status="failed"}`
220
+ metric increment -- a reaped job is counted exactly like any other
221
+ real failure, not a separate, uncounted code path.
222
+
223
+ Called once from `IndexingEngine.__init__`, mirroring `hydrate_all`'s
224
+ own "reconcile whatever happened before this process began"
225
+ philosophy and timing. Returns the ids of every job reaped (empty if
226
+ none were stale) -- primarily for logging/testing, not a public API.
227
+ """
228
+ session = storage.get_session()
229
+ try:
230
+ stale_ids = [
231
+ job.id
232
+ for job in session.query(IndexJobModel)
233
+ .filter(IndexJobModel.status.in_(ACTIVE_STATUSES))
234
+ .all()
235
+ ]
236
+ finally:
237
+ session.close()
238
+
239
+ for job_id in stale_ids:
240
+ mark_failed(
241
+ storage,
242
+ job_id,
243
+ "Reaped at startup: this job was still queued/running when the "
244
+ "previous process ended (crash, restart, or forced shutdown) "
245
+ "and can never complete.",
246
+ )
247
+ return stale_ids
248
+
249
+
250
+ def get_job(storage: StorageManager, job_id: int) -> JobInfo | None:
251
+ """Returns the current state of one job, or None if no such job exists."""
252
+ session = storage.get_session()
253
+ try:
254
+ job = session.query(IndexJobModel).filter(IndexJobModel.id == job_id).first()
255
+ return _to_job_info(job) if job else None
256
+ finally:
257
+ session.close()