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,76 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+ from codealmanac.services.harnesses.models import HarnessTranscriptRef
7
+ from codealmanac.services.runs.models import RunEventKind, RunOperation, RunStatus
8
+
9
+
10
+ class ListRunsRequest(CodeAlmanacModel):
11
+ cwd: Path
12
+ wiki: str | None = None
13
+ limit: int | None = None
14
+
15
+ @field_validator("limit")
16
+ @classmethod
17
+ def non_negative_limit(cls, value: int | None) -> int | None:
18
+ if value is not None and value < 0:
19
+ raise ValueError("limit must be non-negative")
20
+ return value
21
+
22
+
23
+ class ShowRunRequest(CodeAlmanacModel):
24
+ cwd: Path
25
+ run_id: str
26
+ wiki: str | None = None
27
+
28
+
29
+ class ReadRunLogRequest(CodeAlmanacModel):
30
+ cwd: Path
31
+ run_id: str
32
+ wiki: str | None = None
33
+
34
+
35
+ class StartRunRequest(CodeAlmanacModel):
36
+ cwd: Path
37
+ operation: RunOperation
38
+ wiki: str | None = None
39
+ title: str | None = None
40
+
41
+
42
+ class RecordRunEventRequest(CodeAlmanacModel):
43
+ cwd: Path
44
+ run_id: str
45
+ kind: RunEventKind
46
+ message: str
47
+ wiki: str | None = None
48
+
49
+
50
+ class MarkRunRunningRequest(CodeAlmanacModel):
51
+ cwd: Path
52
+ run_id: str
53
+ wiki: str | None = None
54
+
55
+
56
+ class RecordRunHarnessTranscriptRequest(CodeAlmanacModel):
57
+ cwd: Path
58
+ run_id: str
59
+ transcript: HarnessTranscriptRef
60
+ wiki: str | None = None
61
+
62
+
63
+ class FinishRunRequest(CodeAlmanacModel):
64
+ cwd: Path
65
+ run_id: str
66
+ status: RunStatus
67
+ wiki: str | None = None
68
+ summary: str | None = None
69
+ error: str | None = None
70
+
71
+ @field_validator("status")
72
+ @classmethod
73
+ def terminal_status(cls, value: RunStatus) -> RunStatus:
74
+ if value not in {RunStatus.DONE, RunStatus.FAILED, RunStatus.CANCELLED}:
75
+ raise ValueError("finish status must be done, failed, or cancelled")
76
+ return value
@@ -0,0 +1,86 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.services.runs.models import RunLogEvent, RunRecord
4
+ from codealmanac.services.runs.requests import (
5
+ FinishRunRequest,
6
+ ListRunsRequest,
7
+ MarkRunRunningRequest,
8
+ ReadRunLogRequest,
9
+ RecordRunEventRequest,
10
+ RecordRunHarnessTranscriptRequest,
11
+ ShowRunRequest,
12
+ StartRunRequest,
13
+ )
14
+ from codealmanac.services.runs.store import RunStore
15
+ from codealmanac.services.workspaces.models import Workspace
16
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
17
+ from codealmanac.services.workspaces.service import WorkspacesService
18
+
19
+
20
+ class RunsService:
21
+ def __init__(self, workspaces: WorkspacesService, store: RunStore):
22
+ self.workspaces = workspaces
23
+ self.store = store
24
+
25
+ def start(self, request: StartRunRequest) -> RunRecord:
26
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
27
+ return self.store.create(
28
+ workspace.almanac_path,
29
+ workspace.almanac_root,
30
+ workspace.workspace_id,
31
+ request.operation,
32
+ request.title,
33
+ )
34
+
35
+ def list(self, request: ListRunsRequest) -> tuple[RunRecord, ...]:
36
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
37
+ return self.store.list(workspace.almanac_path, request.limit)
38
+
39
+ def show(self, request: ShowRunRequest) -> RunRecord:
40
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
41
+ return self.store.read(workspace.almanac_path, request.run_id)
42
+
43
+ def log(self, request: ReadRunLogRequest) -> tuple[RunLogEvent, ...]:
44
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
45
+ return self.store.log(workspace.almanac_path, request.run_id)
46
+
47
+ def record_event(self, request: RecordRunEventRequest) -> RunLogEvent:
48
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
49
+ return self.store.append(
50
+ workspace.almanac_path,
51
+ request.run_id,
52
+ request.kind,
53
+ request.message,
54
+ )
55
+
56
+ def mark_running(self, request: MarkRunRunningRequest) -> RunRecord:
57
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
58
+ return self.store.mark_running(workspace.almanac_path, request.run_id)
59
+
60
+ def record_harness_transcript(
61
+ self,
62
+ request: RecordRunHarnessTranscriptRequest,
63
+ ) -> RunRecord:
64
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
65
+ return self.store.record_harness_transcript(
66
+ workspace.almanac_path,
67
+ request.run_id,
68
+ request.transcript,
69
+ )
70
+
71
+ def finish(self, request: FinishRunRequest) -> RunRecord:
72
+ workspace = self.resolve_workspace(request.cwd, request.wiki)
73
+ return self.store.finish(
74
+ workspace.almanac_path,
75
+ request.run_id,
76
+ request.status,
77
+ request.summary,
78
+ request.error,
79
+ )
80
+
81
+ def resolve_workspace(self, cwd: Path, wiki: str | None) -> Workspace:
82
+ if wiki is None:
83
+ return self.workspaces.resolve(cwd)
84
+ return self.workspaces.select(
85
+ SelectWorkspaceRequest(selector=wiki, base_path=cwd)
86
+ )
@@ -0,0 +1,256 @@
1
+ from datetime import UTC, datetime
2
+ from pathlib import Path
3
+ from uuid import uuid4
4
+
5
+ from pydantic import ValidationError
6
+
7
+ from codealmanac.core.errors import ConflictError, NotFoundError
8
+ from codealmanac.services.harnesses.models import HarnessTranscriptRef
9
+ from codealmanac.services.runs.models import (
10
+ RunEventKind,
11
+ RunLogEvent,
12
+ RunOperation,
13
+ RunRecord,
14
+ RunStatus,
15
+ )
16
+
17
+
18
+ class RunStore:
19
+ def create(
20
+ self,
21
+ almanac_path: Path,
22
+ almanac_root: Path,
23
+ workspace_id: str,
24
+ operation: RunOperation,
25
+ title: str | None,
26
+ ) -> RunRecord:
27
+ now = datetime.now(UTC)
28
+ run_id = new_run_id(operation, now)
29
+ record = RunRecord(
30
+ run_id=run_id,
31
+ workspace_id=workspace_id,
32
+ operation=operation,
33
+ status=RunStatus.QUEUED,
34
+ title=title,
35
+ created_at=now,
36
+ updated_at=now,
37
+ log_path=run_log_reference_path(almanac_root, run_id),
38
+ )
39
+ write_record(almanac_path, record)
40
+ append_event(
41
+ almanac_path,
42
+ RunLogEvent(
43
+ run_id=run_id,
44
+ sequence=1,
45
+ timestamp=now,
46
+ kind=RunEventKind.STATUS,
47
+ message=f"queued {operation.value}",
48
+ ),
49
+ )
50
+ return record
51
+
52
+ def list(self, almanac_path: Path, limit: int | None) -> tuple[RunRecord, ...]:
53
+ records = sorted(
54
+ iter_records(almanac_path),
55
+ key=lambda record: (record.created_at, record.run_id),
56
+ reverse=True,
57
+ )
58
+ if limit is not None:
59
+ return tuple(records[:limit])
60
+ return tuple(records)
61
+
62
+ def read(self, almanac_path: Path, run_id: str) -> RunRecord:
63
+ record = read_record(almanac_path, run_id)
64
+ if record is None:
65
+ raise NotFoundError("run", run_id)
66
+ return record
67
+
68
+ def log(self, almanac_path: Path, run_id: str) -> tuple[RunLogEvent, ...]:
69
+ self.read(almanac_path, run_id)
70
+ return tuple(iter_events(almanac_path, run_id))
71
+
72
+ def append(
73
+ self,
74
+ almanac_path: Path,
75
+ run_id: str,
76
+ kind: RunEventKind,
77
+ message: str,
78
+ ) -> RunLogEvent:
79
+ record = self.read(almanac_path, run_id)
80
+ event = RunLogEvent(
81
+ run_id=run_id,
82
+ sequence=next_sequence(almanac_path, run_id),
83
+ timestamp=datetime.now(UTC),
84
+ kind=kind,
85
+ message=message,
86
+ )
87
+ append_event(almanac_path, event)
88
+ write_record(
89
+ almanac_path,
90
+ record.model_copy(update={"updated_at": event.timestamp}),
91
+ )
92
+ return event
93
+
94
+ def mark_running(self, almanac_path: Path, run_id: str) -> RunRecord:
95
+ record = self.read(almanac_path, run_id)
96
+ if record.status == RunStatus.RUNNING:
97
+ return record
98
+ if record.status != RunStatus.QUEUED:
99
+ raise ConflictError(
100
+ f"run {run_id} cannot start from {record.status.value}"
101
+ )
102
+ now = datetime.now(UTC)
103
+ running = record.model_copy(
104
+ update={
105
+ "status": RunStatus.RUNNING,
106
+ "updated_at": now,
107
+ "started_at": now,
108
+ }
109
+ )
110
+ write_record(almanac_path, running)
111
+ append_event(
112
+ almanac_path,
113
+ RunLogEvent(
114
+ run_id=run_id,
115
+ sequence=next_sequence(almanac_path, run_id),
116
+ timestamp=now,
117
+ kind=RunEventKind.STATUS,
118
+ message=RunStatus.RUNNING.value,
119
+ ),
120
+ )
121
+ return running
122
+
123
+ def record_harness_transcript(
124
+ self,
125
+ almanac_path: Path,
126
+ run_id: str,
127
+ transcript: HarnessTranscriptRef,
128
+ ) -> RunRecord:
129
+ record = self.read(almanac_path, run_id)
130
+ updated = record.model_copy(
131
+ update={
132
+ "harness_transcript": transcript,
133
+ "updated_at": datetime.now(UTC),
134
+ }
135
+ )
136
+ write_record(almanac_path, updated)
137
+ return updated
138
+
139
+ def finish(
140
+ self,
141
+ almanac_path: Path,
142
+ run_id: str,
143
+ status: RunStatus,
144
+ summary: str | None,
145
+ error: str | None,
146
+ ) -> RunRecord:
147
+ record = self.read(almanac_path, run_id)
148
+ now = datetime.now(UTC)
149
+ finished = record.model_copy(
150
+ update={
151
+ "status": status,
152
+ "summary": summary,
153
+ "error": error,
154
+ "updated_at": now,
155
+ "finished_at": now,
156
+ }
157
+ )
158
+ write_record(almanac_path, finished)
159
+ append_event(
160
+ almanac_path,
161
+ RunLogEvent(
162
+ run_id=run_id,
163
+ sequence=next_sequence(almanac_path, run_id),
164
+ timestamp=now,
165
+ kind=RunEventKind.STATUS,
166
+ message=status.value,
167
+ ),
168
+ )
169
+ return finished
170
+
171
+
172
+ def new_run_id(operation: RunOperation, now: datetime) -> str:
173
+ stamp = now.strftime("%Y%m%d%H%M%S")
174
+ return f"{operation.value}-{stamp}-{uuid4().hex[:8]}"
175
+
176
+
177
+ def runs_dir(almanac_path: Path) -> Path:
178
+ return almanac_path / "jobs"
179
+
180
+
181
+ def run_record_path(almanac_path: Path, run_id: str) -> Path:
182
+ return runs_dir(almanac_path) / f"{run_id}.json"
183
+
184
+
185
+ def run_log_path(almanac_path: Path, run_id: str) -> Path:
186
+ return runs_dir(almanac_path) / f"{run_id}.jsonl"
187
+
188
+
189
+ def run_log_reference_path(almanac_root: Path, run_id: str) -> Path:
190
+ return almanac_root / "jobs" / f"{run_id}.jsonl"
191
+
192
+
193
+ def write_record(almanac_path: Path, record: RunRecord) -> None:
194
+ path = run_record_path(almanac_path, record.run_id)
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
197
+ try:
198
+ temporary.write_text(record.model_dump_json(indent=2), encoding="utf-8")
199
+ temporary.replace(path)
200
+ finally:
201
+ if temporary.exists():
202
+ temporary.unlink()
203
+
204
+
205
+ def read_record(almanac_path: Path, run_id: str) -> RunRecord | None:
206
+ path = run_record_path(almanac_path, run_id)
207
+ if not path.is_file():
208
+ return None
209
+ try:
210
+ return RunRecord.model_validate_json(path.read_text(encoding="utf-8"))
211
+ except (OSError, ValidationError, ValueError):
212
+ return None
213
+
214
+
215
+ def iter_records(almanac_path: Path) -> tuple[RunRecord, ...]:
216
+ directory = runs_dir(almanac_path)
217
+ if not directory.is_dir():
218
+ return ()
219
+ records: list[RunRecord] = []
220
+ for path in sorted(directory.glob("*.json")):
221
+ run_id = path.stem
222
+ record = read_record(almanac_path, run_id)
223
+ if record is not None:
224
+ records.append(record)
225
+ return tuple(records)
226
+
227
+
228
+ def append_event(almanac_path: Path, event: RunLogEvent) -> None:
229
+ path = run_log_path(almanac_path, event.run_id)
230
+ path.parent.mkdir(parents=True, exist_ok=True)
231
+ with path.open("a", encoding="utf-8") as file:
232
+ file.write(event.model_dump_json())
233
+ file.write("\n")
234
+
235
+
236
+ def iter_events(almanac_path: Path, run_id: str) -> tuple[RunLogEvent, ...]:
237
+ path = run_log_path(almanac_path, run_id)
238
+ if not path.is_file():
239
+ return ()
240
+ try:
241
+ lines = path.read_text(encoding="utf-8").splitlines()
242
+ except OSError:
243
+ return ()
244
+ events: list[RunLogEvent] = []
245
+ for line in lines:
246
+ if not line.strip():
247
+ continue
248
+ try:
249
+ events.append(RunLogEvent.model_validate_json(line))
250
+ except (ValidationError, ValueError):
251
+ continue
252
+ return tuple(events)
253
+
254
+
255
+ def next_sequence(almanac_path: Path, run_id: str) -> int:
256
+ return len(iter_events(almanac_path, run_id)) + 1
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+
7
+
8
+ class SearchPagesRequest(CodeAlmanacModel):
9
+ cwd: Path
10
+ wiki: str | None = None
11
+ query: str | None = None
12
+ topics: tuple[str, ...] = ()
13
+ mentions: str | None = None
14
+ include_archive: bool = False
15
+ archived: bool = False
16
+ limit: int | None = None
17
+
18
+ @field_validator("limit")
19
+ @classmethod
20
+ def non_negative_limit(cls, value: int | None) -> int | None:
21
+ if value is not None and value < 0:
22
+ raise ValueError("limit must be non-negative")
23
+ return value
@@ -0,0 +1,31 @@
1
+ from codealmanac.services.index.models import SearchPageResult
2
+ from codealmanac.services.index.requests import SearchIndexRequest
3
+ from codealmanac.services.index.service import IndexService
4
+ from codealmanac.services.search.requests import SearchPagesRequest
5
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
6
+ from codealmanac.services.workspaces.service import WorkspacesService
7
+
8
+
9
+ class SearchService:
10
+ def __init__(self, workspaces: WorkspacesService, index: IndexService):
11
+ self.workspaces = workspaces
12
+ self.index = index
13
+
14
+ def search(self, request: SearchPagesRequest) -> tuple[SearchPageResult, ...]:
15
+ if request.wiki is None:
16
+ workspace = self.workspaces.resolve(request.cwd)
17
+ else:
18
+ workspace = self.workspaces.select(
19
+ SelectWorkspaceRequest(selector=request.wiki, base_path=request.cwd)
20
+ )
21
+ return self.index.search(
22
+ workspace.workspace_id,
23
+ SearchIndexRequest(
24
+ query=request.query,
25
+ topics=request.topics,
26
+ mentions=request.mentions,
27
+ include_archive=request.include_archive,
28
+ archived=request.archived,
29
+ limit=request.limit,
30
+ ),
31
+ )
@@ -0,0 +1 @@
1
+ """Source input contracts for lifecycle workflows."""
@@ -0,0 +1,126 @@
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
+
10
+
11
+ class SourceKind(StrEnum):
12
+ PATH_FILE = "path.file"
13
+ PATH_DIRECTORY = "path.directory"
14
+ PATH_UNKNOWN = "path.unknown"
15
+ GITHUB_PULL_REQUEST = "github.pull_request"
16
+ GITHUB_ISSUE = "github.issue"
17
+ WEB_URL = "web.url"
18
+ GIT_RANGE = "git.range"
19
+ GIT_DIFF = "git.diff"
20
+ TRANSCRIPT = "transcript"
21
+
22
+
23
+ class SourceProvenanceKind(StrEnum):
24
+ FILE = "file"
25
+ DIRECTORY = "directory"
26
+ MISSING_PATH = "missing_path"
27
+ PR = "pr"
28
+ ISSUE = "issue"
29
+ URL = "url"
30
+ GIT = "git"
31
+ TRANSCRIPT = "transcript"
32
+
33
+
34
+ class SourceRuntimeStatus(StrEnum):
35
+ AVAILABLE = "available"
36
+ SKIPPED = "skipped"
37
+ UNAVAILABLE = "unavailable"
38
+
39
+
40
+ class TranscriptApp(StrEnum):
41
+ CLAUDE = "claude"
42
+ CODEX = "codex"
43
+
44
+
45
+ class SourceAddress(CodeAlmanacModel):
46
+ raw: str
47
+
48
+ @field_validator("raw")
49
+ @classmethod
50
+ def require_raw(cls, value: str) -> str:
51
+ return required_text(value, "source address")
52
+
53
+
54
+ class SourceRef(CodeAlmanacModel):
55
+ raw: str
56
+ kind: SourceKind
57
+ identity: str
58
+ path: Path | None = None
59
+ url: str | None = None
60
+ repository: str | None = None
61
+ number: int | None = None
62
+ revision_range: str | None = None
63
+ transcript: str | None = None
64
+ exists: bool | None = None
65
+ fingerprint: str | None = None
66
+
67
+ @field_validator("raw", "identity")
68
+ @classmethod
69
+ def require_text_fields(cls, value: str) -> str:
70
+ return required_text(value, "source reference")
71
+
72
+ @field_validator("number")
73
+ @classmethod
74
+ def positive_number(cls, value: int | None) -> int | None:
75
+ if value is not None and value < 1:
76
+ raise ValueError("source number must be positive")
77
+ return value
78
+
79
+
80
+ class SourceBrief(CodeAlmanacModel):
81
+ ref: SourceRef
82
+ title: str
83
+ provenance_kind: SourceProvenanceKind
84
+ prompt_hint: str
85
+
86
+ @field_validator("title", "prompt_hint")
87
+ @classmethod
88
+ def require_brief_text(cls, value: str) -> str:
89
+ return required_text(value, "source brief")
90
+
91
+
92
+ class SourceRuntime(CodeAlmanacModel):
93
+ ref: SourceRef
94
+ status: SourceRuntimeStatus
95
+ title: str
96
+ content: str | None = None
97
+ diagnostics: tuple[str, ...] = ()
98
+ truncated: bool = False
99
+
100
+ @field_validator("title")
101
+ @classmethod
102
+ def require_title(cls, value: str) -> str:
103
+ return required_text(value, "source runtime title")
104
+
105
+
106
+ class TranscriptCandidate(CodeAlmanacModel):
107
+ app: TranscriptApp
108
+ session_id: str
109
+ transcript_path: Path
110
+ cwd: Path
111
+ repo_root: Path
112
+ almanac_path: Path
113
+ modified_at: datetime
114
+ size_bytes: int
115
+
116
+ @field_validator("session_id")
117
+ @classmethod
118
+ def require_session_id(cls, value: str) -> str:
119
+ return required_text(value, "transcript session id")
120
+
121
+ @field_validator("size_bytes")
122
+ @classmethod
123
+ def non_negative_size(cls, value: int) -> int:
124
+ if value < 0:
125
+ raise ValueError("transcript size must be non-negative")
126
+ return value
@@ -0,0 +1,30 @@
1
+ from typing import Protocol
2
+
3
+ from codealmanac.services.sources.models import (
4
+ SourceRef,
5
+ SourceRuntime,
6
+ TranscriptApp,
7
+ TranscriptCandidate,
8
+ )
9
+ from codealmanac.services.sources.requests import (
10
+ DiscoverTranscriptsRequest,
11
+ InspectSourceRuntimeRequest,
12
+ )
13
+
14
+
15
+ class TranscriptDiscoveryAdapter(Protocol):
16
+ app: TranscriptApp
17
+
18
+ def discover(
19
+ self,
20
+ request: DiscoverTranscriptsRequest,
21
+ ) -> tuple[TranscriptCandidate, ...]:
22
+ """Return local transcript candidates for one supported agent app."""
23
+
24
+
25
+ class SourceRuntimeAdapter(Protocol):
26
+ def supports(self, ref: SourceRef) -> bool:
27
+ """Return true when this adapter can inspect the source ref."""
28
+
29
+ def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime:
30
+ """Return bounded runtime material for one source ref."""