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,197 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.errors import ExecutionFailed, ValidationFailed
4
+ from codealmanac.core.models import CodeAlmanacModel
5
+ from codealmanac.services.harnesses.models import (
6
+ HarnessEvent,
7
+ HarnessEventKind,
8
+ HarnessRunResult,
9
+ HarnessRunStatus,
10
+ terminal_harness_event,
11
+ )
12
+ from codealmanac.services.runs.models import RunEventKind
13
+ from codealmanac.services.workspaces.models import (
14
+ Workspace,
15
+ WorkspaceChangeSnapshot,
16
+ WorkspacePathChange,
17
+ )
18
+ from codealmanac.services.workspaces.ports import WorkspaceChangeProbe
19
+
20
+
21
+ class LifecycleMutationPreflight(CodeAlmanacModel):
22
+ before: WorkspaceChangeSnapshot
23
+ almanac_prefix: Path
24
+
25
+
26
+ class LifecycleMutationReport(CodeAlmanacModel):
27
+ before: WorkspaceChangeSnapshot
28
+ after: WorkspaceChangeSnapshot
29
+ changed_files: tuple[Path, ...]
30
+
31
+
32
+ class LifecycleMutationPolicy:
33
+ def __init__(self, probe: WorkspaceChangeProbe, operation: str):
34
+ self.probe = probe
35
+ self.operation = operation
36
+
37
+ def preflight(self, workspace: Workspace) -> LifecycleMutationPreflight:
38
+ before = self.probe.snapshot(workspace.root_path)
39
+ validate_snapshot_available(before, self.operation)
40
+ almanac_prefix = almanac_relative_path(workspace)
41
+ dirty_almanac = tuple(
42
+ change.path
43
+ for change in before.changes
44
+ if path_is_under(change.path, almanac_prefix)
45
+ )
46
+ if dirty_almanac:
47
+ almanac_label = almanac_prefix.as_posix()
48
+ raise ValidationFailed(
49
+ f"{self.operation} requires a clean {almanac_label} before running: "
50
+ f"{format_paths(dirty_almanac)}"
51
+ )
52
+ return LifecycleMutationPreflight(
53
+ before=before,
54
+ almanac_prefix=almanac_prefix,
55
+ )
56
+
57
+ def validate(
58
+ self,
59
+ preflight: LifecycleMutationPreflight,
60
+ workspace: Workspace,
61
+ reported_changed_files: tuple[Path, ...],
62
+ ) -> LifecycleMutationReport:
63
+ validate_reported_changes(workspace, reported_changed_files)
64
+ after = self.probe.snapshot(workspace.root_path)
65
+ validate_snapshot_available(after, self.operation)
66
+ mutated = changed_paths(preflight.before, after)
67
+ unsafe = tuple(
68
+ path
69
+ for path in mutated
70
+ if not path_is_under(path, preflight.almanac_prefix)
71
+ )
72
+ if unsafe:
73
+ almanac_label = preflight.almanac_prefix.as_posix()
74
+ raise ValidationFailed(
75
+ f"{self.operation} changed file outside {almanac_label}: "
76
+ f"{format_paths(unsafe)}"
77
+ )
78
+ return LifecycleMutationReport(
79
+ before=preflight.before,
80
+ after=after,
81
+ changed_files=tuple(
82
+ workspace.root_path / path
83
+ for path in mutated
84
+ if path_is_under(path, preflight.almanac_prefix)
85
+ ),
86
+ )
87
+
88
+
89
+ def validate_harness_result(result: HarnessRunResult) -> None:
90
+ if result.status != HarnessRunStatus.SUCCEEDED:
91
+ suffix = first_line(result.output_text)
92
+ details = f": {suffix}" if suffix else ""
93
+ raise ExecutionFailed(
94
+ f"harness {result.kind.value} failed with status "
95
+ f"{result.status.value}{details}"
96
+ )
97
+
98
+
99
+ def harness_events(result: HarnessRunResult) -> tuple[HarnessEvent, ...]:
100
+ if len(result.events) > 0:
101
+ return result.events
102
+ return (terminal_harness_event(result.kind, result.status, result.output_text),)
103
+
104
+
105
+ def harness_run_event_kind(event: HarnessEvent) -> RunEventKind:
106
+ if event.kind == HarnessEventKind.ERROR:
107
+ return RunEventKind.ERROR
108
+ if event.kind in {
109
+ HarnessEventKind.TOOL_USE,
110
+ HarnessEventKind.TOOL_RESULT,
111
+ HarnessEventKind.TOOL_SUMMARY,
112
+ HarnessEventKind.CONTEXT_USAGE,
113
+ HarnessEventKind.WARNING,
114
+ }:
115
+ return RunEventKind.TOOL
116
+ return RunEventKind.OUTPUT
117
+
118
+
119
+ def first_line(value: str) -> str:
120
+ return value.splitlines()[0] if value.splitlines() else value
121
+
122
+
123
+ def validate_snapshot_available(
124
+ snapshot: WorkspaceChangeSnapshot,
125
+ operation: str,
126
+ ) -> None:
127
+ if snapshot.available:
128
+ return
129
+ reason = snapshot.unavailable_reason or "unknown git status failure"
130
+ raise ValidationFailed(f"{operation} requires Git change tracking: {reason}")
131
+
132
+
133
+ def validate_reported_changes(
134
+ workspace: Workspace,
135
+ reported_changed_files: tuple[Path, ...],
136
+ ) -> None:
137
+ almanac_root = workspace.almanac_path.resolve()
138
+ for changed_file in reported_changed_files:
139
+ candidate = changed_file
140
+ if not candidate.is_absolute():
141
+ candidate = workspace.root_path / candidate
142
+ try:
143
+ candidate.resolve().relative_to(almanac_root)
144
+ except ValueError as error:
145
+ raise ValidationFailed(
146
+ "harness reported change outside configured Almanac root: "
147
+ f"{changed_file}"
148
+ ) from error
149
+
150
+
151
+ def changed_paths(
152
+ before: WorkspaceChangeSnapshot,
153
+ after: WorkspaceChangeSnapshot,
154
+ ) -> tuple[Path, ...]:
155
+ before_by_path = changes_by_path(before.changes)
156
+ after_by_path = changes_by_path(after.changes)
157
+ paths = set(before_by_path) | set(after_by_path)
158
+ changed = [
159
+ path
160
+ for path in paths
161
+ if change_identity(before_by_path.get(path))
162
+ != change_identity(after_by_path.get(path))
163
+ ]
164
+ return tuple(sorted(changed, key=lambda item: item.as_posix()))
165
+
166
+
167
+ def changes_by_path(
168
+ changes: tuple[WorkspacePathChange, ...],
169
+ ) -> dict[Path, WorkspacePathChange]:
170
+ return {change.path: change for change in changes}
171
+
172
+
173
+ def change_identity(
174
+ change: WorkspacePathChange | None,
175
+ ) -> tuple[str, str, str | None] | None:
176
+ if change is None:
177
+ return None
178
+ return (change.state.value, change.status, change.fingerprint)
179
+
180
+
181
+ def almanac_relative_path(workspace: Workspace) -> Path:
182
+ try:
183
+ return workspace.almanac_path.resolve().relative_to(
184
+ workspace.root_path.resolve()
185
+ )
186
+ except ValueError as error:
187
+ raise ValidationFailed(
188
+ f"Almanac root is outside workspace: {workspace.almanac_path}"
189
+ ) from error
190
+
191
+
192
+ def path_is_under(path: Path, parent: Path) -> bool:
193
+ return path == parent or parent in path.parents
194
+
195
+
196
+ def format_paths(paths: tuple[Path, ...]) -> str:
197
+ return ", ".join(path.as_posix() for path in paths)
@@ -0,0 +1,3 @@
1
+ from codealmanac.workflows.sync.service import SyncWorkflow
2
+
3
+ __all__ = ["SyncWorkflow"]
@@ -0,0 +1,157 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+
5
+ from pydantic import field_validator
6
+
7
+ from codealmanac.core.models import CodeAlmanacModel
8
+ from codealmanac.core.text import required_text
9
+ from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
10
+
11
+
12
+ class SyncMode(StrEnum):
13
+ STATUS = "status"
14
+ SYNC = "sync"
15
+
16
+
17
+ class SyncLedgerStatus(StrEnum):
18
+ DONE = "done"
19
+ PENDING = "pending"
20
+ FAILED = "failed"
21
+ NEEDS_ATTENTION = "needs_attention"
22
+
23
+
24
+ class SyncDecisionKind(StrEnum):
25
+ SKIP = "skip"
26
+ NEEDS_ATTENTION = "needs_attention"
27
+ READY = "ready"
28
+
29
+
30
+ class SyncLedgerEntry(CodeAlmanacModel):
31
+ app: TranscriptApp
32
+ session_id: str
33
+ transcript_path: Path
34
+ status: SyncLedgerStatus
35
+ last_absorbed_size: int
36
+ last_absorbed_line: int
37
+ last_absorbed_prefix_hash: str
38
+ last_absorbed_at: datetime | None = None
39
+ last_job_id: str | None = None
40
+ last_error: str | None = None
41
+ failed_attempts: int = 0
42
+ pending_started_at: datetime | None = None
43
+ pending_owner: str | None = None
44
+ pending_run_id: str | None = None
45
+ pending_to_size: int | None = None
46
+ pending_prefix_hash: str | None = None
47
+ pending_from_line: int | None = None
48
+ pending_to_line: int | None = None
49
+
50
+ @field_validator("session_id", "last_absorbed_prefix_hash")
51
+ @classmethod
52
+ def require_text(cls, value: str) -> str:
53
+ return required_text(value, "sync ledger entry")
54
+
55
+ @field_validator("pending_owner", "pending_run_id", "pending_prefix_hash")
56
+ @classmethod
57
+ def require_optional_pending_text(cls, value: str | None) -> str | None:
58
+ if value is None:
59
+ return None
60
+ return required_text(value, "sync pending value")
61
+
62
+ @field_validator("last_absorbed_size", "last_absorbed_line")
63
+ @classmethod
64
+ def non_negative_cursor(cls, value: int) -> int:
65
+ if value < 0:
66
+ raise ValueError("sync cursor must be non-negative")
67
+ return value
68
+
69
+ @field_validator("failed_attempts")
70
+ @classmethod
71
+ def non_negative_failed_attempts(cls, value: int) -> int:
72
+ if value < 0:
73
+ raise ValueError("sync failed attempts must be non-negative")
74
+ return value
75
+
76
+ @field_validator("pending_to_size", "pending_from_line", "pending_to_line")
77
+ @classmethod
78
+ def non_negative_pending_cursor(cls, value: int | None) -> int | None:
79
+ if value is not None and value < 0:
80
+ raise ValueError("sync pending cursor must be non-negative")
81
+ return value
82
+
83
+
84
+ class SyncLedger(CodeAlmanacModel):
85
+ version: int
86
+ updated_at: datetime
87
+ sessions: dict[str, SyncLedgerEntry]
88
+
89
+
90
+ class SyncReady(CodeAlmanacModel):
91
+ app: TranscriptApp
92
+ session_id: str
93
+ transcript_path: Path
94
+ repo_root: Path
95
+ from_line: int
96
+ to_line: int
97
+
98
+
99
+ class SyncStarted(CodeAlmanacModel):
100
+ app: TranscriptApp
101
+ session_id: str
102
+ transcript_path: Path
103
+ repo_root: Path
104
+ run_id: str
105
+ from_line: int
106
+ to_line: int
107
+
108
+
109
+ class SyncSkipped(CodeAlmanacModel):
110
+ transcript_path: Path
111
+ reason: str
112
+ app: TranscriptApp | None = None
113
+ session_id: str | None = None
114
+ repo_root: Path | None = None
115
+
116
+ @field_validator("reason")
117
+ @classmethod
118
+ def require_reason(cls, value: str) -> str:
119
+ return required_text(value, "sync skip reason")
120
+
121
+
122
+ class SyncSummary(CodeAlmanacModel):
123
+ mode: SyncMode
124
+ scanned: int
125
+ eligible: int
126
+ ready: tuple[SyncReady, ...] = ()
127
+ started: tuple[SyncStarted, ...] = ()
128
+ skipped: tuple[SyncSkipped, ...] = ()
129
+ needs_attention: tuple[SyncSkipped, ...] = ()
130
+
131
+
132
+ class TranscriptSnapshot(CodeAlmanacModel):
133
+ content: bytes
134
+ current_size: int
135
+ current_line: int
136
+
137
+
138
+ class SyncCursorDecision(CodeAlmanacModel):
139
+ kind: SyncDecisionKind
140
+ reason: str = ""
141
+ from_line: int = 0
142
+ to_line: int = 0
143
+
144
+
145
+ class SyncWorkItem(CodeAlmanacModel):
146
+ candidate: TranscriptCandidate
147
+ ledger_key: str
148
+ entry: SyncLedgerEntry
149
+ snapshot: TranscriptSnapshot
150
+ from_line: int
151
+ to_line: int
152
+
153
+
154
+ class SyncEvaluation(CodeAlmanacModel):
155
+ summary: SyncSummary
156
+ work_items: tuple[SyncWorkItem, ...]
157
+ ledgers: dict[Path, SyncLedger]
@@ -0,0 +1,63 @@
1
+ from datetime import datetime, timedelta
2
+ from pathlib import Path
3
+
4
+ from pydantic import field_validator
5
+
6
+ from codealmanac.core.models import CodeAlmanacModel
7
+ from codealmanac.core.text import required_text
8
+ from codealmanac.services.harnesses.models import HarnessKind
9
+ from codealmanac.services.sources.models import TranscriptApp
10
+
11
+ DEFAULT_SYNC_PENDING_TIMEOUT = timedelta(hours=24)
12
+ DEFAULT_SYNC_MAX_FAILED_ATTEMPTS = 3
13
+
14
+
15
+ class SyncSelectionRequest(CodeAlmanacModel):
16
+ cwd: Path
17
+ apps: tuple[TranscriptApp, ...]
18
+ quiet: timedelta
19
+ wiki: str | None = None
20
+ home: Path | None = None
21
+ now: datetime | None = None
22
+ pending_timeout: timedelta = DEFAULT_SYNC_PENDING_TIMEOUT
23
+ max_failed_attempts: int = DEFAULT_SYNC_MAX_FAILED_ATTEMPTS
24
+
25
+ @field_validator("apps")
26
+ @classmethod
27
+ def require_apps(
28
+ cls,
29
+ value: tuple[TranscriptApp, ...],
30
+ ) -> tuple[TranscriptApp, ...]:
31
+ if len(value) == 0:
32
+ raise ValueError("at least one sync app is required")
33
+ return value
34
+
35
+ @field_validator("quiet", "pending_timeout")
36
+ @classmethod
37
+ def non_negative_duration(cls, value: timedelta) -> timedelta:
38
+ if value.total_seconds() < 0:
39
+ raise ValueError("sync duration must be non-negative")
40
+ return value
41
+
42
+ @field_validator("max_failed_attempts")
43
+ @classmethod
44
+ def non_negative_max_failed_attempts(cls, value: int) -> int:
45
+ if value < 0:
46
+ raise ValueError("sync max failed attempts must be non-negative")
47
+ return value
48
+
49
+
50
+ class RunSyncStatusRequest(SyncSelectionRequest):
51
+ pass
52
+
53
+
54
+ class RunSyncRequest(SyncSelectionRequest):
55
+ harness: HarnessKind
56
+ claim_owner: str | None = None
57
+
58
+ @field_validator("claim_owner")
59
+ @classmethod
60
+ def require_claim_owner(cls, value: str | None) -> str | None:
61
+ if value is None:
62
+ return None
63
+ return required_text(value, "sync claim owner")