taskledger 0.1.0__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.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- taskledger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
7
|
+
|
|
8
|
+
from taskledger.errors import LaunchError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from taskledger.storage.paths import ProjectPaths
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
tomllib = importlib.import_module("tomllib")
|
|
15
|
+
except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10
|
|
16
|
+
tomllib = importlib.import_module("tomli")
|
|
17
|
+
|
|
18
|
+
LOCATION_CONFIG_KEYS = frozenset({"config_version", "taskledger_dir"})
|
|
19
|
+
WORKFLOW_CONFIG_KEYS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"default_memory_update_mode",
|
|
22
|
+
"default_file_render_mode",
|
|
23
|
+
"default_save_run_reports",
|
|
24
|
+
"default_source_max_chars",
|
|
25
|
+
"default_total_source_max_chars",
|
|
26
|
+
"default_source_head_lines",
|
|
27
|
+
"default_source_tail_lines",
|
|
28
|
+
"default_context_order",
|
|
29
|
+
"workflow_schema",
|
|
30
|
+
"project_context",
|
|
31
|
+
"artifact_rules",
|
|
32
|
+
"default_artifact_order",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
SUPPORTED_PROJECT_CONFIG_KEYS = LOCATION_CONFIG_KEYS | WORKFLOW_CONFIG_KEYS
|
|
36
|
+
|
|
37
|
+
MemoryUpdateMode = Literal["replace", "append", "prepend"]
|
|
38
|
+
FileRenderMode = Literal["content", "reference"]
|
|
39
|
+
DEFAULT_PROJECT_SOURCE_MAX_CHARS = 12000
|
|
40
|
+
DEFAULT_PROJECT_TOTAL_SOURCE_MAX_CHARS = 48000
|
|
41
|
+
DEFAULT_PROJECT_SOURCE_HEAD_LINES = 200
|
|
42
|
+
DEFAULT_PROJECT_SOURCE_TAIL_LINES = 50
|
|
43
|
+
ARTIFACT_MEMORY_REF_FIELDS = (
|
|
44
|
+
"analysis_memory_ref",
|
|
45
|
+
"state_memory_ref",
|
|
46
|
+
"plan_memory_ref",
|
|
47
|
+
"implementation_memory_ref",
|
|
48
|
+
"validation_memory_ref",
|
|
49
|
+
"save_target_ref",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True, frozen=True)
|
|
54
|
+
class ProjectArtifactRule:
|
|
55
|
+
name: str
|
|
56
|
+
depends_on: tuple[str, ...] = ()
|
|
57
|
+
memory_ref_field: str | None = None
|
|
58
|
+
label: str | None = None
|
|
59
|
+
description: str | None = None
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, object]:
|
|
62
|
+
return {
|
|
63
|
+
"name": self.name,
|
|
64
|
+
"depends_on": list(self.depends_on),
|
|
65
|
+
"memory_ref_field": self.memory_ref_field,
|
|
66
|
+
"label": self.label,
|
|
67
|
+
"description": self.description,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, frozen=True)
|
|
72
|
+
class ProjectConfig:
|
|
73
|
+
default_memory_update_mode: MemoryUpdateMode = "replace"
|
|
74
|
+
default_file_render_mode: FileRenderMode = "content"
|
|
75
|
+
default_save_run_reports: bool = True
|
|
76
|
+
default_source_max_chars: int | None = DEFAULT_PROJECT_SOURCE_MAX_CHARS
|
|
77
|
+
default_total_source_max_chars: int | None = DEFAULT_PROJECT_TOTAL_SOURCE_MAX_CHARS
|
|
78
|
+
default_source_head_lines: int | None = DEFAULT_PROJECT_SOURCE_HEAD_LINES
|
|
79
|
+
default_source_tail_lines: int | None = DEFAULT_PROJECT_SOURCE_TAIL_LINES
|
|
80
|
+
default_context_order: tuple[str, ...] = (
|
|
81
|
+
"memory",
|
|
82
|
+
"file",
|
|
83
|
+
"item",
|
|
84
|
+
"inline",
|
|
85
|
+
"loop_artifact",
|
|
86
|
+
)
|
|
87
|
+
workflow_schema: str | None = None
|
|
88
|
+
project_context: str | None = None
|
|
89
|
+
artifact_rules: tuple[ProjectArtifactRule, ...] = ()
|
|
90
|
+
default_artifact_order: tuple[str, ...] = ()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render_default_taskledger_toml(taskledger_dir: str = ".taskledger") -> str:
|
|
94
|
+
return f"""# Project-local taskledger configuration.
|
|
95
|
+
# This file lives in the source project root.
|
|
96
|
+
config_version = 1
|
|
97
|
+
taskledger_dir = {taskledger_dir!r}
|
|
98
|
+
|
|
99
|
+
# Project-local taskledger overrides.
|
|
100
|
+
# The source-budget settings below are the active composition defaults.
|
|
101
|
+
# Lower them for stricter prompts, or raise them when a run needs more context.
|
|
102
|
+
# Supported keys:
|
|
103
|
+
# default_memory_update_mode = "replace"
|
|
104
|
+
# default_file_render_mode = "content"
|
|
105
|
+
# default_save_run_reports = true
|
|
106
|
+
# default_source_max_chars = {DEFAULT_PROJECT_SOURCE_MAX_CHARS}
|
|
107
|
+
# default_total_source_max_chars = {DEFAULT_PROJECT_TOTAL_SOURCE_MAX_CHARS}
|
|
108
|
+
# default_source_head_lines = {DEFAULT_PROJECT_SOURCE_HEAD_LINES}
|
|
109
|
+
# default_source_tail_lines = {DEFAULT_PROJECT_SOURCE_TAIL_LINES}
|
|
110
|
+
# default_context_order = ["memory", "file", "item", "inline", "loop_artifact"]
|
|
111
|
+
# workflow_schema = "opsx-lite"
|
|
112
|
+
# project_context = "Project-specific workflow guidance."
|
|
113
|
+
# default_artifact_order = ["analysis", "plan", "implementation", "validation"]
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
DEFAULT_TASKLEDGER_TOML = render_default_taskledger_toml()
|
|
118
|
+
DEFAULT_PROJECT_TOML = DEFAULT_TASKLEDGER_TOML
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_project_config_overrides(paths: ProjectPaths) -> dict[str, object]:
|
|
122
|
+
data = load_project_config_document(paths.config_path)
|
|
123
|
+
return {key: value for key, value in data.items() if key in WORKFLOW_CONFIG_KEYS}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_project_config_document(path: Path) -> dict[str, object]:
|
|
127
|
+
if not path.exists():
|
|
128
|
+
return {}
|
|
129
|
+
try:
|
|
130
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
raise LaunchError(f"Failed to read {path}: {exc}") from exc
|
|
133
|
+
if not text:
|
|
134
|
+
return {}
|
|
135
|
+
try:
|
|
136
|
+
data = tomllib.loads(text)
|
|
137
|
+
except Exception as exc: # pragma: no cover - tomllib type varies by runtime
|
|
138
|
+
raise LaunchError(f"Invalid project config {path}: {exc}") from exc
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
raise LaunchError(f"Invalid project config {path}: expected a TOML table.")
|
|
141
|
+
_validate_project_config_overrides(data, path)
|
|
142
|
+
return data
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def merge_project_config(
|
|
146
|
+
base: ProjectConfig, overrides: dict[str, object]
|
|
147
|
+
) -> ProjectConfig:
|
|
148
|
+
default_memory_update_mode = overrides.get(
|
|
149
|
+
"default_memory_update_mode", base.default_memory_update_mode
|
|
150
|
+
)
|
|
151
|
+
default_file_render_mode = overrides.get(
|
|
152
|
+
"default_file_render_mode",
|
|
153
|
+
base.default_file_render_mode,
|
|
154
|
+
)
|
|
155
|
+
default_save_run_reports = overrides.get(
|
|
156
|
+
"default_save_run_reports", base.default_save_run_reports
|
|
157
|
+
)
|
|
158
|
+
default_source_max_chars = overrides.get(
|
|
159
|
+
"default_source_max_chars", base.default_source_max_chars
|
|
160
|
+
)
|
|
161
|
+
default_total_source_max_chars = overrides.get(
|
|
162
|
+
"default_total_source_max_chars", base.default_total_source_max_chars
|
|
163
|
+
)
|
|
164
|
+
default_source_head_lines = overrides.get(
|
|
165
|
+
"default_source_head_lines", base.default_source_head_lines
|
|
166
|
+
)
|
|
167
|
+
default_source_tail_lines = overrides.get(
|
|
168
|
+
"default_source_tail_lines", base.default_source_tail_lines
|
|
169
|
+
)
|
|
170
|
+
default_context_order = overrides.get(
|
|
171
|
+
"default_context_order", list(base.default_context_order)
|
|
172
|
+
)
|
|
173
|
+
workflow_schema = overrides.get("workflow_schema", base.workflow_schema)
|
|
174
|
+
project_context = overrides.get("project_context", base.project_context)
|
|
175
|
+
artifact_rules = _artifact_rules_from_overrides(
|
|
176
|
+
overrides.get("artifact_rules"),
|
|
177
|
+
base.artifact_rules,
|
|
178
|
+
)
|
|
179
|
+
default_artifact_order = overrides.get(
|
|
180
|
+
"default_artifact_order",
|
|
181
|
+
list(base.default_artifact_order),
|
|
182
|
+
)
|
|
183
|
+
if not isinstance(default_memory_update_mode, str):
|
|
184
|
+
raise LaunchError("Project config default_memory_update_mode must be a string.")
|
|
185
|
+
if default_memory_update_mode not in {"replace", "append", "prepend"}:
|
|
186
|
+
raise LaunchError(
|
|
187
|
+
"Project config default_memory_update_mode must be "
|
|
188
|
+
"replace, append, or prepend."
|
|
189
|
+
)
|
|
190
|
+
if not isinstance(default_file_render_mode, str):
|
|
191
|
+
raise LaunchError("Project config default_file_render_mode must be a string.")
|
|
192
|
+
if default_file_render_mode not in {"content", "reference"}:
|
|
193
|
+
raise LaunchError(
|
|
194
|
+
"Project config default_file_render_mode must be content or reference."
|
|
195
|
+
)
|
|
196
|
+
if not isinstance(default_save_run_reports, bool):
|
|
197
|
+
raise LaunchError("Project config default_save_run_reports must be a boolean.")
|
|
198
|
+
for value, label in (
|
|
199
|
+
(default_source_max_chars, "default_source_max_chars"),
|
|
200
|
+
(default_total_source_max_chars, "default_total_source_max_chars"),
|
|
201
|
+
(default_source_head_lines, "default_source_head_lines"),
|
|
202
|
+
(default_source_tail_lines, "default_source_tail_lines"),
|
|
203
|
+
):
|
|
204
|
+
if value is not None and (not isinstance(value, int) or value <= 0):
|
|
205
|
+
raise LaunchError(f"Project config {label} must be a positive integer.")
|
|
206
|
+
if not isinstance(default_context_order, list) or not all(
|
|
207
|
+
isinstance(item, str) for item in default_context_order
|
|
208
|
+
):
|
|
209
|
+
raise LaunchError(
|
|
210
|
+
"Project config default_context_order must be a list of strings."
|
|
211
|
+
)
|
|
212
|
+
if workflow_schema is not None and not isinstance(workflow_schema, str):
|
|
213
|
+
raise LaunchError("Project config workflow_schema must be a string.")
|
|
214
|
+
if project_context is not None and not isinstance(project_context, str):
|
|
215
|
+
raise LaunchError("Project config project_context must be a string.")
|
|
216
|
+
if not isinstance(default_artifact_order, list) or not all(
|
|
217
|
+
isinstance(item, str) for item in default_artifact_order
|
|
218
|
+
):
|
|
219
|
+
raise LaunchError(
|
|
220
|
+
"Project config default_artifact_order must be a list of strings."
|
|
221
|
+
)
|
|
222
|
+
_validate_artifact_order_and_dependencies(
|
|
223
|
+
artifact_rules,
|
|
224
|
+
default_artifact_order=tuple(default_artifact_order),
|
|
225
|
+
)
|
|
226
|
+
return ProjectConfig(
|
|
227
|
+
default_memory_update_mode=cast(
|
|
228
|
+
MemoryUpdateMode,
|
|
229
|
+
default_memory_update_mode,
|
|
230
|
+
),
|
|
231
|
+
default_file_render_mode=cast(FileRenderMode, default_file_render_mode),
|
|
232
|
+
default_save_run_reports=default_save_run_reports,
|
|
233
|
+
default_source_max_chars=cast(int | None, default_source_max_chars),
|
|
234
|
+
default_total_source_max_chars=cast(int | None, default_total_source_max_chars),
|
|
235
|
+
default_source_head_lines=cast(int | None, default_source_head_lines),
|
|
236
|
+
default_source_tail_lines=cast(int | None, default_source_tail_lines),
|
|
237
|
+
default_context_order=tuple(default_context_order),
|
|
238
|
+
workflow_schema=workflow_schema,
|
|
239
|
+
project_context=project_context,
|
|
240
|
+
artifact_rules=artifact_rules,
|
|
241
|
+
default_artifact_order=tuple(default_artifact_order),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _validate_project_config_overrides(data: dict[str, object], path: Path) -> None:
|
|
246
|
+
for key in data:
|
|
247
|
+
if key not in SUPPORTED_PROJECT_CONFIG_KEYS:
|
|
248
|
+
raise LaunchError(f"Unsupported project config key '{key}' in {path}")
|
|
249
|
+
config_version = data.get("config_version")
|
|
250
|
+
if config_version is not None and config_version != 1:
|
|
251
|
+
raise LaunchError(f"Project config key 'config_version' must be 1 in {path}")
|
|
252
|
+
taskledger_dir = data.get("taskledger_dir")
|
|
253
|
+
if taskledger_dir is not None and not isinstance(taskledger_dir, str):
|
|
254
|
+
raise LaunchError(
|
|
255
|
+
f"Project config key 'taskledger_dir' must be a string in {path}"
|
|
256
|
+
)
|
|
257
|
+
artifact_rules = data.get("artifact_rules")
|
|
258
|
+
if artifact_rules is not None:
|
|
259
|
+
if not isinstance(artifact_rules, dict):
|
|
260
|
+
raise LaunchError(
|
|
261
|
+
f"Project config key 'artifact_rules' must be a table in {path}"
|
|
262
|
+
)
|
|
263
|
+
parsed_rules = _artifact_rules_from_mapping(artifact_rules, path=path)
|
|
264
|
+
default_order = data.get("default_artifact_order")
|
|
265
|
+
default_artifact_order = (
|
|
266
|
+
tuple(default_order) if isinstance(default_order, list) else ()
|
|
267
|
+
)
|
|
268
|
+
_validate_artifact_order_and_dependencies(
|
|
269
|
+
parsed_rules,
|
|
270
|
+
default_artifact_order=default_artifact_order,
|
|
271
|
+
path=path,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _artifact_rules_from_overrides(
|
|
276
|
+
raw_rules: object,
|
|
277
|
+
base_rules: tuple[ProjectArtifactRule, ...],
|
|
278
|
+
) -> tuple[ProjectArtifactRule, ...]:
|
|
279
|
+
if raw_rules is None:
|
|
280
|
+
return base_rules
|
|
281
|
+
if not isinstance(raw_rules, dict):
|
|
282
|
+
raise LaunchError("Project config artifact_rules must be a table.")
|
|
283
|
+
return _artifact_rules_from_mapping(raw_rules)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _artifact_rules_from_mapping(
|
|
287
|
+
raw_rules: dict[str, object],
|
|
288
|
+
*,
|
|
289
|
+
path: Path | None = None,
|
|
290
|
+
) -> tuple[ProjectArtifactRule, ...]:
|
|
291
|
+
rules: list[ProjectArtifactRule] = []
|
|
292
|
+
for name, value in raw_rules.items():
|
|
293
|
+
if not isinstance(value, dict):
|
|
294
|
+
raise LaunchError(_artifact_rule_error(name, "must be a table", path=path))
|
|
295
|
+
for key in value:
|
|
296
|
+
if key not in {"depends_on", "memory_ref_field", "label", "description"}:
|
|
297
|
+
raise LaunchError(
|
|
298
|
+
_artifact_rule_error(
|
|
299
|
+
name,
|
|
300
|
+
f"has unsupported key '{key}'",
|
|
301
|
+
path=path,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
depends_on = value.get("depends_on", [])
|
|
305
|
+
if not isinstance(depends_on, list) or not all(
|
|
306
|
+
isinstance(item, str) for item in depends_on
|
|
307
|
+
):
|
|
308
|
+
raise LaunchError(
|
|
309
|
+
_artifact_rule_error(
|
|
310
|
+
name,
|
|
311
|
+
"depends_on must be a list of strings",
|
|
312
|
+
path=path,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
memory_ref_field = value.get("memory_ref_field")
|
|
316
|
+
if memory_ref_field is not None and (
|
|
317
|
+
not isinstance(memory_ref_field, str)
|
|
318
|
+
or memory_ref_field not in ARTIFACT_MEMORY_REF_FIELDS
|
|
319
|
+
):
|
|
320
|
+
allowed = ", ".join(ARTIFACT_MEMORY_REF_FIELDS)
|
|
321
|
+
raise LaunchError(
|
|
322
|
+
_artifact_rule_error(
|
|
323
|
+
name,
|
|
324
|
+
f"memory_ref_field must be one of: {allowed}",
|
|
325
|
+
path=path,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
label = value.get("label")
|
|
329
|
+
if label is not None and not isinstance(label, str):
|
|
330
|
+
raise LaunchError(
|
|
331
|
+
_artifact_rule_error(name, "label must be a string", path=path)
|
|
332
|
+
)
|
|
333
|
+
description = value.get("description")
|
|
334
|
+
if description is not None and not isinstance(description, str):
|
|
335
|
+
raise LaunchError(
|
|
336
|
+
_artifact_rule_error(name, "description must be a string", path=path)
|
|
337
|
+
)
|
|
338
|
+
rules.append(
|
|
339
|
+
ProjectArtifactRule(
|
|
340
|
+
name=name,
|
|
341
|
+
depends_on=tuple(depends_on),
|
|
342
|
+
memory_ref_field=memory_ref_field,
|
|
343
|
+
label=label,
|
|
344
|
+
description=description,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return tuple(rules)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _validate_artifact_order_and_dependencies(
|
|
351
|
+
artifact_rules: tuple[ProjectArtifactRule, ...],
|
|
352
|
+
*,
|
|
353
|
+
default_artifact_order: tuple[str, ...],
|
|
354
|
+
path: Path | None = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
if not artifact_rules:
|
|
357
|
+
return
|
|
358
|
+
names = {rule.name for rule in artifact_rules}
|
|
359
|
+
if len(names) != len(artifact_rules):
|
|
360
|
+
raise LaunchError(
|
|
361
|
+
_project_config_error("Artifact rule names must be unique.", path)
|
|
362
|
+
)
|
|
363
|
+
for rule in artifact_rules:
|
|
364
|
+
for dependency in rule.depends_on:
|
|
365
|
+
if dependency not in names:
|
|
366
|
+
raise LaunchError(
|
|
367
|
+
_project_config_error(
|
|
368
|
+
"Artifact rule "
|
|
369
|
+
f"'{rule.name}' depends on unknown artifact "
|
|
370
|
+
f"'{dependency}'.",
|
|
371
|
+
path,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
if default_artifact_order:
|
|
375
|
+
unknown = [name for name in default_artifact_order if name not in names]
|
|
376
|
+
if unknown:
|
|
377
|
+
raise LaunchError(
|
|
378
|
+
_project_config_error(
|
|
379
|
+
"default_artifact_order references unknown artifacts: "
|
|
380
|
+
+ ", ".join(sorted(unknown)),
|
|
381
|
+
path,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _artifact_rule_error(name: str, message: str, *, path: Path | None) -> str:
|
|
387
|
+
return _project_config_error(f"Artifact rule '{name}' {message}.", path)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _project_config_error(message: str, path: Path | None) -> str:
|
|
391
|
+
if path is None:
|
|
392
|
+
return message
|
|
393
|
+
return f"{message} in {path}"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal, cast
|
|
6
|
+
|
|
7
|
+
from taskledger.errors import LaunchError
|
|
8
|
+
from taskledger.ids import (
|
|
9
|
+
slugify_project_ref as _slugify,
|
|
10
|
+
)
|
|
11
|
+
from taskledger.storage.common import load_json_array as _load_json_array
|
|
12
|
+
from taskledger.storage.common import (
|
|
13
|
+
relative_to_workspace as _relative_to_workspace,
|
|
14
|
+
)
|
|
15
|
+
from taskledger.storage.common import write_json as _write_json
|
|
16
|
+
from taskledger.storage.paths import ProjectPaths
|
|
17
|
+
from taskledger.timeutils import utc_now_iso
|
|
18
|
+
|
|
19
|
+
ProjectRepoKind = Literal["odoo", "enterprise", "custom", "shared", "generic"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True, frozen=True)
|
|
23
|
+
class ProjectRepo:
|
|
24
|
+
name: str
|
|
25
|
+
slug: str
|
|
26
|
+
path: str
|
|
27
|
+
kind: ProjectRepoKind
|
|
28
|
+
branch: str | None
|
|
29
|
+
notes: str | None
|
|
30
|
+
created_at: str
|
|
31
|
+
updated_at: str
|
|
32
|
+
role: Literal["read", "write", "both"] = "read"
|
|
33
|
+
preferred_for_execution: bool = False
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"name": self.name,
|
|
38
|
+
"slug": self.slug,
|
|
39
|
+
"path": self.path,
|
|
40
|
+
"kind": self.kind,
|
|
41
|
+
"branch": self.branch,
|
|
42
|
+
"notes": self.notes,
|
|
43
|
+
"created_at": self.created_at,
|
|
44
|
+
"updated_at": self.updated_at,
|
|
45
|
+
"role": self.role,
|
|
46
|
+
"preferred_for_execution": self.preferred_for_execution,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: dict[str, object]) -> ProjectRepo:
|
|
51
|
+
kind = _string_value(data, "kind")
|
|
52
|
+
if kind not in {"odoo", "enterprise", "custom", "shared", "generic"}:
|
|
53
|
+
raise ValueError(f"Unsupported project repo kind: {kind}")
|
|
54
|
+
role = _optional_string_value(data, "role") or "read"
|
|
55
|
+
if role not in {"read", "write", "both"}:
|
|
56
|
+
raise ValueError(f"Unsupported project repo role: {role}")
|
|
57
|
+
return cls(
|
|
58
|
+
name=_string_value(data, "name"),
|
|
59
|
+
slug=_string_value(data, "slug"),
|
|
60
|
+
path=_string_value(data, "path"),
|
|
61
|
+
kind=cast(ProjectRepoKind, kind),
|
|
62
|
+
branch=_optional_string_value(data, "branch"),
|
|
63
|
+
notes=_optional_string_value(data, "notes"),
|
|
64
|
+
created_at=_string_value(data, "created_at"),
|
|
65
|
+
updated_at=_string_value(data, "updated_at"),
|
|
66
|
+
role=cast(Literal["read", "write", "both"], role),
|
|
67
|
+
preferred_for_execution=bool(data.get("preferred_for_execution", False)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_repos(paths: ProjectPaths) -> list[ProjectRepo]:
|
|
72
|
+
return [
|
|
73
|
+
ProjectRepo.from_dict(item)
|
|
74
|
+
for item in _load_json_array(paths.repo_index_path, "project repo index")
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_repos(paths: ProjectPaths, repos: list[ProjectRepo]) -> None:
|
|
79
|
+
_write_json(paths.repo_index_path, [item.to_dict() for item in repos])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_repo(
|
|
83
|
+
paths: ProjectPaths,
|
|
84
|
+
*,
|
|
85
|
+
name: str,
|
|
86
|
+
path: Path,
|
|
87
|
+
kind: str = "generic",
|
|
88
|
+
branch: str | None = None,
|
|
89
|
+
notes: str | None = None,
|
|
90
|
+
role: str = "read",
|
|
91
|
+
preferred_for_execution: bool = False,
|
|
92
|
+
) -> ProjectRepo:
|
|
93
|
+
if kind not in {"odoo", "enterprise", "custom", "shared", "generic"}:
|
|
94
|
+
raise LaunchError(f"Unsupported project repo kind: {kind}")
|
|
95
|
+
if role not in {"read", "write", "both"}:
|
|
96
|
+
raise LaunchError(f"Unsupported project repo role: {role}")
|
|
97
|
+
if preferred_for_execution and role == "read":
|
|
98
|
+
raise LaunchError(
|
|
99
|
+
"Read-only repos cannot be marked as the default execution repo."
|
|
100
|
+
)
|
|
101
|
+
repos = load_repos(paths)
|
|
102
|
+
slug = _slugify(name)
|
|
103
|
+
_ensure_unique_repo_identity(repos, name=name, slug=slug)
|
|
104
|
+
repo_path = path.expanduser()
|
|
105
|
+
if not repo_path.is_absolute():
|
|
106
|
+
repo_path = paths.workspace_root / repo_path
|
|
107
|
+
repo_path = repo_path.resolve()
|
|
108
|
+
if not repo_path.exists() or not repo_path.is_dir():
|
|
109
|
+
raise LaunchError(f"Project repo path does not exist: {path}")
|
|
110
|
+
now = utc_now_iso()
|
|
111
|
+
repo = ProjectRepo(
|
|
112
|
+
name=name,
|
|
113
|
+
slug=slug,
|
|
114
|
+
path=_relative_to_workspace(paths, repo_path),
|
|
115
|
+
kind=cast(ProjectRepoKind, kind),
|
|
116
|
+
branch=branch,
|
|
117
|
+
notes=notes,
|
|
118
|
+
created_at=now,
|
|
119
|
+
updated_at=now,
|
|
120
|
+
role=cast(Literal["read", "write", "both"], role),
|
|
121
|
+
preferred_for_execution=preferred_for_execution,
|
|
122
|
+
)
|
|
123
|
+
repos.append(repo)
|
|
124
|
+
repos.sort(key=lambda item: item.slug)
|
|
125
|
+
save_repos(
|
|
126
|
+
paths,
|
|
127
|
+
_preferred_repo_state(repos, preferred_slug=repo.slug)
|
|
128
|
+
if preferred_for_execution
|
|
129
|
+
else repos,
|
|
130
|
+
)
|
|
131
|
+
return repo
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_repo(paths: ProjectPaths, ref: str) -> ProjectRepo:
|
|
135
|
+
repos = load_repos(paths)
|
|
136
|
+
normalized_ref = _slugify(ref)
|
|
137
|
+
candidates = [
|
|
138
|
+
item
|
|
139
|
+
for item in repos
|
|
140
|
+
if item.name == ref or item.slug == ref or item.slug == normalized_ref
|
|
141
|
+
]
|
|
142
|
+
if not candidates:
|
|
143
|
+
raise LaunchError(f"Unknown project repo: {ref}")
|
|
144
|
+
if len(candidates) > 1:
|
|
145
|
+
raise LaunchError(f"Ambiguous project repo ref: {ref}")
|
|
146
|
+
return candidates[0]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolve_repo_root(paths: ProjectPaths, ref: str) -> Path:
|
|
150
|
+
repo = resolve_repo(paths, ref)
|
|
151
|
+
candidate = Path(repo.path)
|
|
152
|
+
if not candidate.is_absolute():
|
|
153
|
+
candidate = paths.workspace_root / candidate
|
|
154
|
+
candidate = candidate.resolve()
|
|
155
|
+
if not candidate.exists() or not candidate.is_dir():
|
|
156
|
+
raise LaunchError(f"Project repo path does not exist: {repo.path}")
|
|
157
|
+
return candidate
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def remove_repo(paths: ProjectPaths, ref: str) -> ProjectRepo:
|
|
161
|
+
repos = load_repos(paths)
|
|
162
|
+
repo = resolve_repo(paths, ref)
|
|
163
|
+
remaining = [item for item in repos if item.slug != repo.slug]
|
|
164
|
+
save_repos(paths, remaining)
|
|
165
|
+
return repo
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def set_repo_role(paths: ProjectPaths, ref: str, *, role: str) -> ProjectRepo:
|
|
169
|
+
if role not in {"read", "write", "both"}:
|
|
170
|
+
raise LaunchError(f"Unsupported project repo role: {role}")
|
|
171
|
+
repos = load_repos(paths)
|
|
172
|
+
repo = resolve_repo(paths, ref)
|
|
173
|
+
if role == "read" and repo.preferred_for_execution:
|
|
174
|
+
raise LaunchError(
|
|
175
|
+
"Default execution repo cannot be changed to read-only. "
|
|
176
|
+
"Set a different default first or clear the default execution repo."
|
|
177
|
+
)
|
|
178
|
+
updated = replace(
|
|
179
|
+
repo,
|
|
180
|
+
role=cast(Literal["read", "write", "both"], role),
|
|
181
|
+
updated_at=utc_now_iso(),
|
|
182
|
+
)
|
|
183
|
+
for index, item in enumerate(repos):
|
|
184
|
+
if item.slug == repo.slug:
|
|
185
|
+
repos[index] = updated
|
|
186
|
+
break
|
|
187
|
+
save_repos(paths, repos)
|
|
188
|
+
return updated
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def set_default_execution_repo(paths: ProjectPaths, ref: str) -> ProjectRepo:
|
|
192
|
+
repos = load_repos(paths)
|
|
193
|
+
repo = resolve_repo(paths, ref)
|
|
194
|
+
if repo.role == "read":
|
|
195
|
+
raise LaunchError(
|
|
196
|
+
f"Project repo {repo.name} is read-only and cannot be the default "
|
|
197
|
+
"execution repo."
|
|
198
|
+
)
|
|
199
|
+
updated_repos = _preferred_repo_state(repos, preferred_slug=repo.slug)
|
|
200
|
+
save_repos(paths, updated_repos)
|
|
201
|
+
return next(item for item in updated_repos if item.slug == repo.slug)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def clear_default_execution_repo(paths: ProjectPaths) -> list[ProjectRepo]:
|
|
205
|
+
repos = load_repos(paths)
|
|
206
|
+
updated_repos = _preferred_repo_state(repos, preferred_slug=None)
|
|
207
|
+
save_repos(paths, updated_repos)
|
|
208
|
+
return updated_repos
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ensure_unique_repo_identity(
|
|
212
|
+
repos: list[ProjectRepo], *, name: str, slug: str
|
|
213
|
+
) -> None:
|
|
214
|
+
for item in repos:
|
|
215
|
+
if item.name == name:
|
|
216
|
+
raise LaunchError(f"Project repo already exists with name: {name}")
|
|
217
|
+
if item.slug == slug:
|
|
218
|
+
raise LaunchError(f"Project repo already exists with slug: {slug}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _preferred_repo_state(
|
|
222
|
+
repos: list[ProjectRepo], *, preferred_slug: str | None
|
|
223
|
+
) -> list[ProjectRepo]:
|
|
224
|
+
changed = False
|
|
225
|
+
updated: list[ProjectRepo] = []
|
|
226
|
+
timestamp = utc_now_iso()
|
|
227
|
+
for item in repos:
|
|
228
|
+
preferred = item.slug == preferred_slug if preferred_slug is not None else False
|
|
229
|
+
if item.preferred_for_execution == preferred:
|
|
230
|
+
updated.append(item)
|
|
231
|
+
continue
|
|
232
|
+
updated.append(
|
|
233
|
+
replace(
|
|
234
|
+
item,
|
|
235
|
+
preferred_for_execution=preferred,
|
|
236
|
+
updated_at=timestamp,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
changed = True
|
|
240
|
+
return updated if changed else repos
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _string_value(data: dict[str, object], key: str) -> str:
|
|
244
|
+
value = data.get(key)
|
|
245
|
+
if not isinstance(value, str) or not value:
|
|
246
|
+
raise ValueError(f"Missing or invalid {key}")
|
|
247
|
+
return value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _optional_string_value(data: dict[str, object], key: str) -> str | None:
|
|
251
|
+
value = data.get(key)
|
|
252
|
+
if value is None:
|
|
253
|
+
return None
|
|
254
|
+
if not isinstance(value, str):
|
|
255
|
+
raise ValueError(f"Invalid {key}")
|
|
256
|
+
return value
|