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,14 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from codealmanac.services.automation.models import ScheduledJob, ScheduledJobStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SchedulerAdapter(Protocol):
|
|
7
|
+
def install(self, job: ScheduledJob) -> ScheduledJobStatus:
|
|
8
|
+
"""Install and activate one scheduled job."""
|
|
9
|
+
|
|
10
|
+
def uninstall(self, job: ScheduledJob) -> bool:
|
|
11
|
+
"""Remove one scheduled job. Return true when a plist was removed."""
|
|
12
|
+
|
|
13
|
+
def status(self, job: ScheduledJob) -> ScheduledJobStatus:
|
|
14
|
+
"""Read persisted scheduler state for one job."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import field_validator
|
|
5
|
+
|
|
6
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
7
|
+
from codealmanac.services.automation.models import AutomationTask
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AutomationSelectionRequest(CodeAlmanacModel):
|
|
11
|
+
tasks: tuple[AutomationTask, ...] = ()
|
|
12
|
+
home: Path | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InstallAutomationRequest(AutomationSelectionRequest):
|
|
16
|
+
cwd: Path
|
|
17
|
+
every: timedelta | None = None
|
|
18
|
+
quiet: timedelta | None = None
|
|
19
|
+
garden_every: timedelta | None = None
|
|
20
|
+
garden_off: bool = False
|
|
21
|
+
env_path: str | None = None
|
|
22
|
+
python_executable: Path | None = None
|
|
23
|
+
|
|
24
|
+
@field_validator("every", "quiet", "garden_every")
|
|
25
|
+
@classmethod
|
|
26
|
+
def non_negative_duration(
|
|
27
|
+
cls,
|
|
28
|
+
value: timedelta | None,
|
|
29
|
+
) -> timedelta | None:
|
|
30
|
+
if value is not None and value.total_seconds() < 0:
|
|
31
|
+
raise ValueError("automation duration must be non-negative")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UninstallAutomationRequest(AutomationSelectionRequest):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AutomationStatusRequest(AutomationSelectionRequest):
|
|
40
|
+
pass
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codealmanac.core.errors import ValidationFailed
|
|
9
|
+
from codealmanac.core.paths import home_dir, normalize_path, state_dir_for
|
|
10
|
+
from codealmanac.services.automation.models import (
|
|
11
|
+
AutomationInstallResult,
|
|
12
|
+
AutomationStatusReport,
|
|
13
|
+
AutomationTask,
|
|
14
|
+
AutomationUninstallResult,
|
|
15
|
+
AutomationWorkingDirectory,
|
|
16
|
+
EnvironmentVariable,
|
|
17
|
+
ScheduledJob,
|
|
18
|
+
ScheduledJobStatus,
|
|
19
|
+
)
|
|
20
|
+
from codealmanac.services.automation.ports import SchedulerAdapter
|
|
21
|
+
from codealmanac.services.automation.requests import (
|
|
22
|
+
AutomationSelectionRequest,
|
|
23
|
+
AutomationStatusRequest,
|
|
24
|
+
InstallAutomationRequest,
|
|
25
|
+
UninstallAutomationRequest,
|
|
26
|
+
)
|
|
27
|
+
from codealmanac.services.config.models import DEFAULT_SYNC_QUIET
|
|
28
|
+
from codealmanac.services.workspaces.service import WorkspacesService
|
|
29
|
+
|
|
30
|
+
DEFAULT_SYNC_INTERVAL = timedelta(hours=5)
|
|
31
|
+
DEFAULT_GARDEN_INTERVAL = timedelta(hours=4)
|
|
32
|
+
AUTOMATION_SYNC_CLAIM_OWNER = "codealmanac.automation.sync"
|
|
33
|
+
AUTOMATION_SYNC_PENDING_TIMEOUT = timedelta(hours=24)
|
|
34
|
+
AUTOMATION_SYNC_MAX_FAILED_ATTEMPTS = 3
|
|
35
|
+
|
|
36
|
+
SYNC_LABEL = "com.codealmanac.sync"
|
|
37
|
+
GARDEN_LABEL = "com.codealmanac.garden"
|
|
38
|
+
|
|
39
|
+
LAUNCHD_FALLBACK_PATHS = (
|
|
40
|
+
"/usr/local/bin",
|
|
41
|
+
"/opt/homebrew/bin",
|
|
42
|
+
"/usr/bin",
|
|
43
|
+
"/bin",
|
|
44
|
+
"/usr/sbin",
|
|
45
|
+
"/sbin",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class AutomationTaskDefinition:
|
|
51
|
+
task: AutomationTask
|
|
52
|
+
label: str
|
|
53
|
+
default_interval: timedelta
|
|
54
|
+
stdout_log_name: str
|
|
55
|
+
stderr_log_name: str
|
|
56
|
+
working_directory: AutomationWorkingDirectory
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
DEFAULT_INSTALL_TASKS = (AutomationTask.SYNC, AutomationTask.GARDEN)
|
|
60
|
+
DEFAULT_STATUS_TASKS = (AutomationTask.SYNC, AutomationTask.GARDEN)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AutomationService:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
workspaces: WorkspacesService,
|
|
67
|
+
scheduler: SchedulerAdapter,
|
|
68
|
+
):
|
|
69
|
+
self.workspaces = workspaces
|
|
70
|
+
self.scheduler = scheduler
|
|
71
|
+
|
|
72
|
+
def install(self, request: InstallAutomationRequest) -> AutomationInstallResult:
|
|
73
|
+
explicit_tasks = len(request.tasks) > 0
|
|
74
|
+
tasks = selected_tasks(request.tasks, DEFAULT_INSTALL_TASKS)
|
|
75
|
+
if explicit_tasks and request.garden_off:
|
|
76
|
+
raise ValidationFailed(
|
|
77
|
+
"--garden-off can only be used with the default automation install"
|
|
78
|
+
)
|
|
79
|
+
if explicit_tasks and len(tasks) > 1 and request.every is not None:
|
|
80
|
+
raise ValidationFailed(
|
|
81
|
+
"--every can only target one explicit automation task at a time"
|
|
82
|
+
)
|
|
83
|
+
if request.quiet is not None and request.quiet.total_seconds() < 0:
|
|
84
|
+
raise ValidationFailed("quiet window must be zero or greater")
|
|
85
|
+
|
|
86
|
+
selected = tuple(
|
|
87
|
+
task
|
|
88
|
+
for task in tasks
|
|
89
|
+
if not (task == AutomationTask.GARDEN and request.garden_off)
|
|
90
|
+
)
|
|
91
|
+
jobs = tuple(
|
|
92
|
+
self._job_for_task(
|
|
93
|
+
task,
|
|
94
|
+
request,
|
|
95
|
+
explicit_tasks,
|
|
96
|
+
resolve_working_directory=True,
|
|
97
|
+
)
|
|
98
|
+
for task in selected
|
|
99
|
+
)
|
|
100
|
+
for job in jobs:
|
|
101
|
+
self.scheduler.install(job)
|
|
102
|
+
|
|
103
|
+
disabled: tuple[ScheduledJob, ...] = ()
|
|
104
|
+
if not explicit_tasks and request.garden_off:
|
|
105
|
+
garden = self._job_for_task(
|
|
106
|
+
AutomationTask.GARDEN,
|
|
107
|
+
request,
|
|
108
|
+
explicit_tasks,
|
|
109
|
+
resolve_working_directory=False,
|
|
110
|
+
)
|
|
111
|
+
self.scheduler.uninstall(garden)
|
|
112
|
+
disabled = (garden,)
|
|
113
|
+
|
|
114
|
+
return AutomationInstallResult(jobs=jobs, disabled=disabled)
|
|
115
|
+
|
|
116
|
+
def uninstall(
|
|
117
|
+
self,
|
|
118
|
+
request: UninstallAutomationRequest,
|
|
119
|
+
) -> AutomationUninstallResult:
|
|
120
|
+
tasks = selected_tasks(request.tasks, DEFAULT_STATUS_TASKS)
|
|
121
|
+
removed: list[Path] = []
|
|
122
|
+
for task in tasks:
|
|
123
|
+
job = self._job_for_task(
|
|
124
|
+
task,
|
|
125
|
+
base_request(request),
|
|
126
|
+
explicit_tasks=True,
|
|
127
|
+
resolve_working_directory=False,
|
|
128
|
+
)
|
|
129
|
+
if self.scheduler.uninstall(job):
|
|
130
|
+
removed.append(job.plist_path)
|
|
131
|
+
return AutomationUninstallResult(tasks=tasks, removed=tuple(removed))
|
|
132
|
+
|
|
133
|
+
def status(self, request: AutomationStatusRequest) -> AutomationStatusReport:
|
|
134
|
+
tasks = selected_tasks(request.tasks, DEFAULT_STATUS_TASKS)
|
|
135
|
+
statuses: list[ScheduledJobStatus] = []
|
|
136
|
+
for task in tasks:
|
|
137
|
+
job = self._job_for_task(
|
|
138
|
+
task,
|
|
139
|
+
base_request(request),
|
|
140
|
+
explicit_tasks=True,
|
|
141
|
+
resolve_working_directory=False,
|
|
142
|
+
)
|
|
143
|
+
statuses.append(self.scheduler.status(job))
|
|
144
|
+
return AutomationStatusReport(statuses=tuple(statuses))
|
|
145
|
+
|
|
146
|
+
def _job_for_task(
|
|
147
|
+
self,
|
|
148
|
+
task: AutomationTask,
|
|
149
|
+
request: InstallAutomationRequest,
|
|
150
|
+
explicit_tasks: bool,
|
|
151
|
+
resolve_working_directory: bool,
|
|
152
|
+
) -> ScheduledJob:
|
|
153
|
+
definition = task_definition(task)
|
|
154
|
+
home = normalize_path(request.home or home_dir())
|
|
155
|
+
logs_dir = state_dir_for(home) / "logs"
|
|
156
|
+
return ScheduledJob(
|
|
157
|
+
task=task,
|
|
158
|
+
label=definition.label,
|
|
159
|
+
plist_path=plist_path_for(task, home),
|
|
160
|
+
program_arguments=program_arguments_for(task, request),
|
|
161
|
+
interval=interval_for(task, request, explicit_tasks),
|
|
162
|
+
environment=(
|
|
163
|
+
EnvironmentVariable(
|
|
164
|
+
name="PATH",
|
|
165
|
+
value=launch_path(home, request.env_path),
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
stdout_path=logs_dir / definition.stdout_log_name,
|
|
169
|
+
stderr_path=logs_dir / definition.stderr_log_name,
|
|
170
|
+
working_directory=self._working_directory(definition, request.cwd)
|
|
171
|
+
if resolve_working_directory
|
|
172
|
+
else None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _working_directory(
|
|
176
|
+
self,
|
|
177
|
+
definition: AutomationTaskDefinition,
|
|
178
|
+
cwd: Path,
|
|
179
|
+
) -> Path | None:
|
|
180
|
+
if definition.working_directory == AutomationWorkingDirectory.NONE:
|
|
181
|
+
return None
|
|
182
|
+
return self.workspaces.resolve(cwd).root_path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def base_request(
|
|
186
|
+
request: AutomationSelectionRequest,
|
|
187
|
+
) -> InstallAutomationRequest:
|
|
188
|
+
return InstallAutomationRequest(
|
|
189
|
+
cwd=Path.cwd(),
|
|
190
|
+
tasks=request.tasks,
|
|
191
|
+
home=request.home,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def task_definition(task: AutomationTask) -> AutomationTaskDefinition:
|
|
196
|
+
if task == AutomationTask.SYNC:
|
|
197
|
+
return AutomationTaskDefinition(
|
|
198
|
+
task=AutomationTask.SYNC,
|
|
199
|
+
label=SYNC_LABEL,
|
|
200
|
+
default_interval=DEFAULT_SYNC_INTERVAL,
|
|
201
|
+
stdout_log_name="sync.out.log",
|
|
202
|
+
stderr_log_name="sync.err.log",
|
|
203
|
+
working_directory=AutomationWorkingDirectory.NONE,
|
|
204
|
+
)
|
|
205
|
+
return AutomationTaskDefinition(
|
|
206
|
+
task=AutomationTask.GARDEN,
|
|
207
|
+
label=GARDEN_LABEL,
|
|
208
|
+
default_interval=DEFAULT_GARDEN_INTERVAL,
|
|
209
|
+
stdout_log_name="garden.out.log",
|
|
210
|
+
stderr_log_name="garden.err.log",
|
|
211
|
+
working_directory=AutomationWorkingDirectory.CURRENT_WIKI,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def selected_tasks(
|
|
216
|
+
requested: Sequence[AutomationTask],
|
|
217
|
+
defaults: tuple[AutomationTask, ...],
|
|
218
|
+
) -> tuple[AutomationTask, ...]:
|
|
219
|
+
tasks = tuple(requested) if len(requested) > 0 else defaults
|
|
220
|
+
selected: list[AutomationTask] = []
|
|
221
|
+
for task in tasks:
|
|
222
|
+
if task not in selected:
|
|
223
|
+
selected.append(task)
|
|
224
|
+
return tuple(selected)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def interval_for(
|
|
228
|
+
task: AutomationTask,
|
|
229
|
+
request: InstallAutomationRequest,
|
|
230
|
+
explicit_tasks: bool,
|
|
231
|
+
) -> timedelta:
|
|
232
|
+
if task == AutomationTask.SYNC:
|
|
233
|
+
return request.every or DEFAULT_SYNC_INTERVAL
|
|
234
|
+
if request.garden_every is not None:
|
|
235
|
+
return request.garden_every
|
|
236
|
+
if explicit_tasks and request.every is not None:
|
|
237
|
+
return request.every
|
|
238
|
+
return DEFAULT_GARDEN_INTERVAL
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def program_arguments_for(
|
|
242
|
+
task: AutomationTask,
|
|
243
|
+
request: InstallAutomationRequest,
|
|
244
|
+
) -> tuple[str, ...]:
|
|
245
|
+
executable = request.python_executable or Path(sys.executable)
|
|
246
|
+
base = (str(executable), "-m", "codealmanac.cli.main")
|
|
247
|
+
if task == AutomationTask.SYNC:
|
|
248
|
+
quiet = request.quiet or DEFAULT_SYNC_QUIET
|
|
249
|
+
return (
|
|
250
|
+
*base,
|
|
251
|
+
"sync",
|
|
252
|
+
"--quiet",
|
|
253
|
+
duration_text(quiet),
|
|
254
|
+
"--claim-owner",
|
|
255
|
+
AUTOMATION_SYNC_CLAIM_OWNER,
|
|
256
|
+
"--pending-timeout",
|
|
257
|
+
duration_text(AUTOMATION_SYNC_PENDING_TIMEOUT),
|
|
258
|
+
"--max-failed-attempts",
|
|
259
|
+
str(AUTOMATION_SYNC_MAX_FAILED_ATTEMPTS),
|
|
260
|
+
)
|
|
261
|
+
return (*base, "garden")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def plist_path_for(task: AutomationTask, home: Path) -> Path:
|
|
265
|
+
definition = task_definition(task)
|
|
266
|
+
return home / "Library/LaunchAgents" / f"{definition.label}.plist"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def launch_path(home: Path, env_path: str | None) -> str:
|
|
270
|
+
values = [
|
|
271
|
+
item.strip()
|
|
272
|
+
for item in (env_path or os.environ.get("PATH", "")).split(":")
|
|
273
|
+
if item.strip()
|
|
274
|
+
]
|
|
275
|
+
values.extend([str(home / ".local/bin"), str(home / ".bun/bin")])
|
|
276
|
+
values.extend(LAUNCHD_FALLBACK_PATHS)
|
|
277
|
+
return ":".join(unique(values))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def unique(values: Sequence[str]) -> tuple[str, ...]:
|
|
281
|
+
seen: list[str] = []
|
|
282
|
+
for value in values:
|
|
283
|
+
if value not in seen:
|
|
284
|
+
seen.append(value)
|
|
285
|
+
return tuple(seen)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def duration_text(value: timedelta) -> str:
|
|
289
|
+
seconds = int(value.total_seconds())
|
|
290
|
+
if seconds % 3600 == 0:
|
|
291
|
+
return f"{seconds // 3600}h"
|
|
292
|
+
if seconds % 60 == 0:
|
|
293
|
+
return f"{seconds // 60}m"
|
|
294
|
+
return f"{seconds}s"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from codealmanac.services.config.models import (
|
|
2
|
+
CodeAlmanacConfig,
|
|
3
|
+
HarnessConfig,
|
|
4
|
+
SyncConfig,
|
|
5
|
+
)
|
|
6
|
+
from codealmanac.services.config.requests import LoadConfigRequest
|
|
7
|
+
from codealmanac.services.config.service import ConfigService
|
|
8
|
+
from codealmanac.services.config.store import ConfigStore
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CodeAlmanacConfig",
|
|
12
|
+
"ConfigService",
|
|
13
|
+
"ConfigStore",
|
|
14
|
+
"HarnessConfig",
|
|
15
|
+
"LoadConfigRequest",
|
|
16
|
+
"SyncConfig",
|
|
17
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from humanfriendly import InvalidTimespan, parse_timespan
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
9
|
+
from codealmanac.services.harnesses.models import HarnessKind
|
|
10
|
+
|
|
11
|
+
DEFAULT_HARNESS = HarnessKind.CLAUDE
|
|
12
|
+
DEFAULT_SYNC_QUIET = timedelta(minutes=45)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HarnessConfig(CodeAlmanacModel):
|
|
16
|
+
default: HarnessKind = DEFAULT_HARNESS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncConfig(CodeAlmanacModel):
|
|
20
|
+
quiet: timedelta = DEFAULT_SYNC_QUIET
|
|
21
|
+
|
|
22
|
+
@field_validator("quiet", mode="before")
|
|
23
|
+
@classmethod
|
|
24
|
+
def parse_quiet(cls, value: Any) -> Any:
|
|
25
|
+
return parse_duration(value, "sync.quiet")
|
|
26
|
+
|
|
27
|
+
@field_validator("quiet")
|
|
28
|
+
@classmethod
|
|
29
|
+
def require_non_negative_quiet(cls, value: timedelta) -> timedelta:
|
|
30
|
+
if value.total_seconds() < 0:
|
|
31
|
+
raise ValueError("sync.quiet must be zero or greater")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CodeAlmanacConfig(BaseSettings):
|
|
36
|
+
model_config = SettingsConfigDict(frozen=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
harness: HarnessConfig = Field(default_factory=HarnessConfig)
|
|
39
|
+
sync: SyncConfig = Field(default_factory=SyncConfig)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def settings_customise_sources(
|
|
43
|
+
cls,
|
|
44
|
+
settings_cls,
|
|
45
|
+
init_settings,
|
|
46
|
+
env_settings,
|
|
47
|
+
dotenv_settings,
|
|
48
|
+
file_secret_settings,
|
|
49
|
+
):
|
|
50
|
+
return (init_settings,)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_duration(value: Any, label: str) -> Any:
|
|
54
|
+
if value is None or isinstance(value, timedelta):
|
|
55
|
+
return value
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
return value
|
|
58
|
+
try:
|
|
59
|
+
return timedelta(seconds=parse_timespan(value))
|
|
60
|
+
except InvalidTimespan as error:
|
|
61
|
+
raise ValueError(f"{label} must be a duration") from error
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
|
|
8
|
+
|
|
9
|
+
class LoadConfigRequest(CodeAlmanacModel):
|
|
10
|
+
cwd: Path
|
|
11
|
+
wiki: str | None = Field(
|
|
12
|
+
default=None,
|
|
13
|
+
description="None means use the nearest project config.",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@field_validator("wiki")
|
|
17
|
+
@classmethod
|
|
18
|
+
def require_wiki(cls, value: str | None) -> str | None:
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
return required_text(value, "wiki selector")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from codealmanac.core.errors import NotFoundError
|
|
4
|
+
from codealmanac.core.paths import normalize_path
|
|
5
|
+
from codealmanac.services.config.models import CodeAlmanacConfig
|
|
6
|
+
from codealmanac.services.config.requests import LoadConfigRequest
|
|
7
|
+
from codealmanac.services.config.store import ConfigStore
|
|
8
|
+
from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
|
|
9
|
+
from codealmanac.services.workspaces.service import WorkspacesService
|
|
10
|
+
|
|
11
|
+
PROJECT_CONFIG_NAME = "config.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigService:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
workspaces: WorkspacesService,
|
|
18
|
+
store: ConfigStore,
|
|
19
|
+
user_config_path: Path,
|
|
20
|
+
):
|
|
21
|
+
self.workspaces = workspaces
|
|
22
|
+
self.store = store
|
|
23
|
+
self.user_config_path = user_config_path
|
|
24
|
+
|
|
25
|
+
def load(self, request: LoadConfigRequest) -> CodeAlmanacConfig:
|
|
26
|
+
project_config_path = self._project_config_path(request)
|
|
27
|
+
paths = config_source_paths(
|
|
28
|
+
user_config_path=normalize_path(self.user_config_path),
|
|
29
|
+
project_config_path=project_config_path,
|
|
30
|
+
)
|
|
31
|
+
return self.store.load(paths)
|
|
32
|
+
|
|
33
|
+
def _project_config_path(self, request: LoadConfigRequest) -> Path | None:
|
|
34
|
+
if request.wiki is not None:
|
|
35
|
+
workspace = self.workspaces.select(
|
|
36
|
+
SelectWorkspaceRequest(
|
|
37
|
+
selector=request.wiki,
|
|
38
|
+
base_path=request.cwd,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return workspace.almanac_path / PROJECT_CONFIG_NAME
|
|
42
|
+
try:
|
|
43
|
+
workspace = self.workspaces.resolve(request.cwd)
|
|
44
|
+
except (NotFoundError, OSError):
|
|
45
|
+
return None
|
|
46
|
+
return workspace.almanac_path / PROJECT_CONFIG_NAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def config_source_paths(
|
|
50
|
+
user_config_path: Path,
|
|
51
|
+
project_config_path: Path | None,
|
|
52
|
+
) -> tuple[Path, ...]:
|
|
53
|
+
if project_config_path is None:
|
|
54
|
+
return (user_config_path,)
|
|
55
|
+
return (project_config_path, user_config_path)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from tomllib import TOMLDecodeError
|
|
3
|
+
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
from pydantic_settings import TomlConfigSettingsSource
|
|
6
|
+
|
|
7
|
+
from codealmanac.core.errors import ValidationFailed
|
|
8
|
+
from codealmanac.services.config.models import CodeAlmanacConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigStore:
|
|
12
|
+
def load(self, paths: tuple[Path, ...]) -> CodeAlmanacConfig:
|
|
13
|
+
try:
|
|
14
|
+
sources = tuple(
|
|
15
|
+
TomlConfigSettingsSource(
|
|
16
|
+
CodeAlmanacConfig,
|
|
17
|
+
toml_file=path,
|
|
18
|
+
deep_merge=True,
|
|
19
|
+
)
|
|
20
|
+
for path in paths
|
|
21
|
+
)
|
|
22
|
+
return CodeAlmanacConfig(_build_sources=(sources, {}))
|
|
23
|
+
except TOMLDecodeError as error:
|
|
24
|
+
raise ValidationFailed(f"invalid config TOML: {error}") from error
|
|
25
|
+
except ValidationError as error:
|
|
26
|
+
raise ValidationFailed(f"invalid config: {error}") from error
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local diagnostics service."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DoctorStatus(StrEnum):
|
|
7
|
+
OK = "ok"
|
|
8
|
+
INFO = "info"
|
|
9
|
+
PROBLEM = "problem"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DoctorCheck(CodeAlmanacModel):
|
|
13
|
+
key: str
|
|
14
|
+
status: DoctorStatus
|
|
15
|
+
message: str
|
|
16
|
+
fix: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DoctorReport(CodeAlmanacModel):
|
|
20
|
+
version: str
|
|
21
|
+
install: tuple[DoctorCheck, ...]
|
|
22
|
+
wiki: tuple[DoctorCheck, ...]
|