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,82 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+ from codealmanac.core.text import required_text
7
+ from codealmanac.services.workspaces.roots import (
8
+ DEFAULT_ALMANAC_ROOT,
9
+ validate_almanac_root_field,
10
+ )
11
+
12
+
13
+ class RegisterWorkspaceRequest(CodeAlmanacModel):
14
+ root_path: Path
15
+ almanac_root: Path = Field(default=DEFAULT_ALMANAC_ROOT)
16
+ name: str | None = Field(
17
+ default=None,
18
+ description="None means derive the registry name from the root path.",
19
+ )
20
+ description: str = ""
21
+
22
+ @field_validator("name")
23
+ @classmethod
24
+ def require_name(cls, value: str | None) -> str | None:
25
+ if value is None:
26
+ return None
27
+ return required_text(value, "workspace name")
28
+
29
+ @field_validator("almanac_root")
30
+ @classmethod
31
+ def validate_almanac_root(cls, value: Path) -> Path:
32
+ return validate_almanac_root_field(value)
33
+
34
+
35
+ class InitializeWorkspaceRequest(CodeAlmanacModel):
36
+ path: Path
37
+ almanac_root: Path | None = None
38
+ name: str | None = Field(
39
+ default=None,
40
+ description="None means derive the registry name from the workspace path.",
41
+ )
42
+ description: str = ""
43
+
44
+ @field_validator("name")
45
+ @classmethod
46
+ def require_name(cls, value: str | None) -> str | None:
47
+ if value is None:
48
+ return None
49
+ return required_text(value, "workspace name")
50
+
51
+ @field_validator("almanac_root")
52
+ @classmethod
53
+ def validate_almanac_root(cls, value: Path | None) -> Path | None:
54
+ if value is None:
55
+ return None
56
+ return validate_almanac_root_field(value)
57
+
58
+
59
+ class SelectWorkspaceRequest(CodeAlmanacModel):
60
+ selector: str
61
+ base_path: Path | None = Field(
62
+ default=None,
63
+ description="None means relative path selectors are not resolved.",
64
+ )
65
+
66
+ @field_validator("selector")
67
+ @classmethod
68
+ def require_selector(cls, value: str) -> str:
69
+ return required_text(value, "workspace selector")
70
+
71
+
72
+ class DropWorkspaceRequest(CodeAlmanacModel):
73
+ selector: str
74
+ base_path: Path | None = Field(
75
+ default=None,
76
+ description="None means relative path selectors are not resolved.",
77
+ )
78
+
79
+ @field_validator("selector")
80
+ @classmethod
81
+ def require_selector(cls, value: str) -> str:
82
+ return required_text(value, "workspace selector")
@@ -0,0 +1,74 @@
1
+ from collections.abc import Iterable
2
+ from pathlib import Path
3
+
4
+ from codealmanac.core.models import CodeAlmanacModel
5
+ from codealmanac.core.paths import normalize_path
6
+
7
+ DEFAULT_ALMANAC_ROOT = Path("almanac")
8
+ ALMANAC_ROOT_MARKER_FILES = ("README.md", "topics.yaml")
9
+ ALMANAC_ROOT_MARKER_DIRS = ("pages",)
10
+
11
+
12
+ class AlmanacRootMatch(CodeAlmanacModel):
13
+ repo_root: Path
14
+ almanac_root: Path
15
+ almanac_path: Path
16
+
17
+
18
+ def normalize_almanac_root(value: Path | str | None) -> Path:
19
+ if value is None:
20
+ return DEFAULT_ALMANAC_ROOT
21
+ path = Path(value)
22
+ if path.is_absolute():
23
+ raise ValueError("Almanac root must be a repo-relative path")
24
+ if len(path.parts) == 0:
25
+ raise ValueError("Almanac root must name a directory")
26
+ if any(part in {"..", "~"} for part in path.parts):
27
+ raise ValueError("Almanac root must stay inside the repo")
28
+ return Path(*path.parts)
29
+
30
+
31
+ def normalized_almanac_roots(values: Iterable[Path | str]) -> tuple[Path, ...]:
32
+ roots: list[Path] = []
33
+ for value in values:
34
+ root = normalize_almanac_root(value)
35
+ if root not in roots:
36
+ roots.append(root)
37
+ if len(roots) == 0:
38
+ roots.append(DEFAULT_ALMANAC_ROOT)
39
+ return tuple(roots)
40
+
41
+
42
+ def nearest_almanac_root(
43
+ path: Path,
44
+ almanac_roots: Iterable[Path | str] = (DEFAULT_ALMANAC_ROOT,),
45
+ ) -> AlmanacRootMatch | None:
46
+ current = normalize_path(path)
47
+ if current.is_file():
48
+ current = current.parent
49
+ roots = normalized_almanac_roots(almanac_roots)
50
+ for candidate in [current, *current.parents]:
51
+ for almanac_root in roots:
52
+ almanac_path = candidate / almanac_root
53
+ if is_initialized_almanac_root(almanac_path):
54
+ return AlmanacRootMatch(
55
+ repo_root=candidate,
56
+ almanac_root=almanac_root,
57
+ almanac_path=normalize_path(almanac_path),
58
+ )
59
+ return None
60
+
61
+
62
+ def is_initialized_almanac_root(path: Path) -> bool:
63
+ if not path.is_dir():
64
+ return False
65
+ if any((path / name).is_file() for name in ALMANAC_ROOT_MARKER_FILES):
66
+ return True
67
+ return any((path / name).is_dir() for name in ALMANAC_ROOT_MARKER_DIRS)
68
+
69
+
70
+ def validate_almanac_root_field(value: Path | str | None) -> Path:
71
+ try:
72
+ return normalize_almanac_root(value)
73
+ except ValueError as error:
74
+ raise ValueError(str(error)) from error
@@ -0,0 +1,303 @@
1
+ from datetime import UTC, datetime
2
+ from hashlib import sha256
3
+ from pathlib import Path
4
+
5
+ from codealmanac.core.errors import ConflictError, NotFoundError, ValidationFailed
6
+ from codealmanac.core.paths import normalize_path
7
+ from codealmanac.core.slug import to_kebab_case
8
+ from codealmanac.services.workspaces.models import (
9
+ DropWorkspaceResult,
10
+ Workspace,
11
+ WorkspaceListItem,
12
+ WorkspaceListResult,
13
+ WorkspaceRegistryEntry,
14
+ WorkspaceRegistryStatus,
15
+ )
16
+ from codealmanac.services.workspaces.requests import (
17
+ DropWorkspaceRequest,
18
+ RegisterWorkspaceRequest,
19
+ SelectWorkspaceRequest,
20
+ )
21
+ from codealmanac.services.workspaces.roots import (
22
+ DEFAULT_ALMANAC_ROOT,
23
+ AlmanacRootMatch,
24
+ is_initialized_almanac_root,
25
+ nearest_almanac_root,
26
+ )
27
+ from codealmanac.services.workspaces.store import WorkspaceRegistryStore
28
+
29
+
30
+ class WorkspacesService:
31
+ def __init__(self, store: WorkspaceRegistryStore):
32
+ self.store = store
33
+
34
+ def initialization_target(
35
+ self,
36
+ path: Path,
37
+ almanac_root: Path | None,
38
+ ) -> AlmanacRootMatch:
39
+ normalized = normalize_path(path)
40
+ if normalized.is_file():
41
+ normalized = normalized.parent
42
+ if almanac_root is None:
43
+ selected = containing_workspace(normalized, self.list())
44
+ if selected is not None:
45
+ return AlmanacRootMatch(
46
+ repo_root=selected.root_path,
47
+ almanac_root=selected.almanac_root,
48
+ almanac_path=selected.almanac_path,
49
+ )
50
+ almanac_root = DEFAULT_ALMANAC_ROOT
51
+ existing = nearest_almanac_root(normalized, (almanac_root,))
52
+ if existing is not None:
53
+ return existing
54
+ return AlmanacRootMatch(
55
+ repo_root=normalized,
56
+ almanac_root=almanac_root,
57
+ almanac_path=normalized / almanac_root,
58
+ )
59
+
60
+ def register(self, request: RegisterWorkspaceRequest) -> Workspace:
61
+ root_path = normalize_path(request.root_path)
62
+ almanac_path = root_path / request.almanac_root
63
+ existing = entry_by_exact_path(root_path, self.store.list())
64
+ name = workspace_name_for(
65
+ root_path,
66
+ request.name or (existing.name if existing is not None else None),
67
+ )
68
+ description = (
69
+ request.description.strip()
70
+ or (existing.description if existing is not None else "")
71
+ )
72
+ workspace = Workspace(
73
+ workspace_id=workspace_id_for(root_path),
74
+ name=name,
75
+ description=description,
76
+ root_path=root_path,
77
+ almanac_root=request.almanac_root,
78
+ almanac_path=almanac_path,
79
+ registered_at=(
80
+ existing.registered_at if existing is not None else datetime.now(UTC)
81
+ ),
82
+ )
83
+ return self.store.remember(workspace).to_workspace()
84
+
85
+ def get(self, workspace_id: str) -> Workspace:
86
+ entry = self.store.find_by_workspace_id(workspace_id)
87
+ if entry is None:
88
+ raise NotFoundError("workspace", workspace_id)
89
+ return entry.to_workspace()
90
+
91
+ def select(self, request: SelectWorkspaceRequest) -> Workspace:
92
+ entries = self.store.list()
93
+ selected = entry_by_workspace_id(request.selector, entries)
94
+ if selected is not None:
95
+ return selected.to_workspace()
96
+ selected = entry_by_name(request.selector, entries)
97
+ if selected is not None:
98
+ return selected.to_workspace()
99
+ selected = entry_by_path(request, entries)
100
+ if selected is not None:
101
+ return selected.to_workspace()
102
+ raise NotFoundError("workspace", request.selector)
103
+
104
+ def resolve(self, path: Path) -> Workspace:
105
+ normalized = normalize_path(path)
106
+ selected = containing_workspace(normalized, self.list())
107
+ if selected is not None:
108
+ return selected
109
+ match = nearest_almanac_root(normalized, self.discoverable_almanac_roots())
110
+ if match is None:
111
+ raise NotFoundError("workspace", str(path))
112
+ return self.register(
113
+ RegisterWorkspaceRequest(
114
+ root_path=match.repo_root,
115
+ almanac_root=match.almanac_root,
116
+ )
117
+ )
118
+
119
+ def validate_path(self, workspace_id: str, path: Path) -> Path:
120
+ workspace = self.get(workspace_id)
121
+ normalized = normalize_path(path)
122
+ if not contains_path(workspace.root_path, normalized):
123
+ raise ValidationFailed(
124
+ f"path is outside workspace {workspace.name}: {normalized}"
125
+ )
126
+ return normalized
127
+
128
+ def list(self) -> list[Workspace]:
129
+ return [entry.to_workspace() for entry in self.store.list()]
130
+
131
+ def list_registry(self) -> WorkspaceListResult:
132
+ return WorkspaceListResult(
133
+ items=tuple(
134
+ WorkspaceListItem(
135
+ workspace=entry.to_workspace(),
136
+ status=workspace_registry_status(entry.to_workspace()),
137
+ )
138
+ for entry in self.store.list()
139
+ )
140
+ )
141
+
142
+ def drop(self, request: DropWorkspaceRequest) -> DropWorkspaceResult:
143
+ entries = self.store.list()
144
+ selected = select_registry_entry(
145
+ SelectWorkspaceRequest(
146
+ selector=request.selector,
147
+ base_path=request.base_path,
148
+ ),
149
+ entries,
150
+ )
151
+ if selected is None:
152
+ raise NotFoundError("workspace", request.selector)
153
+ remaining = [
154
+ entry
155
+ for entry in entries
156
+ if entry.workspace_id != selected.workspace_id
157
+ ]
158
+ self.store.replace(remaining)
159
+ return DropWorkspaceResult(dropped=(selected.to_workspace(),))
160
+
161
+ def drop_missing(self) -> DropWorkspaceResult:
162
+ entries = self.store.list()
163
+ dropped = tuple(
164
+ entry.to_workspace()
165
+ for entry in entries
166
+ if workspace_registry_status(entry.to_workspace())
167
+ != WorkspaceRegistryStatus.AVAILABLE
168
+ )
169
+ remaining = [
170
+ entry
171
+ for entry in entries
172
+ if workspace_registry_status(entry.to_workspace())
173
+ == WorkspaceRegistryStatus.AVAILABLE
174
+ ]
175
+ self.store.replace(remaining)
176
+ return DropWorkspaceResult(dropped=dropped)
177
+
178
+ def discoverable_almanac_roots(self) -> tuple[Path, ...]:
179
+ roots = [DEFAULT_ALMANAC_ROOT]
180
+ for workspace in self.list():
181
+ if workspace.almanac_root not in roots:
182
+ roots.append(workspace.almanac_root)
183
+ return tuple(roots)
184
+
185
+ @property
186
+ def registry_path(self) -> Path:
187
+ return self.store.path
188
+
189
+
190
+ def workspace_name_for(root_path: Path, requested_name: str | None) -> str:
191
+ name = to_kebab_case(requested_name or root_path.name)
192
+ if not name:
193
+ raise ValidationFailed("could not derive a workspace name; pass --name")
194
+ return name
195
+
196
+
197
+ def workspace_id_for(root_path: Path) -> str:
198
+ digest = sha256(str(root_path).encode("utf-8")).hexdigest()[:16]
199
+ return f"w_{digest}"
200
+
201
+
202
+ def entry_by_workspace_id(
203
+ selector: str,
204
+ entries: list[WorkspaceRegistryEntry],
205
+ ) -> WorkspaceRegistryEntry | None:
206
+ for entry in entries:
207
+ if entry.workspace_id == selector:
208
+ return entry
209
+ return None
210
+
211
+
212
+ def entry_by_name(
213
+ selector: str,
214
+ entries: list[WorkspaceRegistryEntry],
215
+ ) -> WorkspaceRegistryEntry | None:
216
+ matches = [
217
+ entry
218
+ for entry in entries
219
+ if entry.name.casefold() == selector.casefold()
220
+ ]
221
+ if len(matches) > 1:
222
+ raise ConflictError(f"workspace selector is ambiguous: {selector}")
223
+ if len(matches) == 1:
224
+ return matches[0]
225
+ return None
226
+
227
+
228
+ def entry_by_path(
229
+ request: SelectWorkspaceRequest,
230
+ entries: list[WorkspaceRegistryEntry],
231
+ ) -> WorkspaceRegistryEntry | None:
232
+ selector_path = explicit_selector_path(request)
233
+ if selector_path is None:
234
+ return None
235
+ for entry in entries:
236
+ if same_path(entry.path, selector_path):
237
+ return entry
238
+ return None
239
+
240
+
241
+ def select_registry_entry(
242
+ request: SelectWorkspaceRequest,
243
+ entries: list[WorkspaceRegistryEntry],
244
+ ) -> WorkspaceRegistryEntry | None:
245
+ selected = entry_by_workspace_id(request.selector, entries)
246
+ if selected is not None:
247
+ return selected
248
+ selected = entry_by_name(request.selector, entries)
249
+ if selected is not None:
250
+ return selected
251
+ return entry_by_path(request, entries)
252
+
253
+
254
+ def entry_by_exact_path(
255
+ path: Path,
256
+ entries: list[WorkspaceRegistryEntry],
257
+ ) -> WorkspaceRegistryEntry | None:
258
+ for entry in entries:
259
+ if same_path(entry.path, path):
260
+ return entry
261
+ return None
262
+
263
+
264
+ def explicit_selector_path(request: SelectWorkspaceRequest) -> Path | None:
265
+ if not is_path_selector(request.selector):
266
+ return None
267
+ path = Path(request.selector).expanduser()
268
+ if path.is_absolute():
269
+ return normalize_path(path)
270
+ if request.base_path is None:
271
+ return None
272
+ return normalize_path(request.base_path / path)
273
+
274
+
275
+ def is_path_selector(selector: str) -> bool:
276
+ return selector.startswith(("/", "~", ".")) or "/" in selector
277
+
278
+
279
+ def containing_workspace(path: Path, workspaces: list[Workspace]) -> Workspace | None:
280
+ matches = [
281
+ workspace
282
+ for workspace in workspaces
283
+ if contains_path(workspace.root_path, path)
284
+ ]
285
+ if len(matches) == 0:
286
+ return None
287
+ return max(matches, key=lambda workspace: len(workspace.root_path.parts))
288
+
289
+
290
+ def contains_path(root_path: Path, path: Path) -> bool:
291
+ return path == root_path or root_path in path.parents
292
+
293
+
294
+ def same_path(left: Path, right: Path) -> bool:
295
+ return normalize_path(left) == normalize_path(right)
296
+
297
+
298
+ def workspace_registry_status(workspace: Workspace) -> WorkspaceRegistryStatus:
299
+ if not workspace.root_path.is_dir():
300
+ return WorkspaceRegistryStatus.MISSING_REPO
301
+ if not is_initialized_almanac_root(workspace.almanac_path):
302
+ return WorkspaceRegistryStatus.MISSING_ALMANAC
303
+ return WorkspaceRegistryStatus.AVAILABLE
@@ -0,0 +1,127 @@
1
+ import json
2
+ from collections.abc import Sequence
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from pydantic import TypeAdapter, ValidationError
9
+
10
+ from codealmanac.core.errors import ValidationFailed
11
+ from codealmanac.core.paths import normalize_path
12
+ from codealmanac.services.workspaces.models import (
13
+ Workspace,
14
+ WorkspaceRegistryEntry,
15
+ )
16
+
17
+
18
+ class WorkspaceRegistryStore:
19
+ def __init__(self, path: Path):
20
+ self.path = normalize_path(path)
21
+
22
+ def remember(self, workspace: Workspace) -> WorkspaceRegistryEntry:
23
+ entries = [
24
+ entry
25
+ for entry in self.list()
26
+ if not same_workspace(entry, workspace)
27
+ ]
28
+ entry = registry_entry_for(workspace)
29
+ entries.append(entry)
30
+ write_entries(self.path, entries)
31
+ return entry
32
+
33
+ def replace(self, entries: Sequence[WorkspaceRegistryEntry]) -> None:
34
+ write_entries(self.path, list(entries))
35
+
36
+ def find_by_workspace_id(self, workspace_id: str) -> WorkspaceRegistryEntry | None:
37
+ for entry in self.list():
38
+ if entry.workspace_id == workspace_id:
39
+ return entry
40
+ return None
41
+
42
+ def list(self) -> list[WorkspaceRegistryEntry]:
43
+ if not self.path.exists():
44
+ return []
45
+ text = self.path.read_text(encoding="utf-8").strip()
46
+ if text == "":
47
+ return []
48
+ return parse_entries(text)
49
+
50
+
51
+ def registry_entry_for(workspace: Workspace) -> WorkspaceRegistryEntry:
52
+ return WorkspaceRegistryEntry(
53
+ name=workspace.name,
54
+ description=workspace.description,
55
+ path=workspace.root_path,
56
+ almanac_root=workspace.almanac_root,
57
+ registered_at=workspace.registered_at,
58
+ workspace_id=workspace.workspace_id,
59
+ )
60
+
61
+
62
+ def same_workspace(entry: WorkspaceRegistryEntry, workspace: Workspace) -> bool:
63
+ return (
64
+ entry.workspace_id == workspace.workspace_id
65
+ or same_path(entry.path, workspace.root_path)
66
+ or entry.name.casefold() == workspace.name.casefold()
67
+ )
68
+
69
+
70
+ def write_entries(path: Path, entries: list[WorkspaceRegistryEntry]) -> None:
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ data = entries_adapter().dump_python(entries, mode="json")
73
+ temporary = temporary_registry_path(path)
74
+ temporary.write_text(f"{json.dumps(data, indent=2)}\n", encoding="utf-8")
75
+ temporary.replace(path)
76
+
77
+
78
+ def temporary_registry_path(path: Path) -> Path:
79
+ return path.with_name(f".{path.name}.{uuid4().hex}.tmp")
80
+
81
+
82
+ def parse_entries(text: str) -> list[WorkspaceRegistryEntry]:
83
+ try:
84
+ raw_entries = json.loads(text)
85
+ except json.JSONDecodeError as error:
86
+ message = f"workspace registry is invalid JSON: {error}"
87
+ raise ValidationFailed(message) from error
88
+ if not isinstance(raw_entries, list):
89
+ raise ValidationFailed("workspace registry must be a JSON array")
90
+ return [parse_entry(raw_entry) for raw_entry in raw_entries]
91
+
92
+
93
+ def parse_entry(raw_entry: Any) -> WorkspaceRegistryEntry:
94
+ if not isinstance(raw_entry, dict):
95
+ raise ValidationFailed("workspace registry entries must be objects")
96
+ upgraded = dict(raw_entry)
97
+ if "path" not in upgraded:
98
+ raise ValidationFailed('workspace registry entry is missing "path"')
99
+ path = normalize_path(Path(str(upgraded["path"])))
100
+ upgraded["path"] = path
101
+ if "workspace_id" not in upgraded:
102
+ upgraded["workspace_id"] = workspace_id_for_path(path)
103
+ if "description" not in upgraded:
104
+ upgraded["description"] = ""
105
+ if "almanac_root" not in upgraded:
106
+ upgraded["almanac_root"] = "almanac"
107
+ if "registered_at" not in upgraded or upgraded["registered_at"] == "":
108
+ upgraded["registered_at"] = datetime.now(UTC).isoformat()
109
+ try:
110
+ return WorkspaceRegistryEntry.model_validate(upgraded)
111
+ except ValidationError as error:
112
+ message = f"workspace registry entry is invalid: {error}"
113
+ raise ValidationFailed(message) from error
114
+
115
+
116
+ def entries_adapter() -> TypeAdapter[Sequence[WorkspaceRegistryEntry]]:
117
+ return TypeAdapter(Sequence[WorkspaceRegistryEntry])
118
+
119
+
120
+ def workspace_id_for_path(path: Path) -> str:
121
+ from codealmanac.services.workspaces.service import workspace_id_for
122
+
123
+ return workspace_id_for(path)
124
+
125
+
126
+ def same_path(left: Path, right: Path) -> bool:
127
+ return normalize_path(left) == normalize_path(right)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,8 @@
1
+ from codealmanac.core.models import CodeAlmanacModel
2
+ from codealmanac.services.index.models import IndexRefreshResult
3
+ from codealmanac.services.workspaces.models import Workspace
4
+
5
+
6
+ class BuildResult(CodeAlmanacModel):
7
+ workspace: Workspace
8
+ index: IndexRefreshResult
@@ -0,0 +1,45 @@
1
+ from codealmanac.services.index.service import IndexService
2
+ from codealmanac.services.wiki.service import WikiService
3
+ from codealmanac.services.workspaces.models import Workspace
4
+ from codealmanac.services.workspaces.requests import (
5
+ InitializeWorkspaceRequest,
6
+ RegisterWorkspaceRequest,
7
+ )
8
+ from codealmanac.services.workspaces.service import WorkspacesService
9
+ from codealmanac.workflows.build.models import BuildResult
10
+
11
+
12
+ class BuildWorkflow:
13
+ def __init__(
14
+ self,
15
+ workspaces: WorkspacesService,
16
+ wiki: WikiService,
17
+ index: IndexService,
18
+ ):
19
+ self.workspaces = workspaces
20
+ self.wiki = wiki
21
+ self.index = index
22
+
23
+ def initialize(self, request: InitializeWorkspaceRequest) -> Workspace:
24
+ return self._initialize_workspace(request)
25
+
26
+ def build(self, request: InitializeWorkspaceRequest) -> BuildResult:
27
+ workspace = self._initialize_workspace(request)
28
+ index = self.index.ensure_fresh(workspace.workspace_id)
29
+ return BuildResult(workspace=workspace, index=index)
30
+
31
+ def _initialize_workspace(self, request: InitializeWorkspaceRequest) -> Workspace:
32
+ target = self.workspaces.initialization_target(
33
+ request.path,
34
+ request.almanac_root,
35
+ )
36
+ workspace = self.workspaces.register(
37
+ RegisterWorkspaceRequest(
38
+ root_path=target.repo_root,
39
+ almanac_root=target.almanac_root,
40
+ name=request.name,
41
+ description=request.description,
42
+ )
43
+ )
44
+ self.wiki.initialize(workspace.workspace_id)
45
+ return workspace
@@ -0,0 +1,3 @@
1
+ from codealmanac.workflows.garden.service import GardenWorkflow
2
+
3
+ __all__ = ["GardenWorkflow"]
@@ -0,0 +1,30 @@
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 (
6
+ HealthReport,
7
+ IndexRefreshResult,
8
+ IndexSummary,
9
+ )
10
+ from codealmanac.services.runs.models import RunRecord
11
+ from codealmanac.workflows.lifecycle import LifecycleMutationReport
12
+
13
+
14
+ class GardenPromptPayload(CodeAlmanacModel):
15
+ workspace_name: str
16
+ workspace_root: Path
17
+ almanac_root: Path
18
+ pages_root: Path
19
+ topics_file: Path
20
+ index: IndexSummary
21
+ health: HealthReport
22
+ guidance: str | None = None
23
+
24
+
25
+ class GardenResult(CodeAlmanacModel):
26
+ run: RunRecord
27
+ harness: HarnessRunResult
28
+ safety: LifecycleMutationReport
29
+ index: IndexRefreshResult
30
+ health_before: HealthReport