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.
Files changed (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. 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