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,22 @@
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
+ from codealmanac.services.harnesses.models import HarnessKind
8
+
9
+
10
+ class RunGardenRequest(CodeAlmanacModel):
11
+ cwd: Path
12
+ harness: HarnessKind
13
+ wiki: str | None = None
14
+ title: str | None = None
15
+ guidance: str | None = None
16
+
17
+ @field_validator("title", "guidance")
18
+ @classmethod
19
+ def require_optional_text(cls, value: str | None) -> str | None:
20
+ if value is None:
21
+ return None
22
+ return required_text(value, "garden request text")
@@ -0,0 +1,239 @@
1
+ from contextlib import suppress
2
+ from pathlib import Path
3
+
4
+ from codealmanac.prompts import PromptName, PromptRenderer, RenderPromptRequest
5
+ from codealmanac.services.harnesses.models import HarnessRunResult
6
+ from codealmanac.services.harnesses.requests import RunHarnessRequest
7
+ from codealmanac.services.harnesses.service import HarnessesService
8
+ from codealmanac.services.health.requests import HealthCheckRequest
9
+ from codealmanac.services.health.service import HealthService
10
+ from codealmanac.services.index.models import HealthReport, IndexSummary
11
+ from codealmanac.services.index.service import IndexService
12
+ from codealmanac.services.runs.models import RunEventKind, RunOperation, RunStatus
13
+ from codealmanac.services.runs.requests import (
14
+ FinishRunRequest,
15
+ MarkRunRunningRequest,
16
+ RecordRunEventRequest,
17
+ RecordRunHarnessTranscriptRequest,
18
+ StartRunRequest,
19
+ )
20
+ from codealmanac.services.runs.service import RunsService
21
+ from codealmanac.services.workspaces.models import Workspace
22
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
23
+ from codealmanac.services.workspaces.service import WorkspacesService
24
+ from codealmanac.workflows.garden.models import GardenPromptPayload, GardenResult
25
+ from codealmanac.workflows.garden.requests import RunGardenRequest
26
+ from codealmanac.workflows.lifecycle import (
27
+ LifecycleMutationPolicy,
28
+ first_line,
29
+ harness_events,
30
+ harness_run_event_kind,
31
+ validate_harness_result,
32
+ )
33
+
34
+ GARDEN_PROMPT_SECTIONS = (
35
+ PromptName.BASE_PURPOSE,
36
+ PromptName.BASE_NOTABILITY,
37
+ PromptName.BASE_SYNTAX,
38
+ PromptName.OPERATION_GARDEN,
39
+ )
40
+
41
+
42
+ class GardenWorkflow:
43
+ def __init__(
44
+ self,
45
+ workspaces: WorkspacesService,
46
+ harnesses: HarnessesService,
47
+ runs: RunsService,
48
+ index: IndexService,
49
+ health: HealthService,
50
+ mutation_policy: LifecycleMutationPolicy,
51
+ prompts: PromptRenderer,
52
+ ):
53
+ self.workspaces = workspaces
54
+ self.harnesses = harnesses
55
+ self.runs = runs
56
+ self.index = index
57
+ self.health = health
58
+ self.mutation_policy = mutation_policy
59
+ self.prompts = prompts
60
+
61
+ def run(self, request: RunGardenRequest) -> GardenResult:
62
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
63
+ started = self.runs.start(
64
+ StartRunRequest(
65
+ cwd=request.cwd,
66
+ wiki=request.wiki,
67
+ operation=RunOperation.GARDEN,
68
+ title=request.title or "Garden wiki",
69
+ )
70
+ )
71
+ self.runs.mark_running(
72
+ MarkRunRunningRequest(
73
+ cwd=request.cwd,
74
+ wiki=request.wiki,
75
+ run_id=started.run_id,
76
+ )
77
+ )
78
+ try:
79
+ index_before = self.index.summary(workspace.workspace_id)
80
+ health_before = self.health.check(
81
+ HealthCheckRequest(cwd=request.cwd, wiki=request.wiki)
82
+ )
83
+ self.record(
84
+ request,
85
+ started.run_id,
86
+ RunEventKind.MESSAGE,
87
+ "prepared garden context",
88
+ )
89
+ preflight = self.mutation_policy.preflight(workspace)
90
+ self.record(
91
+ request,
92
+ started.run_id,
93
+ RunEventKind.MESSAGE,
94
+ f"verified clean {workspace.almanac_root.as_posix()} preflight",
95
+ )
96
+ harness = self.harnesses.run(
97
+ RunHarnessRequest(
98
+ kind=request.harness,
99
+ cwd=workspace.root_path,
100
+ prompt=render_garden_prompt(
101
+ self.prompts,
102
+ workspace,
103
+ index_before,
104
+ health_before,
105
+ request.guidance,
106
+ ),
107
+ title=request.title,
108
+ )
109
+ )
110
+ self.record_harness_transcript(request, started.run_id, harness)
111
+ self.record_harness(request, started.run_id, harness)
112
+ safety = self.mutation_policy.validate(
113
+ preflight,
114
+ workspace,
115
+ harness.changed_files,
116
+ )
117
+ validate_harness_result(harness)
118
+ index_after = self.index.ensure_fresh(workspace.workspace_id)
119
+ finished = self.runs.finish(
120
+ FinishRunRequest(
121
+ cwd=request.cwd,
122
+ wiki=request.wiki,
123
+ run_id=started.run_id,
124
+ status=RunStatus.DONE,
125
+ summary=harness.summary or "garden completed",
126
+ )
127
+ )
128
+ return GardenResult(
129
+ run=finished,
130
+ harness=harness,
131
+ safety=safety,
132
+ index=index_after,
133
+ health_before=health_before,
134
+ )
135
+ except Exception as error:
136
+ self.fail_run(request, started.run_id, error)
137
+ raise
138
+
139
+ def resolve_workspace(self, cwd: Path, wiki: str | None) -> Workspace:
140
+ if wiki is None:
141
+ return self.workspaces.resolve(cwd)
142
+ return self.workspaces.select(
143
+ SelectWorkspaceRequest(selector=wiki, base_path=cwd)
144
+ )
145
+
146
+ def record(
147
+ self,
148
+ request: RunGardenRequest,
149
+ run_id: str,
150
+ kind: RunEventKind,
151
+ message: str,
152
+ ) -> None:
153
+ self.runs.record_event(
154
+ RecordRunEventRequest(
155
+ cwd=request.cwd,
156
+ wiki=request.wiki,
157
+ run_id=run_id,
158
+ kind=kind,
159
+ message=message,
160
+ )
161
+ )
162
+
163
+ def record_harness_transcript(
164
+ self,
165
+ request: RunGardenRequest,
166
+ run_id: str,
167
+ harness: HarnessRunResult,
168
+ ) -> None:
169
+ if harness.transcript is None:
170
+ return
171
+ self.runs.record_harness_transcript(
172
+ RecordRunHarnessTranscriptRequest(
173
+ cwd=request.cwd,
174
+ wiki=request.wiki,
175
+ run_id=run_id,
176
+ transcript=harness.transcript,
177
+ )
178
+ )
179
+
180
+ def record_harness(
181
+ self,
182
+ request: RunGardenRequest,
183
+ run_id: str,
184
+ harness: HarnessRunResult,
185
+ ) -> None:
186
+ for event in harness_events(harness):
187
+ self.record(
188
+ request,
189
+ run_id,
190
+ harness_run_event_kind(event),
191
+ event.message,
192
+ )
193
+
194
+ def fail_run(
195
+ self,
196
+ request: RunGardenRequest,
197
+ run_id: str,
198
+ error: Exception,
199
+ ) -> None:
200
+ message = first_line(str(error)) or error.__class__.__name__
201
+ with suppress(Exception):
202
+ self.record(request, run_id, RunEventKind.ERROR, message)
203
+ self.runs.finish(
204
+ FinishRunRequest(
205
+ cwd=request.cwd,
206
+ wiki=request.wiki,
207
+ run_id=run_id,
208
+ status=RunStatus.FAILED,
209
+ error=message,
210
+ )
211
+ )
212
+
213
+
214
+ def render_garden_prompt(
215
+ prompts: PromptRenderer,
216
+ workspace: Workspace,
217
+ index: IndexSummary,
218
+ health: HealthReport,
219
+ guidance: str | None,
220
+ ) -> str:
221
+ payload = GardenPromptPayload(
222
+ workspace_name=workspace.name,
223
+ workspace_root=workspace.root_path,
224
+ almanac_root=workspace.almanac_path,
225
+ pages_root=workspace.almanac_path / "pages",
226
+ topics_file=workspace.almanac_path / "topics.yaml",
227
+ index=index,
228
+ health=health,
229
+ guidance=guidance,
230
+ )
231
+ return prompts.render(
232
+ RenderPromptRequest(
233
+ sections=GARDEN_PROMPT_SECTIONS,
234
+ context=(
235
+ "Runtime context:\n"
236
+ f"{payload.model_dump_json(indent=2)}\n",
237
+ ),
238
+ )
239
+ )
@@ -0,0 +1 @@
1
+ """Ingest workflow orchestration."""
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+ from codealmanac.services.harnesses.models import HarnessRunResult
5
+ from codealmanac.services.index.models import IndexRefreshResult
6
+ from codealmanac.services.runs.models import RunRecord
7
+ from codealmanac.services.sources.models import SourceBrief, SourceRuntime
8
+ from codealmanac.workflows.lifecycle import LifecycleMutationReport
9
+
10
+
11
+ class IngestPromptPayload(CodeAlmanacModel):
12
+ workspace_name: str
13
+ workspace_root: Path
14
+ almanac_root: Path
15
+ sources: tuple[SourceBrief, ...]
16
+ source_runtime: tuple[SourceRuntime, ...]
17
+ guidance: str | None = None
18
+
19
+
20
+ class IngestResult(CodeAlmanacModel):
21
+ run: RunRecord
22
+ sources: tuple[SourceBrief, ...]
23
+ source_runtime: tuple[SourceRuntime, ...]
24
+ harness: HarnessRunResult
25
+ safety: LifecycleMutationReport
26
+ index: IndexRefreshResult
@@ -0,0 +1,39 @@
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
+ from codealmanac.services.harnesses.models import HarnessKind
8
+
9
+
10
+ class RunIngestRequest(CodeAlmanacModel):
11
+ cwd: Path
12
+ inputs: tuple[str, ...]
13
+ harness: HarnessKind
14
+ wiki: str | None = None
15
+ title: str | None = None
16
+ guidance: str | None = None
17
+
18
+ @field_validator("inputs")
19
+ @classmethod
20
+ def require_inputs(cls, value: tuple[str, ...]) -> tuple[str, ...]:
21
+ if len(value) == 0:
22
+ raise ValueError("at least one ingest input is required")
23
+ return value
24
+
25
+ @field_validator("title", "guidance")
26
+ @classmethod
27
+ def require_optional_text(cls, value: str | None) -> str | None:
28
+ if value is None:
29
+ return None
30
+ return required_text(value, "ingest request text")
31
+
32
+
33
+ class RunIngestWithRunRequest(RunIngestRequest):
34
+ run_id: str
35
+
36
+ @field_validator("run_id")
37
+ @classmethod
38
+ def require_run_id(cls, value: str) -> str:
39
+ return required_text(value, "ingest run id")
@@ -0,0 +1,302 @@
1
+ from contextlib import suppress
2
+ from pathlib import Path
3
+
4
+ from codealmanac.prompts import PromptName, PromptRenderer, RenderPromptRequest
5
+ from codealmanac.services.harnesses.models import HarnessRunResult
6
+ from codealmanac.services.harnesses.requests import RunHarnessRequest
7
+ from codealmanac.services.harnesses.service import HarnessesService
8
+ from codealmanac.services.index.service import IndexService
9
+ from codealmanac.services.runs.models import (
10
+ RunEventKind,
11
+ RunOperation,
12
+ RunRecord,
13
+ RunStatus,
14
+ )
15
+ from codealmanac.services.runs.requests import (
16
+ FinishRunRequest,
17
+ MarkRunRunningRequest,
18
+ RecordRunEventRequest,
19
+ RecordRunHarnessTranscriptRequest,
20
+ StartRunRequest,
21
+ )
22
+ from codealmanac.services.runs.service import RunsService
23
+ from codealmanac.services.sources.models import SourceBrief, SourceRuntime
24
+ from codealmanac.services.sources.requests import (
25
+ InspectSourceRuntimeRequest,
26
+ ResolveSourcesRequest,
27
+ SourceRuntimeContext,
28
+ )
29
+ from codealmanac.services.sources.service import SourcesService
30
+ from codealmanac.services.workspaces.models import Workspace
31
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
32
+ from codealmanac.services.workspaces.service import WorkspacesService
33
+ from codealmanac.workflows.ingest.models import IngestPromptPayload, IngestResult
34
+ from codealmanac.workflows.ingest.requests import (
35
+ RunIngestRequest,
36
+ RunIngestWithRunRequest,
37
+ )
38
+ from codealmanac.workflows.lifecycle import (
39
+ LifecycleMutationPolicy,
40
+ first_line,
41
+ harness_events,
42
+ harness_run_event_kind,
43
+ validate_harness_result,
44
+ )
45
+
46
+ INGEST_PROMPT_SECTIONS = (
47
+ PromptName.BASE_PURPOSE,
48
+ PromptName.BASE_NOTABILITY,
49
+ PromptName.BASE_SYNTAX,
50
+ PromptName.OPERATION_INGEST,
51
+ )
52
+
53
+
54
+ class IngestWorkflow:
55
+ def __init__(
56
+ self,
57
+ workspaces: WorkspacesService,
58
+ sources: SourcesService,
59
+ harnesses: HarnessesService,
60
+ runs: RunsService,
61
+ index: IndexService,
62
+ mutation_policy: LifecycleMutationPolicy,
63
+ prompts: PromptRenderer,
64
+ ):
65
+ self.workspaces = workspaces
66
+ self.sources = sources
67
+ self.harnesses = harnesses
68
+ self.runs = runs
69
+ self.index = index
70
+ self.mutation_policy = mutation_policy
71
+ self.prompts = prompts
72
+
73
+ def run(self, request: RunIngestRequest) -> IngestResult:
74
+ started = self.start(request)
75
+ return self.run_with_run(
76
+ RunIngestWithRunRequest(
77
+ cwd=request.cwd,
78
+ inputs=request.inputs,
79
+ harness=request.harness,
80
+ wiki=request.wiki,
81
+ title=request.title,
82
+ guidance=request.guidance,
83
+ run_id=started.run_id,
84
+ )
85
+ )
86
+
87
+ def start(self, request: RunIngestRequest) -> RunRecord:
88
+ return self.runs.start(
89
+ StartRunRequest(
90
+ cwd=request.cwd,
91
+ wiki=request.wiki,
92
+ operation=RunOperation.INGEST,
93
+ title=request.title or default_title(request.inputs),
94
+ )
95
+ )
96
+
97
+ def run_with_run(self, request: RunIngestWithRunRequest) -> IngestResult:
98
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
99
+ run_id = request.run_id
100
+ try:
101
+ self.runs.mark_running(
102
+ MarkRunRunningRequest(
103
+ cwd=request.cwd,
104
+ wiki=request.wiki,
105
+ run_id=run_id,
106
+ )
107
+ )
108
+ preflight = self.mutation_policy.preflight(workspace)
109
+ self.record(
110
+ request,
111
+ run_id,
112
+ RunEventKind.MESSAGE,
113
+ f"verified clean {workspace.almanac_root.as_posix()} preflight",
114
+ )
115
+ sources = self.sources.resolve(
116
+ ResolveSourcesRequest(cwd=request.cwd, inputs=request.inputs)
117
+ )
118
+ self.record(
119
+ request,
120
+ run_id,
121
+ RunEventKind.MESSAGE,
122
+ f"resolved {len(sources)} {source_word(len(sources))}",
123
+ )
124
+ source_runtime = self.inspect_source_runtime(workspace, sources)
125
+ self.record(
126
+ request,
127
+ run_id,
128
+ RunEventKind.MESSAGE,
129
+ f"loaded {len(source_runtime)} source runtime snapshot"
130
+ f"{'' if len(source_runtime) == 1 else 's'}",
131
+ )
132
+ harness = self.harnesses.run(
133
+ RunHarnessRequest(
134
+ kind=request.harness,
135
+ cwd=workspace.root_path,
136
+ prompt=render_ingest_prompt(
137
+ self.prompts,
138
+ workspace,
139
+ sources,
140
+ source_runtime,
141
+ request.guidance,
142
+ ),
143
+ title=request.title,
144
+ )
145
+ )
146
+ self.record_harness_transcript(request, run_id, harness)
147
+ self.record_harness_events(request, run_id, harness)
148
+ safety = self.mutation_policy.validate(
149
+ preflight,
150
+ workspace,
151
+ harness.changed_files,
152
+ )
153
+ validate_harness_result(harness)
154
+ index = self.index.ensure_fresh(workspace.workspace_id)
155
+ finished = self.runs.finish(
156
+ FinishRunRequest(
157
+ cwd=request.cwd,
158
+ wiki=request.wiki,
159
+ run_id=run_id,
160
+ status=RunStatus.DONE,
161
+ summary=harness.summary or "ingest completed",
162
+ )
163
+ )
164
+ return IngestResult(
165
+ run=finished,
166
+ sources=sources,
167
+ source_runtime=source_runtime,
168
+ harness=harness,
169
+ safety=safety,
170
+ index=index,
171
+ )
172
+ except Exception as error:
173
+ self.fail_run(request, run_id, error)
174
+ raise
175
+
176
+ def resolve_workspace(self, cwd: Path, wiki: str | None) -> Workspace:
177
+ if wiki is None:
178
+ return self.workspaces.resolve(cwd)
179
+ return self.workspaces.select(
180
+ SelectWorkspaceRequest(selector=wiki, base_path=cwd)
181
+ )
182
+
183
+ def record(
184
+ self,
185
+ request: RunIngestRequest,
186
+ run_id: str,
187
+ kind: RunEventKind,
188
+ message: str,
189
+ ) -> None:
190
+ self.runs.record_event(
191
+ RecordRunEventRequest(
192
+ cwd=request.cwd,
193
+ wiki=request.wiki,
194
+ run_id=run_id,
195
+ kind=kind,
196
+ message=message,
197
+ )
198
+ )
199
+
200
+ def record_harness_transcript(
201
+ self,
202
+ request: RunIngestRequest,
203
+ run_id: str,
204
+ harness: HarnessRunResult,
205
+ ) -> None:
206
+ if harness.transcript is None:
207
+ return
208
+ self.runs.record_harness_transcript(
209
+ RecordRunHarnessTranscriptRequest(
210
+ cwd=request.cwd,
211
+ wiki=request.wiki,
212
+ run_id=run_id,
213
+ transcript=harness.transcript,
214
+ )
215
+ )
216
+
217
+ def record_harness_events(
218
+ self,
219
+ request: RunIngestRequest,
220
+ run_id: str,
221
+ harness: HarnessRunResult,
222
+ ) -> None:
223
+ for event in harness_events(harness):
224
+ self.record(
225
+ request,
226
+ run_id,
227
+ harness_run_event_kind(event),
228
+ event.message,
229
+ )
230
+
231
+ def inspect_source_runtime(
232
+ self,
233
+ workspace: Workspace,
234
+ sources: tuple[SourceBrief, ...],
235
+ ) -> tuple[SourceRuntime, ...]:
236
+ return tuple(
237
+ self.sources.inspect_runtime(
238
+ InspectSourceRuntimeRequest(
239
+ cwd=workspace.root_path,
240
+ ref=source.ref,
241
+ context=SourceRuntimeContext(
242
+ ignored_directories=(workspace.almanac_root,)
243
+ ),
244
+ )
245
+ )
246
+ for source in sources
247
+ )
248
+
249
+ def fail_run(
250
+ self,
251
+ request: RunIngestRequest,
252
+ run_id: str,
253
+ error: Exception,
254
+ ) -> None:
255
+ message = first_line(str(error)) or error.__class__.__name__
256
+ with suppress(Exception):
257
+ self.record(request, run_id, RunEventKind.ERROR, message)
258
+ self.runs.finish(
259
+ FinishRunRequest(
260
+ cwd=request.cwd,
261
+ wiki=request.wiki,
262
+ run_id=run_id,
263
+ status=RunStatus.FAILED,
264
+ error=message,
265
+ )
266
+ )
267
+
268
+
269
+ def render_ingest_prompt(
270
+ prompts: PromptRenderer,
271
+ workspace: Workspace,
272
+ sources: tuple[SourceBrief, ...],
273
+ source_runtime: tuple[SourceRuntime, ...],
274
+ guidance: str | None,
275
+ ) -> str:
276
+ payload = IngestPromptPayload(
277
+ workspace_name=workspace.name,
278
+ workspace_root=workspace.root_path,
279
+ almanac_root=workspace.almanac_path,
280
+ sources=sources,
281
+ source_runtime=source_runtime,
282
+ guidance=guidance,
283
+ )
284
+ return prompts.render(
285
+ RenderPromptRequest(
286
+ sections=INGEST_PROMPT_SECTIONS,
287
+ context=(
288
+ "Runtime context:\n"
289
+ f"{payload.model_dump_json(indent=2)}\n",
290
+ ),
291
+ )
292
+ )
293
+
294
+
295
+ def default_title(inputs: tuple[str, ...]) -> str:
296
+ if len(inputs) == 1:
297
+ return f"Ingest {inputs[0]}"
298
+ return f"Ingest {len(inputs)} sources"
299
+
300
+
301
+ def source_word(count: int) -> str:
302
+ return "source" if count == 1 else "sources"