oc_chat
Loading...
Searching...
No Matches
domain.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/domain.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_domain_queue.py and test_domain_plan.py"
6"""
7Domain-level registry mutation API.
8
9Purpose
10-------
11Expose typed queue and plan-lifecycle functions that emit canonical
12operation-log rows through :class:`oc_chat.registry.Registry`.
13
14Responsibilities
15----------------
16- Build versioned payloads for the queue and plan operation catalogs.
17- Enforce domain-level preconditions that belong above the seam.
18- Keep callers state-version and operation-uuid unaware.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - lifecycle events
25 L3 - semantic details
26 L4 - deep tracing
27
28Contracts
29---------
30- Every public mutating function calls ``Registry.execute_domain_command``.
31- This module performs no SQL, git, subprocess, filesystem, or clock work.
32- Payload version is always 1 for the v0.2.3 operation catalog.
33
34Dependencies
35------------
36- typing (literal operation arguments)
37- collections.abc (the hub message payload mapping)
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.registry.api (Registry facade)
40- oc_chat.registry.backends.base (OperationLogRow return type)
41"""
42from __future__ import annotations
43
44from collections.abc import Mapping
45from typing import Literal
46
47from oc_diagnostics import _dbg
48
49from oc_chat.registry.api import Registry
50from oc_chat.registry.backends.base import OperationLogRow
51
52
53_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
54
55PlanStatus = Literal[
56 "draft",
57 "pending",
58 "implementing",
59 "ready_for_merge",
60 "integrating",
61 "completed",
62 "partial",
63 "failed",
64 "superseded",
65]
66ActorType = Literal["human_operator", "orchestrator_agent", "worker_agent"]
67QueueEnqueueReason = Literal[
68 "initial enqueue",
69 "promoted_from_partial",
70 "restored_from_aborted_round",
71]
72QueueDequeueReason = Literal[
73 "transition_to_failed",
74 "transition_to_integrating",
75 "operator_dequeue",
76 # Plan 259 P4, closing a plan-258 residual. The set had no member
77 # meaning "handed to an instance": ``operator_dequeue`` says WHO acted
78 # rather than why, and the other two rewrite the plan's status, which a
79 # dispatch must not. Recording a dispatch honestly needs this word.
80 "dispatched",
81]
82PlanStatusSource = Literal["worker_self", "orchestrator", "round_lifecycle"]
83
84QUEUE_ENQUEUE_REASONS = {
85 "initial enqueue",
86 "promoted_from_partial",
87 "restored_from_aborted_round",
88}
89QUEUE_DEQUEUE_REASONS = {
90 "transition_to_failed",
91 "transition_to_integrating",
92 "operator_dequeue",
93 "dispatched",
94}
95PLAN_STATUS_SOURCES = {"worker_self", "orchestrator", "round_lifecycle"}
96PLAN_STATUSES = {
97 "draft",
98 "pending",
99 "implementing",
100 "ready_for_merge",
101 "integrating",
102 "completed",
103 "partial",
104 "failed",
105 "superseded",
106}
107
108
109def queue_enqueue(
110 registry: Registry,
111 *,
112 plan_id: str,
113 slot: str,
114 position: int,
115 reason: QueueEnqueueReason,
116 actor_slot: str,
117 actor_type: ActorType,
118 command: str | None = None,
119) -> OperationLogRow:
120 """Emit a ``queue.enqueue`` operation."""
121
122 if reason not in QUEUE_ENQUEUE_REASONS:
123 raise ValueError(f"invalid queue enqueue reason: {reason}")
124 _require_positive_position(position)
125 return registry.execute_domain_command(
126 "queue.enqueue",
127 actor_slot,
128 {
129 "version": 1,
130 "plan_id": plan_id,
131 "slot": slot,
132 "position": position,
133 "reason": reason,
134 },
135 actor_type=actor_type,
136 command=command,
137 related_plan_id=plan_id,
138 )
139
140
141def queue_move(
142 registry: Registry,
143 *,
144 plan_id: str,
145 from_slot: str,
146 to_slot: str,
147 from_position: int,
148 to_position: int,
149 reason: str,
150 actor_slot: str,
151 actor_type: ActorType,
152 command: str | None = None,
153) -> OperationLogRow:
154 """Emit a ``queue.move`` operation."""
155
156 _require_positive_position(from_position)
157 _require_positive_position(to_position)
158 if not reason.strip():
159 raise ValueError("queue move reason is required")
160 return registry.execute_domain_command(
161 "queue.move",
162 actor_slot,
163 {
164 "version": 1,
165 "plan_id": plan_id,
166 "from_slot": from_slot,
167 "to_slot": to_slot,
168 "from_position": from_position,
169 "to_position": to_position,
170 "reason": reason,
171 },
172 actor_type=actor_type,
173 command=command,
174 related_plan_id=plan_id,
175 )
176
177
178def queue_dequeue(
179 registry: Registry,
180 *,
181 plan_id: str,
182 slot: str,
183 position: int,
184 reason: QueueDequeueReason,
185 actor_slot: str,
186 actor_type: ActorType,
187 command: str | None = None,
188) -> OperationLogRow:
189 """Emit a ``queue.dequeue`` operation."""
190
191 if reason not in QUEUE_DEQUEUE_REASONS:
192 raise ValueError(f"invalid queue dequeue reason: {reason}")
193 _require_positive_position(position)
194 return registry.execute_domain_command(
195 "queue.dequeue",
196 actor_slot,
197 {
198 "version": 1,
199 "plan_id": plan_id,
200 "slot": slot,
201 "position": position,
202 "reason": reason,
203 },
204 actor_type=actor_type,
205 command=command,
206 related_plan_id=plan_id,
207 )
208
209
210def queue_complete(
211 registry: Registry,
212 *,
213 plan_id: str,
214 round_no: int,
215 merge_commit_sha: str,
216 actor_slot: str,
217 actor_type: ActorType,
218 command: str | None = None,
219) -> OperationLogRow:
220 """Emit a ``queue.complete`` operation."""
221
222 if round_no <= 0:
223 raise ValueError("round_no must be positive")
224 return registry.execute_domain_command(
225 "queue.complete",
226 actor_slot,
227 {
228 "version": 1,
229 "plan_id": plan_id,
230 "round_no": round_no,
231 "merge_commit_sha": merge_commit_sha,
232 },
233 actor_type=actor_type,
234 command=command,
235 related_plan_id=plan_id,
236 related_round=round_no,
237 )
238
239
240def plan_create(
241 registry: Registry,
242 *,
243 plan_id: str,
244 plan_number: int,
245 title: str,
246 body_path: str,
247 body_sha256: str,
248 body_observed_at: str | None = None,
249 plan_type: str | None = None,
250 model_override: str | None = None,
251 thinking_override: str | None = None,
252 session_override: str | None = None,
253 actor_slot: str,
254 actor_type: ActorType,
255 command: str | None = None,
256) -> OperationLogRow:
257 """Emit a ``plan.create`` operation."""
258
259 if plan_number <= 0:
260 raise ValueError("plan_number must be positive")
261 return registry.execute_domain_command(
262 "plan.create",
263 actor_slot,
264 {
265 "version": 1,
266 "plan_id": plan_id,
267 "plan_number": plan_number,
268 "title": title,
269 "plan_type": plan_type,
270 "body_path": body_path,
271 "body_sha256": body_sha256,
272 "body_observed_at": body_observed_at,
273 "model_override": model_override,
274 "thinking_override": thinking_override,
275 "session_override": session_override,
276 },
277 actor_type=actor_type,
278 command=command,
279 related_plan_id=plan_id,
280 )
281
282
283def plan_set_status(
284 registry: Registry,
285 *,
286 plan_id: str,
287 from_status: PlanStatus,
288 to_status: PlanStatus,
289 source: PlanStatusSource,
290 note: str | None = None,
291 actor_slot: str,
292 actor_type: ActorType,
293 command: str | None = None,
294) -> OperationLogRow:
295 """Emit a ``plan.set_status`` operation."""
296
297 _require_status(from_status, "from_status")
298 _require_status(to_status, "to_status")
299 if source not in PLAN_STATUS_SOURCES:
300 raise ValueError(f"invalid plan status source: {source}")
301 if to_status in {"partial", "failed"} and not (note and note.strip()):
302 raise ValueError("note is required for partial and failed status transitions")
303 return registry.execute_domain_command(
304 "plan.set_status",
305 actor_slot,
306 {
307 "version": 1,
308 "plan_id": plan_id,
309 "from_status": from_status,
310 "to_status": to_status,
311 "source": source,
312 "note": note,
313 },
314 actor_type=actor_type,
315 command=command,
316 related_plan_id=plan_id,
317 )
318
319
320def plan_assign(
321 registry: Registry,
322 *,
323 plan_id: str,
324 from_slot: str | None,
325 to_slot: str,
326 branch: str,
327 worktree: str,
328 queue_position: int,
329 actor_slot: str,
330 actor_type: ActorType,
331 command: str | None = None,
332) -> OperationLogRow:
333 """Emit a ``plan.assign`` operation."""
334
335 _require_positive_position(queue_position)
336 return registry.execute_domain_command(
337 "plan.assign",
338 actor_slot,
339 {
340 "version": 1,
341 "plan_id": plan_id,
342 "from_slot": from_slot,
343 "to_slot": to_slot,
344 "branch": branch,
345 "worktree": worktree,
346 "queue_position": queue_position,
347 },
348 actor_type=actor_type,
349 command=command,
350 related_plan_id=plan_id,
351 )
352
353
354def plan_supersede(
355 registry: Registry,
356 *,
357 plan_id: str,
358 superseded_by_plan_id: str,
359 reason: str,
360 actor_slot: str,
361 actor_type: ActorType,
362 command: str | None = None,
363) -> OperationLogRow:
364 """Emit a ``plan.supersede`` operation."""
365
366 if not reason.strip():
367 raise ValueError("supersede reason is required")
368 return registry.execute_domain_command(
369 "plan.supersede",
370 actor_slot,
371 {
372 "version": 1,
373 "plan_id": plan_id,
374 "superseded_by_plan_id": superseded_by_plan_id,
375 "reason": reason,
376 },
377 actor_type=actor_type,
378 command=command,
379 related_plan_id=plan_id,
380 )
381
382
383def plan_reimport(
384 registry: Registry,
385 *,
386 plan_id: str,
387 previous_body_sha256: str,
388 new_body_sha256: str,
389 previous_body_observed_at: str,
390 new_body_observed_at: str,
391 actor_slot: str,
392 actor_type: ActorType,
393 command: str | None = None,
394) -> OperationLogRow:
395 """Emit a ``plan.reimport`` operation."""
396
397 return registry.execute_domain_command(
398 "plan.reimport",
399 actor_slot,
400 {
401 "version": 1,
402 "plan_id": plan_id,
403 "previous_body_sha256": previous_body_sha256,
404 "new_body_sha256": new_body_sha256,
405 "previous_body_observed_at": previous_body_observed_at,
406 "new_body_observed_at": new_body_observed_at,
407 },
408 actor_type=actor_type,
409 command=command,
410 related_plan_id=plan_id,
411 )
412
413
414def auth_register_key(
415 registry: Registry,
416 *,
417 slot: str,
418 hmac_key_id: str,
419 secret_fingerprint: str,
420 actor_slot: str,
421 actor_type: ActorType,
422 command: str | None = None,
423) -> OperationLogRow:
424 """Emit an ``auth.register_key`` audit operation."""
425
426 return registry.execute_domain_command(
427 "auth.register_key",
428 actor_slot,
429 {
430 "version": 1,
431 "slot": slot,
432 "hmac_key_id": hmac_key_id,
433 "secret_fingerprint": secret_fingerprint,
434 },
435 actor_type=actor_type,
436 command=command,
437 )
438
439
440def auth_reset(
441 registry: Registry,
442 *,
443 slot: str,
444 previous_key_ids_revoked: list[str],
445 new_key_id: str,
446 reason: str,
447 actor_slot: str,
448 actor_type: ActorType,
449 command: str | None = None,
450) -> OperationLogRow:
451 """Emit an ``auth.reset`` audit operation."""
452
453 return registry.execute_domain_command(
454 "auth.reset",
455 actor_slot,
456 {
457 "version": 1,
458 "slot": slot,
459 "previous_key_ids_revoked": previous_key_ids_revoked,
460 "new_key_id": new_key_id,
461 "reason": reason,
462 },
463 actor_type=actor_type,
464 command=command,
465 )
466
467
468def handover_initiate(
469 registry: Registry,
470 *,
471 from_slot: str,
472 to_slot: str,
473 reason: str,
474 actor_slot: str,
475 actor_type: ActorType,
476 command: str | None = None,
477) -> OperationLogRow:
478 """Emit a handover-start operation."""
479
480 return registry.execute_domain_command(
481 "role.handover.start",
482 actor_slot,
483 {
484 "version": 1,
485 "from_slot": from_slot,
486 "to_slot": to_slot,
487 "reason": reason,
488 },
489 actor_type=actor_type,
490 command=command or "handover.initiate",
491 )
492
493
494def handover_accept(
495 registry: Registry,
496 *,
497 from_slot: str,
498 to_slot: str,
499 started_at: str,
500 completed_at: str,
501 parent_operation_id: int,
502 actor_slot: str,
503 actor_type: ActorType,
504 command: str | None = None,
505) -> OperationLogRow:
506 """Emit a handover-complete operation."""
507
508 return registry.execute_domain_command(
509 "role.handover.complete",
510 actor_slot,
511 {
512 "version": 1,
513 "from_slot": from_slot,
514 "to_slot": to_slot,
515 "started_at": started_at,
516 "completed_at": completed_at,
517 "parent_operation_id": parent_operation_id,
518 },
519 actor_type=actor_type,
520 command=command or "handover.accept",
521 parent_operation_id=parent_operation_id,
522 )
523
524
525RoundCleanupOutcome = Literal["clean", "failed"]
526
527
528def round_open(
529 registry: Registry,
530 *,
531 round_no: int,
532 orchestrator_slot: str,
533 plan_id: str | None = None,
534 merge_order: int | None = None,
535 actor_slot: str,
536 actor_type: ActorType,
537 command: str | None = None,
538) -> OperationLogRow:
539 """Emit a ``round.open`` operation (idempotent-additive per SF-001)."""
540
541 if round_no <= 0:
542 raise ValueError("round_no must be positive")
543 payload: dict[str, object] = {
544 "version": 1,
545 "round_no": round_no,
546 "orchestrator_slot": orchestrator_slot,
547 }
548 if plan_id is not None:
549 payload["plan_id"] = plan_id
550 if merge_order is not None:
551 payload["merge_order"] = merge_order
552 return registry.execute_domain_command(
553 "round.open",
554 actor_slot,
555 payload,
556 actor_type=actor_type,
557 command=command,
558 related_plan_id=plan_id,
559 related_round=round_no,
560 )
561
562
563def round_merge(
564 registry: Registry,
565 *,
566 round_no: int,
567 plan_id: str,
568 merge_order: int,
569 merge_commit_sha: str,
570 base_sha: str | None = None,
571 staging_branch: str | None = None,
572 merge_base_sha: str | None = None,
573 actor_slot: str,
574 actor_type: ActorType,
575 command: str | None = None,
576) -> OperationLogRow:
577 """Emit a ``round.merge`` operation populating ``merge_commit_sha``."""
578
579 if round_no <= 0:
580 raise ValueError("round_no must be positive")
581 if merge_order <= 0:
582 raise ValueError("merge_order must be positive")
583 return registry.execute_domain_command(
584 "round.merge",
585 actor_slot,
586 {
587 "version": 1,
588 "round_no": round_no,
589 "plan_id": plan_id,
590 "merge_order": merge_order,
591 "base_sha": base_sha,
592 "merge_commit_sha": merge_commit_sha,
593 "staging_branch": staging_branch,
594 "merge_base_sha": merge_base_sha,
595 },
596 actor_type=actor_type,
597 command=command,
598 related_plan_id=plan_id,
599 related_round=round_no,
600 )
601
602
603def round_test(
604 registry: Registry,
605 *,
606 round_no: int,
607 test_count_before: int | None,
608 test_count_after: int | None,
609 input_hash: str,
610 result: Literal["pass", "fail"],
611 duration_seconds: float | None = None,
612 plan_results: list[dict[str, object]] | None = None,
613 artifact_paths: list[str] | None = None,
614 actor_slot: str,
615 actor_type: ActorType,
616 command: str | None = None,
617) -> OperationLogRow:
618 """Emit a ``round.test`` operation recording the full-suite result."""
619
620 return registry.execute_domain_command(
621 "round.test",
622 actor_slot,
623 {
624 "version": 1,
625 "round_no": round_no,
626 "test_count_before": test_count_before,
627 "test_count_after": test_count_after,
628 "input_hash": input_hash,
629 "result": result,
630 "duration_seconds": duration_seconds,
631 "plan_results": plan_results or [],
632 "artifact_paths": artifact_paths or [],
633 },
634 actor_type=actor_type,
635 command=command,
636 related_round=round_no,
637 )
638
639
640def round_lint(
641 registry: Registry,
642 *,
643 round_no: int,
644 staging_sha: str,
645 errors: int,
646 warnings: int,
647 input_hash: str,
648 result: Literal["pass", "fail"],
649 artifact_paths: list[str] | None = None,
650 actor_slot: str,
651 actor_type: ActorType,
652 command: str | None = None,
653) -> OperationLogRow:
654 """Emit a ``round.lint`` operation recording the strict-profile result."""
655
656 return registry.execute_domain_command(
657 "round.lint",
658 actor_slot,
659 {
660 "version": 1,
661 "round_no": round_no,
662 "staging_sha": staging_sha,
663 "errors": errors,
664 "warnings": warnings,
665 "input_hash": input_hash,
666 "result": result,
667 "artifact_paths": artifact_paths or [],
668 },
669 actor_type=actor_type,
670 command=command,
671 related_round=round_no,
672 )
673
674
675def round_consolidate(
676 registry: Registry,
677 *,
678 round_no: int,
679 final_commit_sha: str,
680 actor_slot: str,
681 actor_type: ActorType,
682 command: str | None = None,
683) -> OperationLogRow:
684 """Emit a ``round.consolidate`` operation; flips status to ``committed``."""
685
686 return registry.execute_domain_command(
687 "round.consolidate",
688 actor_slot,
689 {
690 "version": 1,
691 "round_no": round_no,
692 "final_commit_sha": final_commit_sha,
693 },
694 actor_type=actor_type,
695 command=command,
696 related_round=round_no,
697 )
698
699
700def round_ff_main(
701 registry: Registry,
702 *,
703 round_no: int,
704 from_sha: str,
705 to_sha: str,
706 actor_slot: str,
707 actor_type: ActorType,
708 command: str | None = None,
709) -> OperationLogRow:
710 """Emit a ``round.ff_main`` operation recording the fast-forward."""
711
712 return registry.execute_domain_command(
713 "round.ff_main",
714 actor_slot,
715 {
716 "version": 1,
717 "round_no": round_no,
718 "from_sha": from_sha,
719 "to_sha": to_sha,
720 },
721 actor_type=actor_type,
722 command=command,
723 related_round=round_no,
724 )
725
726
727def round_cleanup(
728 registry: Registry,
729 *,
730 round_no: int,
731 outcome: RoundCleanupOutcome,
732 branches_deleted: list[str] | None = None,
733 worktrees_removed: list[str] | None = None,
734 error_summary: str | None = None,
735 roadmap_version: str | None = None,
736 app_version: str | None = None,
737 actor_slot: str,
738 actor_type: ActorType,
739 command: str | None = None,
740) -> OperationLogRow:
741 """Emit a ``round.cleanup`` operation; closes the round when ``clean``."""
742
743 if outcome not in {"clean", "failed"}:
744 raise ValueError(f"invalid cleanup outcome: {outcome}")
745 return registry.execute_domain_command(
746 "round.cleanup",
747 actor_slot,
748 {
749 "version": 1,
750 "round_no": round_no,
751 "outcome": outcome,
752 "branches_deleted": branches_deleted or [],
753 "worktrees_removed": worktrees_removed or [],
754 "error_summary": error_summary,
755 "roadmap_version": roadmap_version,
756 "app_version": app_version,
757 },
758 actor_type=actor_type,
759 command=command,
760 related_round=round_no,
761 )
762
763
764def round_abort(
765 registry: Registry,
766 *,
767 round_no: int,
768 reason: str,
769 actor_slot: str,
770 actor_type: ActorType,
771 command: str | None = None,
772) -> OperationLogRow:
773 """Emit a ``round.abort`` operation; returns plans to ``ready_for_merge``."""
774
775 if not reason.strip():
776 raise ValueError("abort reason is required")
777 return registry.execute_domain_command(
778 "round.abort",
779 actor_slot,
780 {
781 "version": 1,
782 "round_no": round_no,
783 "reason": reason,
784 },
785 actor_type=actor_type,
786 command=command,
787 related_round=round_no,
788 )
789
790
791def hub_message(
792 registry: Registry,
793 *,
794 message: Mapping[str, object],
795 kind: str,
796 actor_slot: str,
797 actor_type: ActorType,
798 related_plan_id: str | None = None,
799) -> OperationLogRow:
800 """Emit a ``hub.message`` operation carrying one addressed hub message.
801
802 Plan 259 P4. The hub's registry backend records a message as ONE
803 operation-log row and nothing else -- there is no projection, because
804 a message changes no projected state; it IS the state.
805
806 ``command`` is the hub kind, so the log is filterable by kind without
807 decoding a payload. ``related_plan_id`` is the message's subject when
808 that subject is a plan id, and ``None`` otherwise: the column is
809 plan-shaped, which the design note ยง5 names as the seam a second work
810 item kind will find, and smuggling a ticket id into it would make the
811 column lie before that seam is even reached.
812
813 **The ROW's ``actor_type`` and ``hmac_key_id`` are the signature; the
814 message's own ``provenance`` is the CLAIM.** Those are different
815 things and the hub keeps them apart on purpose: an agent can write
816 any provenance it likes into a payload, but the row's actor type is
817 derived from the slot by the storage layer and cannot be chosen.
818 """
819 if not kind.strip():
820 raise ValueError("a hub message must name its kind")
821 return registry.execute_domain_command(
822 "hub.message",
823 actor_slot,
824 {
825 "version": 1,
826 "message": dict(message),
827 },
828 actor_type=actor_type,
829 command=kind,
830 related_plan_id=related_plan_id,
831 )
832
833
834def _require_positive_position(position: int) -> None:
835 if position <= 0:
836 raise ValueError("queue position must be positive")
837
838
839def _require_status(value: str, field: str) -> None:
840 if value not in PLAN_STATUSES:
841 raise ValueError(f"invalid {field}: {value}")