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,542 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
|
|
6
|
+
from taskledger.domain.models import TaskLock, TaskRecord, TaskRunRecord
|
|
7
|
+
from taskledger.domain.states import (
|
|
8
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
9
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
10
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Decision:
|
|
16
|
+
allowed: bool
|
|
17
|
+
code: str
|
|
18
|
+
message: str
|
|
19
|
+
blocking_refs: tuple[str, ...] = ()
|
|
20
|
+
details: dict[str, object] = field(default_factory=dict)
|
|
21
|
+
exit_code: int = 0
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def ok(self) -> bool:
|
|
25
|
+
return self.allowed
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def reason(self) -> str:
|
|
29
|
+
return self.message
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PolicyDecision = Decision
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PolicyContext:
|
|
37
|
+
task: TaskRecord
|
|
38
|
+
lock: TaskLock | None
|
|
39
|
+
run: TaskRunRecord | None
|
|
40
|
+
active_stage: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def derive_active_stage(
|
|
44
|
+
lock: TaskLock | None,
|
|
45
|
+
runs: Iterable[TaskRunRecord],
|
|
46
|
+
) -> str | None:
|
|
47
|
+
if lock is None:
|
|
48
|
+
return None
|
|
49
|
+
for run in runs:
|
|
50
|
+
if (
|
|
51
|
+
run.run_id == lock.run_id
|
|
52
|
+
and run.run_type == lock.run_type
|
|
53
|
+
and run.status == "running"
|
|
54
|
+
):
|
|
55
|
+
return run.run_type
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_policy_context(
|
|
60
|
+
task: TaskRecord,
|
|
61
|
+
lock: TaskLock | None,
|
|
62
|
+
*,
|
|
63
|
+
run: TaskRunRecord | None = None,
|
|
64
|
+
) -> PolicyContext:
|
|
65
|
+
active_stage = (
|
|
66
|
+
(derive_active_stage(lock, (run,)) if run is not None else lock.run_type)
|
|
67
|
+
if lock is not None
|
|
68
|
+
else None
|
|
69
|
+
)
|
|
70
|
+
return PolicyContext(task=task, lock=lock, run=run, active_stage=active_stage)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _policy_decision(ok: bool, reason: str, exit_code: int) -> Decision:
|
|
74
|
+
return Decision(
|
|
75
|
+
allowed=ok,
|
|
76
|
+
code="OK" if ok else "POLICY_DENIED",
|
|
77
|
+
message=reason,
|
|
78
|
+
exit_code=exit_code,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _with_denial_code(decision: Decision, code: str) -> Decision:
|
|
83
|
+
if decision.allowed:
|
|
84
|
+
return decision
|
|
85
|
+
return replace(decision, code=code)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def can_start_planning(task: TaskRecord, ledger: object) -> Decision:
|
|
89
|
+
lock = getattr(ledger, "lock", None)
|
|
90
|
+
allowed = task.status_stage in {"draft", "plan_review"} and lock is None
|
|
91
|
+
return Decision(
|
|
92
|
+
allowed=allowed,
|
|
93
|
+
code="OK" if allowed else "TASK_NOT_PLANNABLE",
|
|
94
|
+
message=(
|
|
95
|
+
"Planning can start."
|
|
96
|
+
if allowed
|
|
97
|
+
else "Planning requires draft or plan_review state without an active lock."
|
|
98
|
+
),
|
|
99
|
+
exit_code=0 if allowed else EXIT_CODE_INVALID_TRANSITION,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def can_propose_plan(task: TaskRecord, run: TaskRunRecord, ledger: object) -> Decision:
|
|
104
|
+
return _with_denial_code(
|
|
105
|
+
plan_propose_decision(task, getattr(ledger, "lock", None), run=run),
|
|
106
|
+
"RUN_LOCK_MISMATCH",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def can_approve_plan(task: TaskRecord, plan: object, ledger: object) -> Decision:
|
|
111
|
+
return _with_denial_code(
|
|
112
|
+
plan_approve_decision(task, getattr(ledger, "lock", None)),
|
|
113
|
+
"TASK_NOT_IN_REVIEW",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def can_start_implementation(task: TaskRecord, ledger: object) -> Decision:
|
|
118
|
+
lock = getattr(ledger, "lock", None)
|
|
119
|
+
accepted_plan = getattr(ledger, "accepted_plan", None)
|
|
120
|
+
allowed = (
|
|
121
|
+
task.status_stage in {"approved", "failed_validation"}
|
|
122
|
+
and accepted_plan is not None
|
|
123
|
+
and lock is None
|
|
124
|
+
)
|
|
125
|
+
return Decision(
|
|
126
|
+
allowed=allowed,
|
|
127
|
+
code="OK" if allowed else "TASK_NOT_APPROVED",
|
|
128
|
+
message=(
|
|
129
|
+
"Implementation can start."
|
|
130
|
+
if allowed
|
|
131
|
+
else (
|
|
132
|
+
"Implementation requires approved state, an accepted plan, "
|
|
133
|
+
"and no active lock."
|
|
134
|
+
)
|
|
135
|
+
),
|
|
136
|
+
exit_code=0 if allowed else EXIT_CODE_APPROVAL_REQUIRED,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def can_finish_implementation(
|
|
141
|
+
task: TaskRecord,
|
|
142
|
+
run: TaskRunRecord,
|
|
143
|
+
ledger: object,
|
|
144
|
+
) -> Decision:
|
|
145
|
+
return _with_denial_code(
|
|
146
|
+
implementation_mutation_decision(
|
|
147
|
+
task,
|
|
148
|
+
getattr(ledger, "lock", None),
|
|
149
|
+
run=run,
|
|
150
|
+
action="finish implementation",
|
|
151
|
+
),
|
|
152
|
+
"RUN_LOCK_MISMATCH",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def can_start_validation(task: TaskRecord, ledger: object) -> Decision:
|
|
157
|
+
lock = getattr(ledger, "lock", None)
|
|
158
|
+
allowed = task.status_stage == "implemented" and lock is None
|
|
159
|
+
return Decision(
|
|
160
|
+
allowed=allowed,
|
|
161
|
+
code="OK" if allowed else "TASK_NOT_IMPLEMENTED",
|
|
162
|
+
message=(
|
|
163
|
+
"Validation can start."
|
|
164
|
+
if allowed
|
|
165
|
+
else "Validation requires implemented state and no active lock."
|
|
166
|
+
),
|
|
167
|
+
exit_code=0 if allowed else EXIT_CODE_INVALID_TRANSITION,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def can_finish_validation(
|
|
172
|
+
task: TaskRecord,
|
|
173
|
+
run: TaskRunRecord,
|
|
174
|
+
ledger: object,
|
|
175
|
+
) -> Decision:
|
|
176
|
+
return _with_denial_code(
|
|
177
|
+
validation_check_decision(task, getattr(ledger, "lock", None), run=run),
|
|
178
|
+
"RUN_LOCK_MISMATCH",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def can_mark_todo_done(
|
|
183
|
+
task: TaskRecord,
|
|
184
|
+
todo: object,
|
|
185
|
+
actor: object,
|
|
186
|
+
ledger: object,
|
|
187
|
+
) -> Decision:
|
|
188
|
+
actor_role = getattr(actor, "actor_type", "user")
|
|
189
|
+
return _with_denial_code(
|
|
190
|
+
todo_toggle_decision(
|
|
191
|
+
task,
|
|
192
|
+
getattr(ledger, "lock", None),
|
|
193
|
+
actor_role="user" if actor_role == "user" else "implementer",
|
|
194
|
+
),
|
|
195
|
+
"TODO_NOT_MUTABLE",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def metadata_edit_decision(task: TaskRecord, lock: TaskLock | None) -> Decision:
|
|
200
|
+
ctx = build_policy_context(task, lock)
|
|
201
|
+
if ctx.task.status_stage not in {"draft", "plan_review", "approved"}:
|
|
202
|
+
return _policy_decision(
|
|
203
|
+
False,
|
|
204
|
+
"Task metadata can only be edited in draft, plan_review, or approved.",
|
|
205
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
206
|
+
)
|
|
207
|
+
if ctx.active_stage is not None:
|
|
208
|
+
return _policy_decision(
|
|
209
|
+
False,
|
|
210
|
+
(
|
|
211
|
+
f"Task {ctx.task.id} is locked for {ctx.active_stage}. "
|
|
212
|
+
"Break the lock explicitly before editing metadata."
|
|
213
|
+
),
|
|
214
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
215
|
+
)
|
|
216
|
+
return _policy_decision(True, "Task metadata can be edited.", 0)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def todo_add_decision(
|
|
220
|
+
task: TaskRecord,
|
|
221
|
+
lock: TaskLock | None,
|
|
222
|
+
*,
|
|
223
|
+
actor_role: str,
|
|
224
|
+
) -> Decision:
|
|
225
|
+
ctx = build_policy_context(task, lock)
|
|
226
|
+
if ctx.active_stage == "planning":
|
|
227
|
+
return _active_stage_lock_decision(
|
|
228
|
+
ctx,
|
|
229
|
+
expected_stage="planning",
|
|
230
|
+
action="add todos during planning",
|
|
231
|
+
)
|
|
232
|
+
if ctx.active_stage == "implementation" and ctx.task.status_stage in {
|
|
233
|
+
"approved",
|
|
234
|
+
"implementing",
|
|
235
|
+
"failed_validation",
|
|
236
|
+
}:
|
|
237
|
+
return _active_stage_lock_decision(
|
|
238
|
+
ctx,
|
|
239
|
+
expected_stage="implementing",
|
|
240
|
+
action="add todos during implementation",
|
|
241
|
+
)
|
|
242
|
+
if ctx.active_stage is not None or ctx.task.status_stage not in {
|
|
243
|
+
"draft",
|
|
244
|
+
"plan_review",
|
|
245
|
+
"approved",
|
|
246
|
+
}:
|
|
247
|
+
return _policy_decision(
|
|
248
|
+
False,
|
|
249
|
+
"Todos can only be added before implementation starts.",
|
|
250
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
251
|
+
)
|
|
252
|
+
return _policy_decision(True, f"{actor_role} can add a todo.", 0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def todo_toggle_decision(
|
|
256
|
+
task: TaskRecord,
|
|
257
|
+
lock: TaskLock | None,
|
|
258
|
+
*,
|
|
259
|
+
actor_role: str,
|
|
260
|
+
) -> Decision:
|
|
261
|
+
ctx = build_policy_context(task, lock)
|
|
262
|
+
if ctx.active_stage == "implementation":
|
|
263
|
+
return _active_stage_lock_decision(
|
|
264
|
+
ctx,
|
|
265
|
+
expected_stage="implementing",
|
|
266
|
+
action="toggle todos during implementation",
|
|
267
|
+
)
|
|
268
|
+
if ctx.active_stage == "validation" or ctx.task.status_stage in {
|
|
269
|
+
"cancelled",
|
|
270
|
+
"done",
|
|
271
|
+
}:
|
|
272
|
+
return _policy_decision(
|
|
273
|
+
False,
|
|
274
|
+
f"Todos cannot be toggled while the task is {ctx.task.status_stage}.",
|
|
275
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
276
|
+
)
|
|
277
|
+
if actor_role != "user":
|
|
278
|
+
return _policy_decision(
|
|
279
|
+
False,
|
|
280
|
+
"Only the user may toggle todos outside implementation.",
|
|
281
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
282
|
+
)
|
|
283
|
+
return _policy_decision(True, f"{actor_role} can toggle todos.", 0)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def question_add_decision(
|
|
287
|
+
task: TaskRecord,
|
|
288
|
+
lock: TaskLock | None,
|
|
289
|
+
*,
|
|
290
|
+
actor_role: str,
|
|
291
|
+
) -> Decision:
|
|
292
|
+
ctx = build_policy_context(task, lock)
|
|
293
|
+
if ctx.active_stage not in {None, "planning"} or ctx.task.status_stage not in {
|
|
294
|
+
"draft",
|
|
295
|
+
"plan_review",
|
|
296
|
+
}:
|
|
297
|
+
return _policy_decision(
|
|
298
|
+
False,
|
|
299
|
+
"Questions can only be added during planning or plan review.",
|
|
300
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
301
|
+
)
|
|
302
|
+
if ctx.active_stage == "planning" and actor_role != "user":
|
|
303
|
+
return _active_stage_lock_decision(
|
|
304
|
+
ctx,
|
|
305
|
+
expected_stage="planning",
|
|
306
|
+
action="add questions during planning",
|
|
307
|
+
)
|
|
308
|
+
return _policy_decision(True, f"{actor_role} can add a question.", 0)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def question_mutation_decision(
|
|
312
|
+
task: TaskRecord,
|
|
313
|
+
lock: TaskLock | None,
|
|
314
|
+
*,
|
|
315
|
+
actor_role: str,
|
|
316
|
+
) -> Decision:
|
|
317
|
+
ctx = build_policy_context(task, lock)
|
|
318
|
+
if ctx.active_stage not in {None, "planning"} or ctx.task.status_stage not in {
|
|
319
|
+
"draft",
|
|
320
|
+
"plan_review",
|
|
321
|
+
}:
|
|
322
|
+
return _policy_decision(
|
|
323
|
+
False,
|
|
324
|
+
"Questions can only be updated during planning or plan review.",
|
|
325
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
326
|
+
)
|
|
327
|
+
if ctx.active_stage == "planning" and actor_role != "user":
|
|
328
|
+
return _active_stage_lock_decision(
|
|
329
|
+
ctx,
|
|
330
|
+
expected_stage="planning",
|
|
331
|
+
action="update questions during planning",
|
|
332
|
+
)
|
|
333
|
+
return _policy_decision(True, f"{actor_role} can update the question.", 0)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def plan_propose_decision(
|
|
337
|
+
task: TaskRecord,
|
|
338
|
+
lock: TaskLock | None,
|
|
339
|
+
*,
|
|
340
|
+
run: TaskRunRecord | None,
|
|
341
|
+
) -> Decision:
|
|
342
|
+
ctx = build_policy_context(task, lock, run=run)
|
|
343
|
+
if (
|
|
344
|
+
ctx.task.status_stage not in {"draft", "plan_review"}
|
|
345
|
+
or ctx.active_stage != "planning"
|
|
346
|
+
):
|
|
347
|
+
return _policy_decision(
|
|
348
|
+
False,
|
|
349
|
+
"Plan proposals require active planning.",
|
|
350
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
351
|
+
)
|
|
352
|
+
if ctx.run is None or ctx.run.run_type != "planning" or ctx.run.status != "running":
|
|
353
|
+
return _policy_decision(
|
|
354
|
+
False,
|
|
355
|
+
"Plan proposals require an active planning run.",
|
|
356
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
357
|
+
)
|
|
358
|
+
return _active_stage_lock_decision(
|
|
359
|
+
ctx,
|
|
360
|
+
expected_stage="planning",
|
|
361
|
+
action="propose a plan",
|
|
362
|
+
expected_run_id=ctx.run.run_id,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def plan_command_decision(
|
|
367
|
+
task: TaskRecord,
|
|
368
|
+
lock: TaskLock | None,
|
|
369
|
+
*,
|
|
370
|
+
run: TaskRunRecord | None,
|
|
371
|
+
) -> Decision:
|
|
372
|
+
ctx = build_policy_context(task, lock, run=run)
|
|
373
|
+
if (
|
|
374
|
+
ctx.task.status_stage not in {"draft", "plan_review"}
|
|
375
|
+
or ctx.active_stage != "planning"
|
|
376
|
+
):
|
|
377
|
+
return _policy_decision(
|
|
378
|
+
False,
|
|
379
|
+
"Plan commands require active planning.",
|
|
380
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
381
|
+
)
|
|
382
|
+
if ctx.run is None or ctx.run.run_type != "planning" or ctx.run.status != "running":
|
|
383
|
+
return _policy_decision(
|
|
384
|
+
False,
|
|
385
|
+
"Plan commands require an active planning run.",
|
|
386
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
387
|
+
)
|
|
388
|
+
return _active_stage_lock_decision(
|
|
389
|
+
ctx,
|
|
390
|
+
expected_stage="planning",
|
|
391
|
+
action="run a planning command",
|
|
392
|
+
expected_run_id=ctx.run.run_id,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def plan_approve_decision(task: TaskRecord, lock: TaskLock | None) -> Decision:
|
|
397
|
+
ctx = build_policy_context(task, lock)
|
|
398
|
+
if ctx.task.status_stage != "plan_review":
|
|
399
|
+
return _policy_decision(
|
|
400
|
+
False,
|
|
401
|
+
"Plan approval requires plan_review state.",
|
|
402
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
403
|
+
)
|
|
404
|
+
if ctx.active_stage is not None:
|
|
405
|
+
return _policy_decision(
|
|
406
|
+
False,
|
|
407
|
+
(
|
|
408
|
+
f"Task {ctx.task.id} still has a {ctx.active_stage} lock. "
|
|
409
|
+
"Break it before plan review actions."
|
|
410
|
+
),
|
|
411
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
412
|
+
)
|
|
413
|
+
return _policy_decision(True, "Plan can be approved.", 0)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def plan_revise_decision(task: TaskRecord, lock: TaskLock | None) -> Decision:
|
|
417
|
+
ctx = build_policy_context(task, lock)
|
|
418
|
+
if ctx.task.status_stage != "plan_review":
|
|
419
|
+
return _policy_decision(
|
|
420
|
+
False,
|
|
421
|
+
"Plan revision requires plan_review state.",
|
|
422
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
423
|
+
)
|
|
424
|
+
if ctx.active_stage is not None:
|
|
425
|
+
return _policy_decision(
|
|
426
|
+
False,
|
|
427
|
+
(
|
|
428
|
+
f"Task {ctx.task.id} still has a {ctx.active_stage} lock. "
|
|
429
|
+
"Break it before revising the plan."
|
|
430
|
+
),
|
|
431
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
432
|
+
)
|
|
433
|
+
return _policy_decision(True, "Plan can be revised.", 0)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def implementation_mutation_decision(
|
|
437
|
+
task: TaskRecord,
|
|
438
|
+
lock: TaskLock | None,
|
|
439
|
+
*,
|
|
440
|
+
run: TaskRunRecord | None,
|
|
441
|
+
action: str,
|
|
442
|
+
) -> Decision:
|
|
443
|
+
ctx = build_policy_context(task, lock, run=run)
|
|
444
|
+
if (
|
|
445
|
+
ctx.task.status_stage not in {"approved", "failed_validation", "implementing"}
|
|
446
|
+
or ctx.active_stage != "implementation"
|
|
447
|
+
):
|
|
448
|
+
return _policy_decision(
|
|
449
|
+
False,
|
|
450
|
+
f"{action} requires active implementation.",
|
|
451
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
452
|
+
)
|
|
453
|
+
if (
|
|
454
|
+
ctx.run is None
|
|
455
|
+
or ctx.run.run_type != "implementation"
|
|
456
|
+
or ctx.run.status != "running"
|
|
457
|
+
):
|
|
458
|
+
return _policy_decision(
|
|
459
|
+
False,
|
|
460
|
+
f"{action} requires an active implementation run.",
|
|
461
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
462
|
+
)
|
|
463
|
+
return _active_stage_lock_decision(
|
|
464
|
+
ctx,
|
|
465
|
+
expected_stage="implementing",
|
|
466
|
+
action=action,
|
|
467
|
+
expected_run_id=ctx.run.run_id,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def validation_check_decision(
|
|
472
|
+
task: TaskRecord,
|
|
473
|
+
lock: TaskLock | None,
|
|
474
|
+
*,
|
|
475
|
+
run: TaskRunRecord | None,
|
|
476
|
+
) -> Decision:
|
|
477
|
+
ctx = build_policy_context(task, lock, run=run)
|
|
478
|
+
if ctx.task.status_stage != "implemented" or ctx.active_stage != "validation":
|
|
479
|
+
return _policy_decision(
|
|
480
|
+
False,
|
|
481
|
+
"Validation checks require active validation.",
|
|
482
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
483
|
+
)
|
|
484
|
+
if (
|
|
485
|
+
ctx.run is None
|
|
486
|
+
or ctx.run.run_type != "validation"
|
|
487
|
+
or ctx.run.status != "running"
|
|
488
|
+
):
|
|
489
|
+
return _policy_decision(
|
|
490
|
+
False,
|
|
491
|
+
"Validation checks require an active validation run.",
|
|
492
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
493
|
+
)
|
|
494
|
+
return _active_stage_lock_decision(
|
|
495
|
+
ctx,
|
|
496
|
+
expected_stage="validating",
|
|
497
|
+
action="record validation checks",
|
|
498
|
+
expected_run_id=ctx.run.run_id,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _active_stage_lock_decision(
|
|
503
|
+
ctx: PolicyContext,
|
|
504
|
+
*,
|
|
505
|
+
expected_stage: str,
|
|
506
|
+
action: str,
|
|
507
|
+
expected_run_id: str | None = None,
|
|
508
|
+
) -> Decision:
|
|
509
|
+
if ctx.lock is None:
|
|
510
|
+
return _policy_decision(
|
|
511
|
+
False,
|
|
512
|
+
f"Task {ctx.task.id} needs an active {expected_stage} lock to {action}.",
|
|
513
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
514
|
+
)
|
|
515
|
+
expected_run_type = {
|
|
516
|
+
"planning": "planning",
|
|
517
|
+
"implementing": "implementation",
|
|
518
|
+
"validating": "validation",
|
|
519
|
+
}[expected_stage]
|
|
520
|
+
if ctx.lock.run_type != expected_run_type:
|
|
521
|
+
return _policy_decision(
|
|
522
|
+
False,
|
|
523
|
+
f"Task {ctx.task.id} is locked for {ctx.lock.stage}, not {expected_stage}.",
|
|
524
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
525
|
+
)
|
|
526
|
+
if expected_run_id is not None and ctx.lock.run_id != expected_run_id:
|
|
527
|
+
return _policy_decision(
|
|
528
|
+
False,
|
|
529
|
+
(
|
|
530
|
+
f"Task {ctx.task.id} has a {expected_stage} lock for "
|
|
531
|
+
f"{ctx.lock.run_id}, "
|
|
532
|
+
f"not {expected_run_id}."
|
|
533
|
+
),
|
|
534
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
535
|
+
)
|
|
536
|
+
return _policy_decision(True, f"Task can {action}.", 0)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def require_known_actor_role(actor_role: str) -> str:
|
|
540
|
+
if actor_role not in {"planner", "implementer", "user"}:
|
|
541
|
+
raise ValueError(f"Unsupported actor role: {actor_role}")
|
|
542
|
+
return actor_role
|