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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|