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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- 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,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
|