codealmanac 0.1.0.dev0__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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from codealmanac.core.slug import to_kebab_case
|
|
5
|
+
from codealmanac.database import SQLiteConnection, SQLiteRow
|
|
6
|
+
from codealmanac.services.index.models import (
|
|
7
|
+
BrokenCrossWikiLink,
|
|
8
|
+
BrokenPageLink,
|
|
9
|
+
CrossWikiReference,
|
|
10
|
+
DeadFileReference,
|
|
11
|
+
EmptyPage,
|
|
12
|
+
EmptyTopic,
|
|
13
|
+
HealthReport,
|
|
14
|
+
IndexCounts,
|
|
15
|
+
OrphanPage,
|
|
16
|
+
PageFileReference,
|
|
17
|
+
PageView,
|
|
18
|
+
SearchPageResult,
|
|
19
|
+
TopicDetail,
|
|
20
|
+
TopicSummary,
|
|
21
|
+
)
|
|
22
|
+
from codealmanac.services.index.requests import SearchIndexRequest
|
|
23
|
+
from codealmanac.services.wiki.paths import (
|
|
24
|
+
escape_glob_meta,
|
|
25
|
+
looks_like_dir,
|
|
26
|
+
normalize_reference_path,
|
|
27
|
+
parent_folder_prefixes,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def search_pages(
|
|
32
|
+
connection: SQLiteConnection,
|
|
33
|
+
request: SearchIndexRequest,
|
|
34
|
+
) -> tuple[SearchPageResult, ...]:
|
|
35
|
+
rows = connection.execute(*search_sql(request)).fetchall()
|
|
36
|
+
results = [search_result_from_row(connection, row) for row in rows]
|
|
37
|
+
if request.limit is not None:
|
|
38
|
+
return tuple(results[: request.limit])
|
|
39
|
+
return tuple(results)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def index_counts(connection: SQLiteConnection) -> IndexCounts:
|
|
43
|
+
page_count = connection.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
|
|
44
|
+
topic_count = connection.execute("SELECT COUNT(*) FROM topics").fetchone()[0]
|
|
45
|
+
return IndexCounts(pages=page_count, topics=topic_count)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_page_view(connection: SQLiteConnection, slug: str) -> PageView | None:
|
|
49
|
+
row = connection.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT slug, title, summary, file_path, updated_at, archived_at,
|
|
52
|
+
superseded_by, body
|
|
53
|
+
FROM pages
|
|
54
|
+
WHERE slug = ?
|
|
55
|
+
""",
|
|
56
|
+
(slug,),
|
|
57
|
+
).fetchone()
|
|
58
|
+
if row is None:
|
|
59
|
+
return None
|
|
60
|
+
return page_view_from_row(connection, row)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_topic_summaries(connection: SQLiteConnection) -> tuple[TopicSummary, ...]:
|
|
64
|
+
rows = connection.execute(
|
|
65
|
+
"""
|
|
66
|
+
SELECT t.slug, t.title, t.description,
|
|
67
|
+
COUNT(p.slug) AS page_count
|
|
68
|
+
FROM topics t
|
|
69
|
+
LEFT JOIN page_topics pt ON pt.topic_slug = t.slug
|
|
70
|
+
LEFT JOIN pages p ON p.slug = pt.page_slug AND p.archived_at IS NULL
|
|
71
|
+
GROUP BY t.slug, t.title, t.description
|
|
72
|
+
ORDER BY t.slug
|
|
73
|
+
"""
|
|
74
|
+
).fetchall()
|
|
75
|
+
return tuple(
|
|
76
|
+
TopicSummary(
|
|
77
|
+
slug=row["slug"],
|
|
78
|
+
title=row["title"],
|
|
79
|
+
description=row["description"],
|
|
80
|
+
page_count=row["page_count"],
|
|
81
|
+
)
|
|
82
|
+
for row in rows
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_topic_detail(
|
|
87
|
+
connection: SQLiteConnection,
|
|
88
|
+
slug: str,
|
|
89
|
+
include_descendants: bool,
|
|
90
|
+
) -> TopicDetail | None:
|
|
91
|
+
row = connection.execute(
|
|
92
|
+
"SELECT slug, title, description FROM topics WHERE slug = ?",
|
|
93
|
+
(slug,),
|
|
94
|
+
).fetchone()
|
|
95
|
+
if row is None:
|
|
96
|
+
return None
|
|
97
|
+
topic_slugs = (
|
|
98
|
+
topic_descendants(connection, slug) if include_descendants else (slug,)
|
|
99
|
+
)
|
|
100
|
+
return TopicDetail(
|
|
101
|
+
slug=row["slug"],
|
|
102
|
+
title=row["title"],
|
|
103
|
+
description=row["description"],
|
|
104
|
+
parents=topic_parents(connection, slug),
|
|
105
|
+
children=topic_children(connection, slug),
|
|
106
|
+
pages=pages_for_topics(connection, topic_slugs),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_health_report(
|
|
111
|
+
connection: SQLiteConnection,
|
|
112
|
+
repo_root: Path,
|
|
113
|
+
registered_wikis: set[str],
|
|
114
|
+
) -> HealthReport:
|
|
115
|
+
return HealthReport(
|
|
116
|
+
orphans=orphan_pages(connection),
|
|
117
|
+
dead_refs=dead_file_refs(connection, repo_root),
|
|
118
|
+
broken_links=broken_page_links(connection),
|
|
119
|
+
broken_xwiki=broken_cross_wiki_links(connection, registered_wikis),
|
|
120
|
+
empty_topics=empty_topics(connection),
|
|
121
|
+
empty_pages=empty_pages(connection),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def search_sql(request: SearchIndexRequest) -> tuple[str, tuple[object, ...]]:
|
|
126
|
+
where_clauses: list[str] = []
|
|
127
|
+
params: list[object] = []
|
|
128
|
+
if request.archived:
|
|
129
|
+
where_clauses.append("p.archived_at IS NOT NULL")
|
|
130
|
+
elif not request.include_archive:
|
|
131
|
+
where_clauses.append("p.archived_at IS NULL")
|
|
132
|
+
|
|
133
|
+
for topic in request.topics:
|
|
134
|
+
topic_slug = to_kebab_case(topic)
|
|
135
|
+
if topic_slug:
|
|
136
|
+
where_clauses.append(
|
|
137
|
+
"""
|
|
138
|
+
EXISTS (
|
|
139
|
+
SELECT 1 FROM page_topics pt
|
|
140
|
+
WHERE pt.page_slug = p.slug AND pt.topic_slug = ?
|
|
141
|
+
)
|
|
142
|
+
"""
|
|
143
|
+
)
|
|
144
|
+
params.append(topic_slug)
|
|
145
|
+
|
|
146
|
+
if request.mentions is not None and request.mentions.strip():
|
|
147
|
+
append_file_mention_clause(where_clauses, params, request.mentions)
|
|
148
|
+
|
|
149
|
+
query = (request.query or "").strip()
|
|
150
|
+
if query:
|
|
151
|
+
where_clauses.insert(0, "fts_pages MATCH ?")
|
|
152
|
+
params.insert(0, build_fts_query(query))
|
|
153
|
+
return (
|
|
154
|
+
f"""
|
|
155
|
+
SELECT p.slug, p.title, p.summary, p.updated_at, p.archived_at,
|
|
156
|
+
p.superseded_by
|
|
157
|
+
FROM pages p
|
|
158
|
+
JOIN fts_pages f ON f.slug = p.slug
|
|
159
|
+
WHERE {" AND ".join(where_clauses)}
|
|
160
|
+
ORDER BY rank, p.updated_at DESC, p.slug ASC
|
|
161
|
+
""",
|
|
162
|
+
tuple(params),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
where_sql = (
|
|
166
|
+
f"WHERE {' AND '.join(where_clauses)}" if len(where_clauses) > 0 else ""
|
|
167
|
+
)
|
|
168
|
+
return (
|
|
169
|
+
f"""
|
|
170
|
+
SELECT p.slug, p.title, p.summary, p.updated_at, p.archived_at,
|
|
171
|
+
p.superseded_by
|
|
172
|
+
FROM pages p
|
|
173
|
+
{where_sql}
|
|
174
|
+
ORDER BY p.updated_at DESC, p.slug ASC
|
|
175
|
+
""",
|
|
176
|
+
tuple(params),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def append_file_mention_clause(
|
|
181
|
+
where_clauses: list[str],
|
|
182
|
+
params: list[object],
|
|
183
|
+
raw_path: str,
|
|
184
|
+
) -> None:
|
|
185
|
+
is_dir = looks_like_dir(raw_path)
|
|
186
|
+
normalized = normalize_reference_path(raw_path, is_dir)
|
|
187
|
+
if is_dir:
|
|
188
|
+
where_clauses.append(
|
|
189
|
+
"""
|
|
190
|
+
EXISTS (
|
|
191
|
+
SELECT 1 FROM file_refs r
|
|
192
|
+
WHERE r.page_slug = p.slug
|
|
193
|
+
AND (r.path = ? OR r.path GLOB ?)
|
|
194
|
+
)
|
|
195
|
+
"""
|
|
196
|
+
)
|
|
197
|
+
params.extend([normalized, f"{escape_glob_meta(normalized)}*"])
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
parent_folders = parent_folder_prefixes(normalized)
|
|
201
|
+
if not parent_folders:
|
|
202
|
+
where_clauses.append(
|
|
203
|
+
"""
|
|
204
|
+
EXISTS (
|
|
205
|
+
SELECT 1 FROM file_refs r
|
|
206
|
+
WHERE r.page_slug = p.slug AND r.path = ?
|
|
207
|
+
)
|
|
208
|
+
"""
|
|
209
|
+
)
|
|
210
|
+
params.append(normalized)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
placeholders = ", ".join("?" for _ in parent_folders)
|
|
214
|
+
where_clauses.append(
|
|
215
|
+
f"""
|
|
216
|
+
EXISTS (
|
|
217
|
+
SELECT 1 FROM file_refs r
|
|
218
|
+
WHERE r.page_slug = p.slug
|
|
219
|
+
AND (
|
|
220
|
+
r.path = ?
|
|
221
|
+
OR (r.is_dir = 1 AND r.path IN ({placeholders}))
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
"""
|
|
225
|
+
)
|
|
226
|
+
params.extend([normalized, *parent_folders])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_fts_query(raw: str) -> str:
|
|
230
|
+
tokens = re.split(r"[^a-zA-Z0-9]+", raw.casefold())
|
|
231
|
+
clean = [token for token in tokens if token]
|
|
232
|
+
if not clean:
|
|
233
|
+
return '""'
|
|
234
|
+
return " AND ".join(f"{token}*" for token in clean)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def search_result_from_row(
|
|
238
|
+
connection: SQLiteConnection,
|
|
239
|
+
row: SQLiteRow,
|
|
240
|
+
) -> SearchPageResult:
|
|
241
|
+
return SearchPageResult(
|
|
242
|
+
slug=row["slug"],
|
|
243
|
+
title=row["title"],
|
|
244
|
+
summary=row["summary"],
|
|
245
|
+
updated_at=row["updated_at"],
|
|
246
|
+
archived_at=row["archived_at"],
|
|
247
|
+
superseded_by=row["superseded_by"],
|
|
248
|
+
topics=topics_for_page(connection, row["slug"]),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def page_view_from_row(connection: SQLiteConnection, row: SQLiteRow) -> PageView:
|
|
253
|
+
slug = row["slug"]
|
|
254
|
+
return PageView(
|
|
255
|
+
slug=slug,
|
|
256
|
+
title=row["title"],
|
|
257
|
+
summary=row["summary"],
|
|
258
|
+
file_path=Path(row["file_path"]),
|
|
259
|
+
updated_at=row["updated_at"],
|
|
260
|
+
archived_at=row["archived_at"],
|
|
261
|
+
superseded_by=row["superseded_by"],
|
|
262
|
+
topics=topics_for_page(connection, slug),
|
|
263
|
+
file_refs=file_refs_for_page(connection, slug),
|
|
264
|
+
wikilinks_out=wikilinks_out_for_page(connection, slug),
|
|
265
|
+
wikilinks_in=wikilinks_in_for_page(connection, slug),
|
|
266
|
+
cross_wiki_links=cross_wiki_for_page(connection, slug),
|
|
267
|
+
body=row["body"],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def topics_for_page(connection: SQLiteConnection, slug: str) -> tuple[str, ...]:
|
|
272
|
+
rows = connection.execute(
|
|
273
|
+
"SELECT topic_slug FROM page_topics WHERE page_slug = ? ORDER BY topic_slug",
|
|
274
|
+
(slug,),
|
|
275
|
+
).fetchall()
|
|
276
|
+
return tuple(row["topic_slug"] for row in rows)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def file_refs_for_page(
|
|
280
|
+
connection: SQLiteConnection,
|
|
281
|
+
slug: str,
|
|
282
|
+
) -> tuple[PageFileReference, ...]:
|
|
283
|
+
rows = connection.execute(
|
|
284
|
+
"""
|
|
285
|
+
SELECT original_path, is_dir
|
|
286
|
+
FROM file_refs
|
|
287
|
+
WHERE page_slug = ?
|
|
288
|
+
ORDER BY original_path
|
|
289
|
+
""",
|
|
290
|
+
(slug,),
|
|
291
|
+
).fetchall()
|
|
292
|
+
return tuple(
|
|
293
|
+
PageFileReference(path=row["original_path"], is_dir=bool(row["is_dir"]))
|
|
294
|
+
for row in rows
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def wikilinks_out_for_page(
|
|
299
|
+
connection: SQLiteConnection,
|
|
300
|
+
slug: str,
|
|
301
|
+
) -> tuple[str, ...]:
|
|
302
|
+
rows = connection.execute(
|
|
303
|
+
"SELECT target_slug FROM wikilinks WHERE source_slug = ? ORDER BY target_slug",
|
|
304
|
+
(slug,),
|
|
305
|
+
).fetchall()
|
|
306
|
+
return tuple(row["target_slug"] for row in rows)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def wikilinks_in_for_page(connection: SQLiteConnection, slug: str) -> tuple[str, ...]:
|
|
310
|
+
rows = connection.execute(
|
|
311
|
+
"SELECT source_slug FROM wikilinks WHERE target_slug = ? ORDER BY source_slug",
|
|
312
|
+
(slug,),
|
|
313
|
+
).fetchall()
|
|
314
|
+
return tuple(row["source_slug"] for row in rows)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cross_wiki_for_page(
|
|
318
|
+
connection: SQLiteConnection,
|
|
319
|
+
slug: str,
|
|
320
|
+
) -> tuple[CrossWikiReference, ...]:
|
|
321
|
+
rows = connection.execute(
|
|
322
|
+
"""
|
|
323
|
+
SELECT target_wiki, target_slug
|
|
324
|
+
FROM cross_wiki_links
|
|
325
|
+
WHERE source_slug = ?
|
|
326
|
+
ORDER BY target_wiki, target_slug
|
|
327
|
+
""",
|
|
328
|
+
(slug,),
|
|
329
|
+
).fetchall()
|
|
330
|
+
return tuple(
|
|
331
|
+
CrossWikiReference(wiki=row["target_wiki"], target=row["target_slug"])
|
|
332
|
+
for row in rows
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def topic_descendants(connection: SQLiteConnection, slug: str) -> tuple[str, ...]:
|
|
337
|
+
rows = connection.execute(
|
|
338
|
+
"""
|
|
339
|
+
WITH RECURSIVE descendants(slug, depth) AS (
|
|
340
|
+
VALUES (?, 0)
|
|
341
|
+
UNION
|
|
342
|
+
SELECT tp.child_slug, descendants.depth + 1
|
|
343
|
+
FROM topic_parents tp
|
|
344
|
+
JOIN descendants ON tp.parent_slug = descendants.slug
|
|
345
|
+
WHERE descendants.depth < 32
|
|
346
|
+
)
|
|
347
|
+
SELECT slug FROM descendants ORDER BY slug
|
|
348
|
+
""",
|
|
349
|
+
(slug,),
|
|
350
|
+
).fetchall()
|
|
351
|
+
return tuple(row["slug"] for row in rows)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def pages_for_topics(
|
|
355
|
+
connection: SQLiteConnection,
|
|
356
|
+
topic_slugs: tuple[str, ...],
|
|
357
|
+
) -> tuple[str, ...]:
|
|
358
|
+
if len(topic_slugs) == 0:
|
|
359
|
+
return ()
|
|
360
|
+
placeholders = ", ".join("?" for _ in topic_slugs)
|
|
361
|
+
rows = connection.execute(
|
|
362
|
+
f"""
|
|
363
|
+
SELECT DISTINCT p.slug
|
|
364
|
+
FROM pages p
|
|
365
|
+
JOIN page_topics pt ON pt.page_slug = p.slug
|
|
366
|
+
WHERE p.archived_at IS NULL
|
|
367
|
+
AND pt.topic_slug IN ({placeholders})
|
|
368
|
+
ORDER BY p.slug
|
|
369
|
+
""",
|
|
370
|
+
topic_slugs,
|
|
371
|
+
).fetchall()
|
|
372
|
+
return tuple(row["slug"] for row in rows)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def topic_parents(connection: SQLiteConnection, slug: str) -> tuple[str, ...]:
|
|
376
|
+
rows = connection.execute(
|
|
377
|
+
"""
|
|
378
|
+
SELECT parent_slug
|
|
379
|
+
FROM topic_parents
|
|
380
|
+
WHERE child_slug = ?
|
|
381
|
+
ORDER BY parent_slug
|
|
382
|
+
""",
|
|
383
|
+
(slug,),
|
|
384
|
+
).fetchall()
|
|
385
|
+
return tuple(row["parent_slug"] for row in rows)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def topic_children(connection: SQLiteConnection, slug: str) -> tuple[str, ...]:
|
|
389
|
+
rows = connection.execute(
|
|
390
|
+
"""
|
|
391
|
+
SELECT child_slug
|
|
392
|
+
FROM topic_parents
|
|
393
|
+
WHERE parent_slug = ?
|
|
394
|
+
ORDER BY child_slug
|
|
395
|
+
""",
|
|
396
|
+
(slug,),
|
|
397
|
+
).fetchall()
|
|
398
|
+
return tuple(row["child_slug"] for row in rows)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def orphan_pages(connection: SQLiteConnection) -> tuple[OrphanPage, ...]:
|
|
402
|
+
rows = connection.execute(
|
|
403
|
+
"""
|
|
404
|
+
SELECT p.slug
|
|
405
|
+
FROM pages p
|
|
406
|
+
WHERE p.archived_at IS NULL
|
|
407
|
+
AND NOT EXISTS (
|
|
408
|
+
SELECT 1 FROM page_topics pt WHERE pt.page_slug = p.slug
|
|
409
|
+
)
|
|
410
|
+
ORDER BY p.slug
|
|
411
|
+
"""
|
|
412
|
+
).fetchall()
|
|
413
|
+
return tuple(OrphanPage(slug=row["slug"]) for row in rows)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def dead_file_refs(
|
|
417
|
+
connection: SQLiteConnection,
|
|
418
|
+
repo_root: Path,
|
|
419
|
+
) -> tuple[DeadFileReference, ...]:
|
|
420
|
+
rows = connection.execute(
|
|
421
|
+
"""
|
|
422
|
+
SELECT p.slug, r.original_path, r.is_dir
|
|
423
|
+
FROM pages p
|
|
424
|
+
JOIN file_refs r ON r.page_slug = p.slug
|
|
425
|
+
WHERE p.archived_at IS NULL
|
|
426
|
+
ORDER BY p.slug, r.original_path
|
|
427
|
+
"""
|
|
428
|
+
).fetchall()
|
|
429
|
+
findings: list[DeadFileReference] = []
|
|
430
|
+
for row in rows:
|
|
431
|
+
path = repo_root / row["original_path"]
|
|
432
|
+
exists = path.is_dir() if row["is_dir"] else path.is_file()
|
|
433
|
+
if not exists:
|
|
434
|
+
findings.append(
|
|
435
|
+
DeadFileReference(slug=row["slug"], path=row["original_path"])
|
|
436
|
+
)
|
|
437
|
+
return tuple(findings)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def broken_page_links(connection: SQLiteConnection) -> tuple[BrokenPageLink, ...]:
|
|
441
|
+
rows = connection.execute(
|
|
442
|
+
"""
|
|
443
|
+
SELECT w.source_slug, w.target_slug
|
|
444
|
+
FROM wikilinks w
|
|
445
|
+
JOIN pages source ON source.slug = w.source_slug
|
|
446
|
+
LEFT JOIN pages target ON target.slug = w.target_slug
|
|
447
|
+
WHERE source.archived_at IS NULL
|
|
448
|
+
AND target.slug IS NULL
|
|
449
|
+
ORDER BY w.source_slug, w.target_slug
|
|
450
|
+
"""
|
|
451
|
+
).fetchall()
|
|
452
|
+
return tuple(
|
|
453
|
+
BrokenPageLink(
|
|
454
|
+
source_slug=row["source_slug"],
|
|
455
|
+
target_slug=row["target_slug"],
|
|
456
|
+
)
|
|
457
|
+
for row in rows
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def broken_cross_wiki_links(
|
|
462
|
+
connection: SQLiteConnection,
|
|
463
|
+
registered_wikis: set[str],
|
|
464
|
+
) -> tuple[BrokenCrossWikiLink, ...]:
|
|
465
|
+
rows = connection.execute(
|
|
466
|
+
"""
|
|
467
|
+
SELECT x.source_slug, x.target_wiki, x.target_slug
|
|
468
|
+
FROM cross_wiki_links x
|
|
469
|
+
JOIN pages source ON source.slug = x.source_slug
|
|
470
|
+
WHERE source.archived_at IS NULL
|
|
471
|
+
ORDER BY x.source_slug, x.target_wiki, x.target_slug
|
|
472
|
+
"""
|
|
473
|
+
).fetchall()
|
|
474
|
+
return tuple(
|
|
475
|
+
BrokenCrossWikiLink(
|
|
476
|
+
source_slug=row["source_slug"],
|
|
477
|
+
target_wiki=row["target_wiki"],
|
|
478
|
+
target_slug=row["target_slug"],
|
|
479
|
+
)
|
|
480
|
+
for row in rows
|
|
481
|
+
if row["target_wiki"] not in registered_wikis
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def empty_topics(connection: SQLiteConnection) -> tuple[EmptyTopic, ...]:
|
|
486
|
+
rows = connection.execute(
|
|
487
|
+
"""
|
|
488
|
+
SELECT t.slug
|
|
489
|
+
FROM topics t
|
|
490
|
+
WHERE NOT EXISTS (
|
|
491
|
+
SELECT 1
|
|
492
|
+
FROM page_topics pt
|
|
493
|
+
JOIN pages p ON p.slug = pt.page_slug
|
|
494
|
+
WHERE pt.topic_slug = t.slug AND p.archived_at IS NULL
|
|
495
|
+
)
|
|
496
|
+
ORDER BY t.slug
|
|
497
|
+
"""
|
|
498
|
+
).fetchall()
|
|
499
|
+
return tuple(EmptyTopic(slug=row["slug"]) for row in rows)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def empty_pages(connection: SQLiteConnection) -> tuple[EmptyPage, ...]:
|
|
503
|
+
rows = connection.execute(
|
|
504
|
+
"""
|
|
505
|
+
SELECT slug, body
|
|
506
|
+
FROM pages
|
|
507
|
+
WHERE archived_at IS NULL
|
|
508
|
+
ORDER BY slug
|
|
509
|
+
"""
|
|
510
|
+
).fetchall()
|
|
511
|
+
return tuple(
|
|
512
|
+
EmptyPage(slug=row["slug"])
|
|
513
|
+
for row in rows
|
|
514
|
+
if not meaningful_body_text(row["body"])
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def meaningful_body_text(body: str) -> str:
|
|
519
|
+
lines = []
|
|
520
|
+
for line in body.splitlines():
|
|
521
|
+
if re.match(r"^\s*#+\s+", line):
|
|
522
|
+
continue
|
|
523
|
+
lines.append(line.strip())
|
|
524
|
+
return "\n".join(lines).strip()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import field_validator
|
|
4
|
+
|
|
5
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
6
|
+
from codealmanac.core.text import required_text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ShowPageRequest(CodeAlmanacModel):
|
|
10
|
+
cwd: Path
|
|
11
|
+
slug: str
|
|
12
|
+
wiki: str | None = None
|
|
13
|
+
|
|
14
|
+
@field_validator("slug")
|
|
15
|
+
@classmethod
|
|
16
|
+
def require_slug(cls, value: str) -> str:
|
|
17
|
+
return required_text(value, "slug")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from codealmanac.core.errors import NotFoundError
|
|
2
|
+
from codealmanac.core.slug import to_kebab_case
|
|
3
|
+
from codealmanac.services.index.models import PageView
|
|
4
|
+
from codealmanac.services.index.service import IndexService
|
|
5
|
+
from codealmanac.services.pages.requests import ShowPageRequest
|
|
6
|
+
from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
|
|
7
|
+
from codealmanac.services.workspaces.service import WorkspacesService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PagesService:
|
|
11
|
+
def __init__(self, workspaces: WorkspacesService, index: IndexService):
|
|
12
|
+
self.workspaces = workspaces
|
|
13
|
+
self.index = index
|
|
14
|
+
|
|
15
|
+
def show(self, request: ShowPageRequest) -> PageView:
|
|
16
|
+
if request.wiki is None:
|
|
17
|
+
workspace = self.workspaces.resolve(request.cwd)
|
|
18
|
+
else:
|
|
19
|
+
workspace = self.workspaces.select(
|
|
20
|
+
SelectWorkspaceRequest(selector=request.wiki, base_path=request.cwd)
|
|
21
|
+
)
|
|
22
|
+
slug = to_kebab_case(request.slug)
|
|
23
|
+
page = self.index.get_page(workspace.workspace_id, slug)
|
|
24
|
+
if page is None:
|
|
25
|
+
raise NotFoundError("page", request.slug)
|
|
26
|
+
return page
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Run ledger service for local CodeAlmanac lifecycle jobs."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import field_validator
|
|
6
|
+
|
|
7
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
8
|
+
from codealmanac.core.text import required_text
|
|
9
|
+
from codealmanac.services.harnesses.models import HarnessTranscriptRef
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunOperation(StrEnum):
|
|
13
|
+
BUILD = "build"
|
|
14
|
+
INGEST = "ingest"
|
|
15
|
+
SYNC = "sync"
|
|
16
|
+
GARDEN = "garden"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RunStatus(StrEnum):
|
|
20
|
+
QUEUED = "queued"
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
DONE = "done"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
CANCELLED = "cancelled"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunEventKind(StrEnum):
|
|
28
|
+
STATUS = "status"
|
|
29
|
+
MESSAGE = "message"
|
|
30
|
+
TOOL = "tool"
|
|
31
|
+
OUTPUT = "output"
|
|
32
|
+
ERROR = "error"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PageChangeSet(CodeAlmanacModel):
|
|
36
|
+
created: tuple[str, ...] = ()
|
|
37
|
+
updated: tuple[str, ...] = ()
|
|
38
|
+
archived: tuple[str, ...] = ()
|
|
39
|
+
deleted: tuple[str, ...] = ()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RunRecord(CodeAlmanacModel):
|
|
43
|
+
run_id: str
|
|
44
|
+
workspace_id: str
|
|
45
|
+
operation: RunOperation
|
|
46
|
+
status: RunStatus
|
|
47
|
+
title: str | None
|
|
48
|
+
summary: str | None = None
|
|
49
|
+
error: str | None = None
|
|
50
|
+
created_at: datetime
|
|
51
|
+
updated_at: datetime
|
|
52
|
+
started_at: datetime | None = None
|
|
53
|
+
finished_at: datetime | None = None
|
|
54
|
+
log_path: Path
|
|
55
|
+
page_changes: PageChangeSet | None = None
|
|
56
|
+
harness_transcript: HarnessTranscriptRef | None = None
|
|
57
|
+
|
|
58
|
+
@field_validator("run_id")
|
|
59
|
+
@classmethod
|
|
60
|
+
def require_run_id(cls, value: str) -> str:
|
|
61
|
+
return required_text(value, "run_id")
|
|
62
|
+
|
|
63
|
+
@field_validator("workspace_id")
|
|
64
|
+
@classmethod
|
|
65
|
+
def require_workspace_id(cls, value: str) -> str:
|
|
66
|
+
return required_text(value, "workspace_id")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RunLogEvent(CodeAlmanacModel):
|
|
70
|
+
run_id: str
|
|
71
|
+
sequence: int
|
|
72
|
+
timestamp: datetime
|
|
73
|
+
kind: RunEventKind
|
|
74
|
+
message: str
|
|
75
|
+
|
|
76
|
+
@field_validator("run_id")
|
|
77
|
+
@classmethod
|
|
78
|
+
def require_run_id(cls, value: str) -> str:
|
|
79
|
+
return required_text(value, "run_id")
|
|
80
|
+
|
|
81
|
+
@field_validator("message")
|
|
82
|
+
@classmethod
|
|
83
|
+
def require_message(cls, value: str) -> str:
|
|
84
|
+
return required_text(value, "message")
|
|
85
|
+
|
|
86
|
+
@field_validator("sequence")
|
|
87
|
+
@classmethod
|
|
88
|
+
def positive_sequence(cls, value: int) -> int:
|
|
89
|
+
if value < 1:
|
|
90
|
+
raise ValueError("sequence must be positive")
|
|
91
|
+
return value
|