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,290 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+
5
+ from codealmanac.services.index.models import (
6
+ HealthReport,
7
+ IndexRefreshResult,
8
+ PageView,
9
+ SearchPageResult,
10
+ TopicDetail,
11
+ TopicSummary,
12
+ )
13
+ from codealmanac.services.tagging.models import TaggingResult
14
+ from codealmanac.services.topics.models import (
15
+ TopicEdgeMutationResult,
16
+ TopicMutationAction,
17
+ TopicMutationResult,
18
+ TopicRewriteMutationResult,
19
+ )
20
+ from codealmanac.services.workspaces.models import (
21
+ DropWorkspaceResult,
22
+ WorkspaceListResult,
23
+ )
24
+ from codealmanac.workflows.garden.models import GardenResult
25
+ from codealmanac.workflows.ingest.models import IngestResult
26
+ from codealmanac.workflows.sync.models import SyncMode, SyncSummary
27
+
28
+
29
+ def render_search(rows: tuple[SearchPageResult, ...], json_output: bool) -> None:
30
+ if json_output:
31
+ data = [row.model_dump(mode="json") for row in rows]
32
+ print(json.dumps(data, indent=2))
33
+ return
34
+ if len(rows) == 0:
35
+ print("# 0 results", file=sys.stderr)
36
+ return
37
+ for row in rows:
38
+ print(row.slug)
39
+
40
+
41
+ def render_workspace_list(result: WorkspaceListResult, json_output: bool) -> None:
42
+ if json_output:
43
+ data = [item.model_dump(mode="json") for item in result.items]
44
+ print(json.dumps(data, indent=2))
45
+ return
46
+ for item in result.items:
47
+ workspace = item.workspace
48
+ print(
49
+ f"{workspace.name}\t{workspace.root_path}\t"
50
+ f"{workspace.almanac_root.as_posix()}"
51
+ )
52
+
53
+
54
+ def render_workspace_drop(result: DropWorkspaceResult, json_output: bool) -> None:
55
+ if json_output:
56
+ print(json.dumps(result.model_dump(mode="json"), indent=2))
57
+ return
58
+ if len(result.dropped) == 0:
59
+ print("# 0 wikis dropped", file=sys.stderr)
60
+ return
61
+ for workspace in result.dropped:
62
+ print(
63
+ f"dropped {workspace.name}\t{workspace.root_path}\t"
64
+ f"{workspace.almanac_root.as_posix()}"
65
+ )
66
+
67
+
68
+ def render_build(workspace_name: str, result: IndexRefreshResult) -> None:
69
+ print(f"built {workspace_name}: {index_summary(result)}")
70
+
71
+ def render_ingest(result: IngestResult) -> None:
72
+ print(f"ingested {result.run.run_id}: {result.run.status.value}")
73
+ print(f"sources: {len(result.sources)}")
74
+ print(f"wiki_changes: {len(result.safety.changed_files)}")
75
+ if result.run.summary is not None:
76
+ print(f"summary: {result.run.summary}")
77
+
78
+ def render_garden(result: GardenResult) -> None:
79
+ print(f"gardened {result.run.run_id}: {result.run.status.value}")
80
+ print(f"wiki_changes: {len(result.safety.changed_files)}")
81
+ print(f"health_before: {health_issue_count(result.health_before)}")
82
+ if result.run.summary is not None:
83
+ print(f"summary: {result.run.summary}")
84
+
85
+ def render_sync_status(summary: SyncSummary, json_output: bool) -> None:
86
+ if json_output:
87
+ print(json.dumps(summary.model_dump(mode="json"), indent=2))
88
+ return
89
+ status_mode = summary.mode == SyncMode.STATUS
90
+ print("sync status:" if status_mode else "sync:")
91
+ print(f" scanned: {summary.scanned}")
92
+ print(f" eligible: {summary.eligible}")
93
+ if status_mode:
94
+ print(f" ready: {len(summary.ready)}")
95
+ else:
96
+ print(f" started: {len(summary.started)}")
97
+ print(f" skipped: {len(summary.skipped)}")
98
+ print(f" needs_attention: {len(summary.needs_attention)}")
99
+ for ready in summary.ready:
100
+ print(
101
+ f" - ready {ready.app.value} {ready.session_id}: "
102
+ f"lines {ready.from_line}-{ready.to_line}"
103
+ )
104
+ for started in summary.started:
105
+ print(
106
+ f" - started {started.app.value} {started.session_id}: "
107
+ f"{started.run_id} (lines {started.from_line}-{started.to_line})"
108
+ )
109
+ for item in summary.needs_attention:
110
+ print(f" - needs attention {item.transcript_path}: {item.reason}")
111
+
112
+ def health_issue_count(report: HealthReport) -> int:
113
+ return sum(
114
+ len(items)
115
+ for items in (
116
+ report.orphans,
117
+ report.dead_refs,
118
+ report.broken_links,
119
+ report.broken_xwiki,
120
+ report.empty_topics,
121
+ report.empty_pages,
122
+ )
123
+ )
124
+
125
+ def render_reindex(result: IndexRefreshResult, json_output: bool) -> None:
126
+ if json_output:
127
+ print(json.dumps(result.model_dump(mode="json"), indent=2))
128
+ return
129
+ print(f"reindexed: {index_summary(result)}")
130
+
131
+ def index_summary(result: IndexRefreshResult) -> str:
132
+ skip_suffix = (
133
+ f"; {result.files_skipped} skipped" if result.files_skipped > 0 else ""
134
+ )
135
+ return (
136
+ f"{result.pages_indexed} {page_word(result.pages_indexed)} "
137
+ f"({result.changed} updated, {result.removed} removed{skip_suffix})"
138
+ )
139
+
140
+ def render_page(page: PageView, args: argparse.Namespace) -> None:
141
+ if args.json:
142
+ print(json.dumps(page.model_dump(mode="json"), indent=2))
143
+ return
144
+ if args.body:
145
+ print(body_with_trailing_newline(page.body), end="")
146
+ return
147
+ if args.links:
148
+ print_lines(page.wikilinks_out)
149
+ return
150
+ if args.backlinks:
151
+ print_lines(page.wikilinks_in)
152
+ return
153
+ if args.files:
154
+ print_lines(tuple(ref.path for ref in page.file_refs))
155
+ return
156
+ if args.topics:
157
+ print_lines(page.topics)
158
+ return
159
+ if args.meta:
160
+ print(metadata_header(page))
161
+ return
162
+ if args.lead:
163
+ print(first_paragraph(page.body))
164
+ return
165
+ print(body_with_trailing_newline(page.body), end="")
166
+
167
+ def print_lines(values: tuple[str, ...]) -> None:
168
+ for value in values:
169
+ print(value)
170
+
171
+ def metadata_header(page: PageView) -> str:
172
+ lines = [
173
+ f"slug: {page.slug}",
174
+ f"title: {page.title or ''}",
175
+ f"path: {page.file_path}",
176
+ ]
177
+ if page.summary:
178
+ lines.append(f"summary: {page.summary}")
179
+ if page.topics:
180
+ lines.append(f"topics: {', '.join(page.topics)}")
181
+ return "\n".join(lines)
182
+
183
+ def first_paragraph(body: str) -> str:
184
+ paragraphs = [part.strip() for part in body.split("\n\n") if part.strip()]
185
+ return paragraphs[0] if paragraphs else ""
186
+
187
+ def body_with_trailing_newline(body: str) -> str:
188
+ if body == "" or body.endswith("\n"):
189
+ return body
190
+ return f"{body}\n"
191
+
192
+ def render_topics(rows: tuple[TopicSummary, ...]) -> None:
193
+ for row in rows:
194
+ title = row.title or row.slug
195
+ print(f"{row.slug}\t{row.page_count}\t{title}")
196
+
197
+ def render_topic(topic: TopicDetail) -> None:
198
+ print(f"slug: {topic.slug}")
199
+ print(f"title: {topic.title or ''}")
200
+ if topic.description:
201
+ print(f"description: {topic.description}")
202
+ if topic.parents:
203
+ print(f"parents: {', '.join(topic.parents)}")
204
+ if topic.children:
205
+ print(f"children: {', '.join(topic.children)}")
206
+ if topic.pages:
207
+ print("pages:")
208
+ for slug in topic.pages:
209
+ print(f" {slug}")
210
+ else:
211
+ print("pages: none")
212
+
213
+ def render_topic_mutation(result: TopicMutationResult) -> None:
214
+ print(f"{result.slug}: {result.action.value}")
215
+
216
+ def render_topic_edge_mutation(result: TopicEdgeMutationResult) -> None:
217
+ if result.action == TopicMutationAction.NO_EDGE:
218
+ print(f"no edge {result.child} -> {result.parent}")
219
+ return
220
+ if result.action == TopicMutationAction.ALREADY_LINKED:
221
+ print(f"edge {result.child} -> {result.parent} already exists")
222
+ return
223
+ print(f"{result.action.value} {result.child} -> {result.parent}")
224
+
225
+ def render_topic_rewrite_mutation(result: TopicRewriteMutationResult) -> None:
226
+ if result.action == TopicMutationAction.UNCHANGED:
227
+ print(f"topic {result.slug} unchanged")
228
+ return
229
+ if result.action == TopicMutationAction.RENAMED:
230
+ print(
231
+ f"renamed {result.slug} -> {result.new_slug} "
232
+ f"({result.pages_updated} {page_word(result.pages_updated)} updated)"
233
+ )
234
+ return
235
+ if result.action == TopicMutationAction.DELETED:
236
+ print(
237
+ f"deleted {result.slug} "
238
+ f"({result.pages_updated} {page_word(result.pages_updated)} untagged)"
239
+ )
240
+ return
241
+ print(f"{result.slug}: {result.action.value}")
242
+
243
+ def page_word(count: int) -> str:
244
+ return "page" if count == 1 else "pages"
245
+
246
+ def render_health(report: HealthReport, json_output: bool) -> None:
247
+ if json_output:
248
+ print(json.dumps(report.model_dump(mode="json"), indent=2))
249
+ return
250
+ render_health_section("orphans", tuple(item.slug for item in report.orphans))
251
+ render_health_section(
252
+ "dead_refs",
253
+ tuple(f"{item.slug}\t{item.path}" for item in report.dead_refs),
254
+ )
255
+ render_health_section(
256
+ "broken_links",
257
+ tuple(
258
+ f"{item.source_slug}\t{item.target_slug}" for item in report.broken_links
259
+ ),
260
+ )
261
+ render_health_section(
262
+ "broken_xwiki",
263
+ tuple(
264
+ f"{item.source_slug}\t{item.target_wiki}:{item.target_slug}"
265
+ for item in report.broken_xwiki
266
+ ),
267
+ )
268
+ render_health_section(
269
+ "empty_topics",
270
+ tuple(item.slug for item in report.empty_topics),
271
+ )
272
+ render_health_section(
273
+ "empty_pages",
274
+ tuple(item.slug for item in report.empty_pages),
275
+ )
276
+
277
+ def render_health_section(name: str, rows: tuple[str, ...]) -> None:
278
+ if not rows:
279
+ print(f"{name} (0): ok")
280
+ return
281
+ print(f"{name} ({len(rows)}):")
282
+ for row in rows:
283
+ print(f" {row}")
284
+
285
+ def render_tagging(changed_label: str, unchanged_label: str, result: TaggingResult):
286
+ if result.changed_topics:
287
+ print(f"{result.slug}: {changed_label} {', '.join(result.changed_topics)}")
288
+ return
289
+ unchanged = ", ".join(result.requested_topics)
290
+ print(f"{result.slug}: {unchanged_label} {unchanged}")
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,45 @@
1
+ from typing import Literal
2
+
3
+ ErrorCode = Literal[
4
+ "codealmanac_error",
5
+ "not_found",
6
+ "conflict",
7
+ "validation_failed",
8
+ "execution_failed",
9
+ ]
10
+
11
+
12
+ class CodeAlmanacError(Exception):
13
+ """Base class for product errors that cross CLI and future server edges."""
14
+
15
+ code: ErrorCode = "codealmanac_error"
16
+
17
+
18
+ class NotFoundError(CodeAlmanacError):
19
+ code: ErrorCode = "not_found"
20
+
21
+ def __init__(self, resource: str, identifier: str):
22
+ super().__init__(f"{resource} not found: {identifier}")
23
+ self.resource = resource
24
+ self.identifier = identifier
25
+
26
+
27
+ class ConflictError(CodeAlmanacError):
28
+ code: ErrorCode = "conflict"
29
+
30
+ def __init__(self, message: str):
31
+ super().__init__(message)
32
+
33
+
34
+ class ValidationFailed(CodeAlmanacError):
35
+ code: ErrorCode = "validation_failed"
36
+
37
+ def __init__(self, message: str):
38
+ super().__init__(message)
39
+
40
+
41
+ class ExecutionFailed(CodeAlmanacError):
42
+ code: ErrorCode = "execution_failed"
43
+
44
+ def __init__(self, message: str):
45
+ super().__init__(message)
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from codealmanac.core.paths import default_config_path, default_registry_path
6
+
7
+
8
+ class CodeAlmanacModel(BaseModel):
9
+ model_config = ConfigDict(frozen=True, extra="forbid")
10
+
11
+
12
+ class AppConfig(CodeAlmanacModel):
13
+ registry_path: Path = Field(default_factory=default_registry_path)
14
+ config_path: Path = Field(default_factory=default_config_path)
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def home_dir() -> Path:
5
+ return Path.home()
6
+
7
+
8
+ def state_dir_for(home: Path) -> Path:
9
+ return home / ".codealmanac"
10
+
11
+
12
+ def global_state_dir() -> Path:
13
+ return state_dir_for(home_dir())
14
+
15
+
16
+ def default_registry_path() -> Path:
17
+ return global_state_dir() / "registry.json"
18
+
19
+
20
+ def default_config_path() -> Path:
21
+ return global_state_dir() / "config.toml"
22
+
23
+
24
+ def normalize_path(path: Path) -> Path:
25
+ return path.expanduser().resolve(strict=False)
@@ -0,0 +1,7 @@
1
+ import re
2
+
3
+
4
+ def to_kebab_case(value: str) -> str:
5
+ text = value.strip().lower()
6
+ text = re.sub(r"[^a-z0-9]+", "-", text)
7
+ return text.strip("-")
@@ -0,0 +1,5 @@
1
+ def required_text(value: str, field_name: str) -> str:
2
+ text = value.strip()
3
+ if not text:
4
+ raise ValueError(f"{field_name} must not be empty")
5
+ return text
@@ -0,0 +1,15 @@
1
+ from codealmanac.database.sqlite import (
2
+ SQLiteConnection,
3
+ SQLiteMigration,
4
+ SQLiteRow,
5
+ apply_migrations,
6
+ connect_sqlite,
7
+ )
8
+
9
+ __all__ = (
10
+ "SQLiteConnection",
11
+ "SQLiteMigration",
12
+ "SQLiteRow",
13
+ "apply_migrations",
14
+ "connect_sqlite",
15
+ )
@@ -0,0 +1,54 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+
4
+ from pydantic import field_validator
5
+
6
+ from codealmanac.core.models import CodeAlmanacModel
7
+ from codealmanac.core.text import required_text
8
+
9
+ type SQLiteConnection = sqlite3.Connection
10
+ type SQLiteRow = sqlite3.Row
11
+
12
+
13
+ class SQLiteMigration(CodeAlmanacModel):
14
+ version: int
15
+ sql: str
16
+
17
+ @field_validator("version")
18
+ @classmethod
19
+ def positive_version(cls, value: int) -> int:
20
+ if value <= 0:
21
+ raise ValueError("SQLite migration version must be positive")
22
+ return value
23
+
24
+ @field_validator("sql")
25
+ @classmethod
26
+ def require_sql(cls, value: str) -> str:
27
+ return required_text(value, "SQLite migration SQL")
28
+
29
+
30
+ def connect_sqlite(path: Path) -> SQLiteConnection:
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ connection = sqlite3.connect(path)
33
+ connection.row_factory = sqlite3.Row
34
+ connection.execute("PRAGMA foreign_keys = ON")
35
+ connection.execute("PRAGMA journal_mode = WAL")
36
+ return connection
37
+
38
+
39
+ def apply_migrations(
40
+ connection: SQLiteConnection,
41
+ migrations: tuple[SQLiteMigration, ...],
42
+ ) -> None:
43
+ current = user_version(connection)
44
+ for migration in sorted(migrations, key=lambda item: item.version):
45
+ if migration.version <= current:
46
+ continue
47
+ connection.executescript(migration.sql)
48
+ connection.execute(f"PRAGMA user_version = {migration.version}")
49
+ current = migration.version
50
+ connection.commit()
51
+
52
+
53
+ def user_version(connection: SQLiteConnection) -> int:
54
+ return int(connection.execute("PRAGMA user_version").fetchone()[0])
@@ -0,0 +1 @@
1
+ """Concrete outside-world adapters."""
@@ -0,0 +1,3 @@
1
+ from codealmanac.integrations.automation.scheduler import LaunchdSchedulerAdapter
2
+
3
+ __all__ = ["LaunchdSchedulerAdapter"]
@@ -0,0 +1,5 @@
1
+ from codealmanac.integrations.automation.scheduler.launchd import (
2
+ LaunchdSchedulerAdapter,
3
+ )
4
+
5
+ __all__ = ["LaunchdSchedulerAdapter"]
@@ -0,0 +1,163 @@
1
+ import os
2
+ import plistlib
3
+ import subprocess
4
+ from datetime import timedelta
5
+ from pathlib import Path
6
+
7
+ from codealmanac.core.errors import ExecutionFailed
8
+ from codealmanac.services.automation.models import (
9
+ EnvironmentVariable,
10
+ ScheduledJob,
11
+ ScheduledJobStatus,
12
+ )
13
+
14
+
15
+ class LaunchdSchedulerAdapter:
16
+ def install(self, job: ScheduledJob) -> ScheduledJobStatus:
17
+ job.plist_path.parent.mkdir(parents=True, exist_ok=True)
18
+ job.stdout_path.parent.mkdir(parents=True, exist_ok=True)
19
+ job.stderr_path.parent.mkdir(parents=True, exist_ok=True)
20
+ with job.plist_path.open("wb") as handle:
21
+ plistlib.dump(launchd_plist(job), handle, sort_keys=False)
22
+ self._bootout(job)
23
+ self._bootstrap(job)
24
+ return self.status(job)
25
+
26
+ def uninstall(self, job: ScheduledJob) -> bool:
27
+ if not job.plist_path.exists():
28
+ return False
29
+ self._bootout(job)
30
+ job.plist_path.unlink(missing_ok=True)
31
+ return True
32
+
33
+ def status(self, job: ScheduledJob) -> ScheduledJobStatus:
34
+ if not job.plist_path.exists():
35
+ return ScheduledJobStatus(
36
+ task=job.task,
37
+ label=job.label,
38
+ plist_path=job.plist_path,
39
+ installed=False,
40
+ loaded=self._is_loaded(job),
41
+ )
42
+ data = read_plist(job.plist_path)
43
+ return ScheduledJobStatus(
44
+ task=job.task,
45
+ label=job.label,
46
+ plist_path=job.plist_path,
47
+ installed=True,
48
+ loaded=self._is_loaded(job),
49
+ interval=read_interval(data),
50
+ quiet=read_quiet(data),
51
+ )
52
+
53
+ def _bootstrap(self, job: ScheduledJob) -> None:
54
+ result = self._run_launchctl(
55
+ ("bootstrap", launchd_target(), str(job.plist_path))
56
+ )
57
+ if result.returncode != 0:
58
+ raise ExecutionFailed(
59
+ "launchctl bootstrap failed for "
60
+ f"{job.label}: {surface_process_error(result)}"
61
+ )
62
+
63
+ def _bootout(self, job: ScheduledJob) -> None:
64
+ self._run_launchctl(("bootout", launchd_target(), str(job.plist_path)))
65
+
66
+ def _is_loaded(self, job: ScheduledJob) -> bool:
67
+ result = self._run_launchctl(("print", f"{launchd_target()}/{job.label}"))
68
+ return result.returncode == 0
69
+
70
+ def _run_launchctl(self, args: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
71
+ try:
72
+ return subprocess.run(
73
+ ("launchctl", *args),
74
+ check=False,
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+ except OSError as error:
79
+ return subprocess.CompletedProcess(
80
+ args=("launchctl", *args),
81
+ returncode=1,
82
+ stdout="",
83
+ stderr=str(error),
84
+ )
85
+
86
+
87
+ def launchd_plist(job: ScheduledJob) -> dict[str, object]:
88
+ data: dict[str, object] = {
89
+ "Label": job.label,
90
+ "ProgramArguments": list(job.program_arguments),
91
+ "StartInterval": int(job.interval.total_seconds()),
92
+ "EnvironmentVariables": environment_dict(job.environment),
93
+ "RunAtLoad": True,
94
+ "StandardOutPath": str(job.stdout_path),
95
+ "StandardErrorPath": str(job.stderr_path),
96
+ }
97
+ if job.working_directory is not None:
98
+ data["WorkingDirectory"] = str(job.working_directory)
99
+ return data
100
+
101
+
102
+ def read_plist(path: Path) -> dict[str, object]:
103
+ with path.open("rb") as handle:
104
+ data = plistlib.load(handle)
105
+ if not isinstance(data, dict):
106
+ return {}
107
+ return data
108
+
109
+
110
+ def read_interval(data: dict[str, object]) -> timedelta | None:
111
+ value = data.get("StartInterval")
112
+ if not isinstance(value, int):
113
+ return None
114
+ return timedelta(seconds=value)
115
+
116
+
117
+ def read_quiet(data: dict[str, object]) -> timedelta | None:
118
+ args = data.get("ProgramArguments")
119
+ if not isinstance(args, list):
120
+ return None
121
+ values = [item for item in args if isinstance(item, str)]
122
+ try:
123
+ index = values.index("--quiet")
124
+ except ValueError:
125
+ return None
126
+ if index + 1 >= len(values):
127
+ return None
128
+ return parse_compact_duration(values[index + 1])
129
+
130
+
131
+ def parse_compact_duration(value: str) -> timedelta | None:
132
+ if value.endswith("h"):
133
+ parsed = parse_int(value[:-1])
134
+ return None if parsed is None else timedelta(hours=parsed)
135
+ if value.endswith("m"):
136
+ parsed = parse_int(value[:-1])
137
+ return None if parsed is None else timedelta(minutes=parsed)
138
+ if value.endswith("s"):
139
+ parsed = parse_int(value[:-1])
140
+ return None if parsed is None else timedelta(seconds=parsed)
141
+ return None
142
+
143
+
144
+ def parse_int(value: str) -> int | None:
145
+ try:
146
+ return int(value)
147
+ except ValueError:
148
+ return None
149
+
150
+
151
+ def environment_dict(values: tuple[EnvironmentVariable, ...]) -> dict[str, str]:
152
+ return {item.name: item.value for item in values}
153
+
154
+
155
+ def launchd_target() -> str:
156
+ return f"gui/{os.getuid()}"
157
+
158
+
159
+ def surface_process_error(result: subprocess.CompletedProcess[str]) -> str:
160
+ text = result.stderr.strip() or result.stdout.strip()
161
+ if len(text) > 500:
162
+ return f"{text[:500]}..."
163
+ return text or f"exit {result.returncode}"