agent-lifecycle-kit 1.50.1__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.
- agent_lifecycle/__init__.py +5 -0
- agent_lifecycle/__main__.py +5 -0
- agent_lifecycle/_version.py +3 -0
- agent_lifecycle/adapter_sessions/__init__.py +41 -0
- agent_lifecycle/adapter_sessions/contracts.py +133 -0
- agent_lifecycle/adapter_sessions/env.py +44 -0
- agent_lifecycle/adapter_sessions/launcher.py +101 -0
- agent_lifecycle/adapter_sessions/process.py +39 -0
- agent_lifecycle/adapter_sessions/redaction.py +30 -0
- agent_lifecycle/adapter_sessions/session_store.py +64 -0
- agent_lifecycle/adapter_sessions/task_intake.py +450 -0
- agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
- agent_lifecycle/audit/__init__.py +67 -0
- agent_lifecycle/audit/bug_forensics.py +94 -0
- agent_lifecycle/audit/implementation.py +516 -0
- agent_lifecycle/audit/ownership.py +119 -0
- agent_lifecycle/audit/proof_integrity.py +814 -0
- agent_lifecycle/audit/review_verdict.py +116 -0
- agent_lifecycle/changesets/__init__.py +5 -0
- agent_lifecycle/changesets/git.py +35 -0
- agent_lifecycle/cli/__init__.py +5 -0
- agent_lifecycle/cli/adapter.py +360 -0
- agent_lifecycle/cli/dispatch.py +1220 -0
- agent_lifecycle/cli/followup.py +89 -0
- agent_lifecycle/cli/main.py +41 -0
- agent_lifecycle/cli/metrics_parser.py +49 -0
- agent_lifecycle/cli/parsers.py +618 -0
- agent_lifecycle/cli/policy.py +101 -0
- agent_lifecycle/cli/progress_hooks.py +117 -0
- agent_lifecycle/cli/worktree.py +74 -0
- agent_lifecycle/compiler/__init__.py +17 -0
- agent_lifecycle/compiler/small_model_packets.py +322 -0
- agent_lifecycle/compiler/task_packets.py +229 -0
- agent_lifecycle/context/__init__.py +20 -0
- agent_lifecycle/context/episode_retrieval.py +30 -0
- agent_lifecycle/context/external_memory.py +71 -0
- agent_lifecycle/context/profiles.py +104 -0
- agent_lifecycle/context/rendering.py +226 -0
- agent_lifecycle/contracts/__init__.py +25 -0
- agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
- agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
- agent_lifecycle/contracts/audit_schemas.py +133 -0
- agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
- agent_lifecycle/contracts/canonical.py +55 -0
- agent_lifecycle/contracts/compatibility.py +242 -0
- agent_lifecycle/contracts/context_model_schemas.py +265 -0
- agent_lifecycle/contracts/core_schemas.py +252 -0
- agent_lifecycle/contracts/cross_check_schemas.py +121 -0
- agent_lifecycle/contracts/errors.py +26 -0
- agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
- agent_lifecycle/contracts/host_capability_schemas.py +64 -0
- agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
- agent_lifecycle/contracts/metric_schemas.py +315 -0
- agent_lifecycle/contracts/paths.py +31 -0
- agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
- agent_lifecycle/contracts/policy_schemas.py +350 -0
- agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
- agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
- agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
- agent_lifecycle/contracts/release_contract_schemas.py +476 -0
- agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
- agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
- agent_lifecycle/contracts/review_quality_schemas.py +109 -0
- agent_lifecycle/contracts/runner_schemas.py +91 -0
- agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
- agent_lifecycle/contracts/sandbox_schemas.py +159 -0
- agent_lifecycle/contracts/schema_builders.py +23 -0
- agent_lifecycle/contracts/schemas.py +93 -0
- agent_lifecycle/contracts/status_goal_schemas.py +469 -0
- agent_lifecycle/contracts/task_template_schemas.py +96 -0
- agent_lifecycle/contracts/usage_export_schemas.py +68 -0
- agent_lifecycle/diagnostics/__init__.py +17 -0
- agent_lifecycle/diagnostics/bundles.py +124 -0
- agent_lifecycle/diagnostics/readiness.py +733 -0
- agent_lifecycle/evidence_index/__init__.py +39 -0
- agent_lifecycle/evidence_index/core.py +272 -0
- agent_lifecycle/evidence_index/episode_index.py +329 -0
- agent_lifecycle/evidence_index/external_context.py +276 -0
- agent_lifecycle/followup/__init__.py +23 -0
- agent_lifecycle/followup/records.py +404 -0
- agent_lifecycle/freeze/__init__.py +5 -0
- agent_lifecycle/freeze/locks.py +23 -0
- agent_lifecycle/goal/__init__.py +15 -0
- agent_lifecycle/goal/records.py +244 -0
- agent_lifecycle/goal/view.py +109 -0
- agent_lifecycle/host_protocol/__init__.py +65 -0
- agent_lifecycle/host_protocol/acp_capability.py +195 -0
- agent_lifecycle/host_protocol/capabilities.py +268 -0
- agent_lifecycle/host_protocol/contracts.py +214 -0
- agent_lifecycle/host_protocol/event_capture.py +390 -0
- agent_lifecycle/host_protocol/events.py +196 -0
- agent_lifecycle/host_protocol/inspection.py +194 -0
- agent_lifecycle/host_protocol/inspection_common.py +310 -0
- agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
- agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
- agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
- agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
- agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
- agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
- agent_lifecycle/host_protocol/receipts.py +58 -0
- agent_lifecycle/host_protocol/scaffold.py +329 -0
- agent_lifecycle/host_protocol/validation.py +229 -0
- agent_lifecycle/imports/__init__.py +76 -0
- agent_lifecycle/imports/agentskills_profile.py +54 -0
- agent_lifecycle/imports/bmad_profile.py +49 -0
- agent_lifecycle/imports/constitution_adr.py +91 -0
- agent_lifecycle/imports/dialect_profiles.py +121 -0
- agent_lifecycle/imports/external_agent.py +28 -0
- agent_lifecycle/imports/external_dialects.py +486 -0
- agent_lifecycle/imports/external_workflow.py +28 -0
- agent_lifecycle/imports/multi_markdown.py +111 -0
- agent_lifecycle/imports/openspec_profile.py +49 -0
- agent_lifecycle/imports/planning.py +536 -0
- agent_lifecycle/imports/spec_kit_profile.py +49 -0
- agent_lifecycle/imports/spec_kitty_profile.py +49 -0
- agent_lifecycle/metrics/__init__.py +60 -0
- agent_lifecycle/metrics/cost_collection.py +315 -0
- agent_lifecycle/metrics/costs.py +203 -0
- agent_lifecycle/metrics/outcome_index.py +330 -0
- agent_lifecycle/metrics/phase_resources.py +208 -0
- agent_lifecycle/metrics/recommendations.py +507 -0
- agent_lifecycle/metrics/regression_signals.py +64 -0
- agent_lifecycle/metrics/usage_export.py +390 -0
- agent_lifecycle/model_routing/__init__.py +19 -0
- agent_lifecycle/model_routing/profiles.py +323 -0
- agent_lifecycle/model_routing/receipts.py +140 -0
- agent_lifecycle/model_routing/resolver.py +566 -0
- agent_lifecycle/neutrality/__init__.py +12 -0
- agent_lifecycle/neutrality/__main__.py +6 -0
- agent_lifecycle/neutrality/authority.py +265 -0
- agent_lifecycle/neutrality/canonical.py +28 -0
- agent_lifecycle/neutrality/cli.py +294 -0
- agent_lifecycle/neutrality/ed25519.py +124 -0
- agent_lifecycle/neutrality/errors.py +89 -0
- agent_lifecycle/neutrality/gate.py +269 -0
- agent_lifecycle/neutrality/paths.py +92 -0
- agent_lifecycle/neutrality/policy.py +76 -0
- agent_lifecycle/neutrality/receipt.py +135 -0
- agent_lifecycle/neutrality/scanner.py +310 -0
- agent_lifecycle/neutrality/test_runner.py +116 -0
- agent_lifecycle/planning/__init__.py +47 -0
- agent_lifecycle/planning/acceptance_markdown.py +159 -0
- agent_lifecycle/planning/completeness.py +362 -0
- agent_lifecycle/planning/continuity.py +255 -0
- agent_lifecycle/planning/templates.py +294 -0
- agent_lifecycle/planning/tiering.py +87 -0
- agent_lifecycle/planning/validation.py +72 -0
- agent_lifecycle/policy/__init__.py +37 -0
- agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
- agent_lifecycle/policy/apply.py +44 -0
- agent_lifecycle/policy/proposals.py +189 -0
- agent_lifecycle/policy/quality_floor.py +144 -0
- agent_lifecycle/policy/runtime_receipts.py +160 -0
- agent_lifecycle/py.typed +1 -0
- agent_lifecycle/quality/__init__.py +89 -0
- agent_lifecycle/quality/bug_forensics.py +727 -0
- agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
- agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
- agent_lifecycle/quality/cross_check.py +437 -0
- agent_lifecycle/quality/failure_classification.py +318 -0
- agent_lifecycle/quality/packs.py +265 -0
- agent_lifecycle/reporting/__init__.py +37 -0
- agent_lifecycle/reporting/change_summary.py +156 -0
- agent_lifecycle/reporting/event_feed.py +101 -0
- agent_lifecycle/reporting/progress_bridge.py +153 -0
- agent_lifecycle/reporting/progress_hooks.py +161 -0
- agent_lifecycle/reporting/progress_terminal.py +95 -0
- agent_lifecycle/reporting/progress_view.py +284 -0
- agent_lifecycle/reporting/status_view.py +125 -0
- agent_lifecycle/reporting/usage_export.py +59 -0
- agent_lifecycle/review/__init__.py +5 -0
- agent_lifecycle/review/validation.py +29 -0
- agent_lifecycle/review_mesh/__init__.py +83 -0
- agent_lifecycle/review_mesh/assignments.py +160 -0
- agent_lifecycle/review_mesh/contracts.py +685 -0
- agent_lifecycle/review_mesh/operator_templates.py +320 -0
- agent_lifecycle/review_mesh/quorum.py +38 -0
- agent_lifecycle/review_mesh/recommendation.py +487 -0
- agent_lifecycle/review_mesh/results.py +98 -0
- agent_lifecycle/review_mesh/synthesis.py +102 -0
- agent_lifecycle/runner/__init__.py +47 -0
- agent_lifecycle/runner/attempt_snapshots.py +244 -0
- agent_lifecycle/runner/core.py +653 -0
- agent_lifecycle/runner/sandbox_receipts.py +628 -0
- agent_lifecycle/specification/__init__.py +23 -0
- agent_lifecycle/specification/completion_check.py +135 -0
- agent_lifecycle/specification/completion_gate.py +501 -0
- agent_lifecycle/specification/completion_signal.py +75 -0
- agent_lifecycle/specification/validation.py +48 -0
- agent_lifecycle/workflow/__init__.py +79 -0
- agent_lifecycle/workflow/artifacts.py +74 -0
- agent_lifecycle/workflow/budget_decisions.py +370 -0
- agent_lifecycle/workflow/budget_policy.py +140 -0
- agent_lifecycle/workflow/budget_receipts.py +70 -0
- agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
- agent_lifecycle/workflow/controller.py +45 -0
- agent_lifecycle/workflow/events.py +50 -0
- agent_lifecycle/workflow/final_proof_integrity.py +74 -0
- agent_lifecycle/workflow/finalization.py +422 -0
- agent_lifecycle/workflow/gates.py +185 -0
- agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
- agent_lifecycle/workflow/leases.py +220 -0
- agent_lifecycle/workflow/lineage.py +108 -0
- agent_lifecycle/workflow/managed_runner.py +167 -0
- agent_lifecycle/workflow/model_usage.py +106 -0
- agent_lifecycle/workflow/next_action.py +86 -0
- agent_lifecycle/workflow/operation_kernel.py +98 -0
- agent_lifecycle/workflow/plan_adoption.py +439 -0
- agent_lifecycle/workflow/query.py +52 -0
- agent_lifecycle/workflow/review_mesh_gate.py +93 -0
- agent_lifecycle/workflow/reviews.py +168 -0
- agent_lifecycle/workflow/run_transitions.py +163 -0
- agent_lifecycle/workflow/sandbox_policy.py +214 -0
- agent_lifecycle/workflow/selectors.py +57 -0
- agent_lifecycle/workflow/state.py +130 -0
- agent_lifecycle/workflow/task_transitions.py +421 -0
- agent_lifecycle/worktree/__init__.py +17 -0
- agent_lifecycle/worktree/isolation.py +407 -0
- agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
- agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared workflow gates for implementation audit reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agent_lifecycle.contracts import LifecycleError, read_json_object
|
|
9
|
+
from agent_lifecycle.contracts.paths import normalize_repo_path
|
|
10
|
+
from agent_lifecycle.workflow.artifacts import artifact_identity, package_root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def task_implementation_audit_required(
|
|
14
|
+
state_path: Path,
|
|
15
|
+
state: dict[str, Any],
|
|
16
|
+
task: dict[str, Any],
|
|
17
|
+
) -> bool:
|
|
18
|
+
if state.get("implementationAuditRequired") is True or _required(state.get("implementationAudit")):
|
|
19
|
+
return True
|
|
20
|
+
if task.get("implementationAuditRequired") is True or _required(task.get("implementationAudit")):
|
|
21
|
+
return True
|
|
22
|
+
manifest = load_state_manifest(state_path, state)
|
|
23
|
+
if manifest is None:
|
|
24
|
+
return False
|
|
25
|
+
if _required(manifest.get("implementationAudit")):
|
|
26
|
+
return True
|
|
27
|
+
for workstream in manifest.get("workstreams", []):
|
|
28
|
+
if isinstance(workstream, dict) and workstream.get("id") == task.get("id"):
|
|
29
|
+
return workstream.get("implementationAuditRequired") is True or _required(workstream.get("implementationAudit"))
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def final_implementation_audit_required(state_path: Path, state: dict[str, Any]) -> bool:
|
|
34
|
+
if state.get("finalImplementationAuditRequired") is True:
|
|
35
|
+
return True
|
|
36
|
+
implementation_audit = state.get("implementationAudit")
|
|
37
|
+
if isinstance(implementation_audit, dict) and implementation_audit.get("finalRequired") is True:
|
|
38
|
+
return True
|
|
39
|
+
manifest = load_state_manifest(state_path, state)
|
|
40
|
+
if manifest is None:
|
|
41
|
+
return False
|
|
42
|
+
manifest_audit = manifest.get("implementationAudit")
|
|
43
|
+
return isinstance(manifest_audit, dict) and manifest_audit.get("finalRequired") is True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_task_implementation_audit_artifact(
|
|
47
|
+
state_path: Path,
|
|
48
|
+
state: dict[str, Any],
|
|
49
|
+
task: dict[str, Any],
|
|
50
|
+
report_path: str,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
root = package_root(state_path, state)
|
|
53
|
+
rel = normalize_repo_path(report_path, label="implementation audit report")
|
|
54
|
+
report = read_json_object(root / rel, label="implementation audit report")
|
|
55
|
+
from agent_lifecycle.audit.implementation import (
|
|
56
|
+
require_implementation_audit_accepted,
|
|
57
|
+
validate_implementation_audit_report,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
validation = validate_implementation_audit_report(report, state=state, task=task)
|
|
61
|
+
require_implementation_audit_accepted(validation, report)
|
|
62
|
+
identity = artifact_identity(root, rel, report)
|
|
63
|
+
return {
|
|
64
|
+
**identity,
|
|
65
|
+
"taskId": report.get("taskId"),
|
|
66
|
+
"attempt": report.get("attempt"),
|
|
67
|
+
"verdict": report.get("verdict"),
|
|
68
|
+
"reportDigest": report.get("reportDigest"),
|
|
69
|
+
"validation": {
|
|
70
|
+
"status": validation["status"],
|
|
71
|
+
"validationDigest": validation["validationDigest"],
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def task_has_accepted_implementation_audit(task: dict[str, Any]) -> bool:
|
|
77
|
+
audit = task.get("implementationAuditReport")
|
|
78
|
+
return isinstance(audit, dict) and audit.get("verdict") == "ACCEPTED"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def missing_required_implementation_audits(state_path: Path, state: dict[str, Any]) -> list[str]:
|
|
82
|
+
missing = []
|
|
83
|
+
for task in state.get("tasks", []):
|
|
84
|
+
if not isinstance(task, dict) or task.get("status") != "ACCEPTED":
|
|
85
|
+
continue
|
|
86
|
+
if task_implementation_audit_required(state_path, state, task) and not task_has_accepted_implementation_audit(task):
|
|
87
|
+
missing.append(str(task.get("id")))
|
|
88
|
+
return missing
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_state_manifest(state_path: Path, state: dict[str, Any]) -> dict[str, Any] | None:
|
|
92
|
+
manifest_path = state.get("manifestPath")
|
|
93
|
+
if not isinstance(manifest_path, str) or not manifest_path:
|
|
94
|
+
return None
|
|
95
|
+
root = package_root(state_path, state)
|
|
96
|
+
rel = normalize_repo_path(manifest_path, label="manifest path")
|
|
97
|
+
path = root / rel
|
|
98
|
+
if not path.is_file():
|
|
99
|
+
return None
|
|
100
|
+
return read_json_object(path, label="plan manifest")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def implementation_audit_blockers(state_path: Path, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
104
|
+
blockers = []
|
|
105
|
+
for task in state.get("tasks", []):
|
|
106
|
+
if not isinstance(task, dict):
|
|
107
|
+
continue
|
|
108
|
+
if task.get("status") not in {"VERIFYING", "ACCEPTED"}:
|
|
109
|
+
continue
|
|
110
|
+
if task_implementation_audit_required(state_path, state, task) and not task_has_accepted_implementation_audit(task):
|
|
111
|
+
blockers.append({"code": "implementation-audit-required", "taskId": task.get("id")})
|
|
112
|
+
return blockers
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def require_final_implementation_audit_pass(audit: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
if audit.get("status") != "PASS":
|
|
117
|
+
raise LifecycleError(
|
|
118
|
+
"final-implementation-audit-not-pass",
|
|
119
|
+
"final implementation audit did not pass",
|
|
120
|
+
{"blockers": audit.get("blockers", [])},
|
|
121
|
+
)
|
|
122
|
+
return audit
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _required(value: Any) -> bool:
|
|
126
|
+
return isinstance(value, dict) and value.get("required") is True
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Worker lease and heartbeat receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest
|
|
9
|
+
|
|
10
|
+
WORKER_LEASE_RECEIPT_SCHEMA = "agent-worker-lease-receipt.v1"
|
|
11
|
+
WORKER_LEASE_RECEIPT_VALIDATION_SCHEMA = "agent-worker-lease-receipt-validation.v1"
|
|
12
|
+
|
|
13
|
+
LEASE_STATUSES = {"active", "expired", "completed"}
|
|
14
|
+
RECEIPT_STATUSES = {"PASS", "FAIL"}
|
|
15
|
+
LINEAGE_KEYS = ("runId", "packageId", "planRevision", "planDigest", "sourceRevision")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_worker_lease_receipt(
|
|
19
|
+
*,
|
|
20
|
+
lineage: dict[str, Any],
|
|
21
|
+
worker_id: str,
|
|
22
|
+
lease_id: str,
|
|
23
|
+
task_id: str,
|
|
24
|
+
acquired_at: str,
|
|
25
|
+
expires_at: str,
|
|
26
|
+
observed_at: str | None = None,
|
|
27
|
+
heartbeat_at: str | None = None,
|
|
28
|
+
completed_at: str | None = None,
|
|
29
|
+
evidence_ids: list[str] | None = None,
|
|
30
|
+
metadata: dict[str, Any] | None = None,
|
|
31
|
+
blockers: list[dict[str, Any]] | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Build a lease receipt with active/expired/completed classification."""
|
|
34
|
+
|
|
35
|
+
observed = observed_at or _now_iso()
|
|
36
|
+
lease_status = classify_lease_status(expires_at=expires_at, observed_at=observed, completed_at=completed_at)
|
|
37
|
+
body = {
|
|
38
|
+
"schemaVersion": WORKER_LEASE_RECEIPT_SCHEMA,
|
|
39
|
+
"status": "FAIL" if blockers else "PASS",
|
|
40
|
+
"lineage": _lineage(lineage, code="invalid-worker-lease-receipt"),
|
|
41
|
+
"workerId": _required_string(worker_id, label="workerId", code="invalid-worker-lease-receipt"),
|
|
42
|
+
"leaseId": _required_string(lease_id, label="leaseId", code="invalid-worker-lease-receipt"),
|
|
43
|
+
"taskId": _required_string(task_id, label="taskId", code="invalid-worker-lease-receipt"),
|
|
44
|
+
"leaseStatus": lease_status,
|
|
45
|
+
"acquiredAt": _iso(acquired_at, label="acquiredAt", code="invalid-worker-lease-receipt"),
|
|
46
|
+
"expiresAt": _iso(expires_at, label="expiresAt", code="invalid-worker-lease-receipt"),
|
|
47
|
+
"observedAt": _iso(observed, label="observedAt", code="invalid-worker-lease-receipt"),
|
|
48
|
+
"heartbeatAt": _optional_iso(heartbeat_at, label="heartbeatAt", code="invalid-worker-lease-receipt"),
|
|
49
|
+
"completedAt": _optional_iso(completed_at, label="completedAt", code="invalid-worker-lease-receipt"),
|
|
50
|
+
"evidenceIds": _string_list(evidence_ids or [], label="evidenceIds", code="invalid-worker-lease-receipt", allow_empty=True),
|
|
51
|
+
"metadata": dict(metadata or {}),
|
|
52
|
+
"blockers": list(blockers or []),
|
|
53
|
+
"productionPromotionClaimed": False,
|
|
54
|
+
}
|
|
55
|
+
return {**body, "receiptDigest": canonical_digest(body)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def classify_lease_status(*, expires_at: str, observed_at: str, completed_at: str | None = None) -> str:
|
|
59
|
+
"""Classify a lease from deterministic timestamps."""
|
|
60
|
+
|
|
61
|
+
if completed_at:
|
|
62
|
+
return "completed"
|
|
63
|
+
expires = _parse_iso(expires_at, label="expiresAt", code="invalid-worker-lease-receipt")
|
|
64
|
+
observed = _parse_iso(observed_at, label="observedAt", code="invalid-worker-lease-receipt")
|
|
65
|
+
return "expired" if observed > expires else "active"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_worker_lease_receipt(receipt: dict[str, Any], *, expected_lineage: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
69
|
+
"""Validate a worker lease receipt and recompute lease state."""
|
|
70
|
+
|
|
71
|
+
blockers: list[dict[str, Any]] = []
|
|
72
|
+
if not isinstance(receipt, dict):
|
|
73
|
+
raise LifecycleError("invalid-worker-lease-receipt", "worker lease receipt must be an object")
|
|
74
|
+
if receipt.get("schemaVersion") != WORKER_LEASE_RECEIPT_SCHEMA:
|
|
75
|
+
blockers.append({"code": "worker-lease-schema-invalid"})
|
|
76
|
+
status = receipt.get("status")
|
|
77
|
+
if status not in RECEIPT_STATUSES:
|
|
78
|
+
blockers.append({"code": "worker-lease-status-invalid", "status": status})
|
|
79
|
+
lineage = _checked_lineage(receipt.get("lineage"), blockers)
|
|
80
|
+
if expected_lineage is not None and lineage is not None:
|
|
81
|
+
_compare_lineage(lineage, expected_lineage, blockers)
|
|
82
|
+
for key in ("workerId", "leaseId", "taskId"):
|
|
83
|
+
if not isinstance(receipt.get(key), str) or not receipt[key]:
|
|
84
|
+
blockers.append({"code": "worker-lease-field-missing", "field": key})
|
|
85
|
+
lease_status = receipt.get("leaseStatus")
|
|
86
|
+
if lease_status not in LEASE_STATUSES:
|
|
87
|
+
blockers.append({"code": "worker-lease-status-class-invalid", "leaseStatus": lease_status})
|
|
88
|
+
acquired = _checked_iso(receipt.get("acquiredAt"), "worker-lease-acquired-invalid", blockers)
|
|
89
|
+
expires = _checked_iso(receipt.get("expiresAt"), "worker-lease-expires-invalid", blockers)
|
|
90
|
+
observed = _checked_iso(receipt.get("observedAt"), "worker-lease-observed-invalid", blockers)
|
|
91
|
+
heartbeat = _checked_optional_iso(receipt.get("heartbeatAt"), "worker-lease-heartbeat-invalid", blockers)
|
|
92
|
+
completed = _checked_optional_iso(receipt.get("completedAt"), "worker-lease-completed-invalid", blockers)
|
|
93
|
+
if acquired and expires and acquired > expires:
|
|
94
|
+
blockers.append({"code": "worker-lease-window-invalid"})
|
|
95
|
+
if heartbeat and acquired and heartbeat < acquired:
|
|
96
|
+
blockers.append({"code": "worker-lease-heartbeat-before-acquire"})
|
|
97
|
+
if completed and acquired and completed < acquired:
|
|
98
|
+
blockers.append({"code": "worker-lease-completed-before-acquire"})
|
|
99
|
+
if expires and observed:
|
|
100
|
+
expected_status = "completed" if completed else "expired" if observed > expires else "active"
|
|
101
|
+
if lease_status != expected_status:
|
|
102
|
+
blockers.append({"code": "worker-lease-status-mismatch", "expected": expected_status, "actual": lease_status})
|
|
103
|
+
_check_string_list(receipt.get("evidenceIds", []), "worker-lease-evidence-ids", blockers, allow_empty=True)
|
|
104
|
+
if not isinstance(receipt.get("metadata", {}), dict):
|
|
105
|
+
blockers.append({"code": "worker-lease-metadata-invalid"})
|
|
106
|
+
_check_object_list(receipt.get("blockers", []), "worker-lease-blockers-invalid", blockers)
|
|
107
|
+
if receipt.get("productionPromotionClaimed") is not False:
|
|
108
|
+
blockers.append({"code": "worker-lease-production-claim"})
|
|
109
|
+
expected_digest = canonical_digest({key: value for key, value in receipt.items() if key != "receiptDigest"})
|
|
110
|
+
if receipt.get("receiptDigest") != expected_digest:
|
|
111
|
+
blockers.append({"code": "worker-lease-receipt-digest-mismatch"})
|
|
112
|
+
body = {
|
|
113
|
+
"schemaVersion": WORKER_LEASE_RECEIPT_VALIDATION_SCHEMA,
|
|
114
|
+
"status": "PASS" if not blockers else "FAIL",
|
|
115
|
+
"receiptStatus": status if isinstance(status, str) else None,
|
|
116
|
+
"workerId": receipt.get("workerId") if isinstance(receipt.get("workerId"), str) else None,
|
|
117
|
+
"leaseId": receipt.get("leaseId") if isinstance(receipt.get("leaseId"), str) else None,
|
|
118
|
+
"taskId": receipt.get("taskId") if isinstance(receipt.get("taskId"), str) else None,
|
|
119
|
+
"leaseStatus": lease_status if isinstance(lease_status, str) else None,
|
|
120
|
+
"blockers": blockers,
|
|
121
|
+
"receiptDigest": receipt.get("receiptDigest"),
|
|
122
|
+
}
|
|
123
|
+
return {**body, "validationDigest": canonical_digest(body)}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require_worker_lease_receipt_pass(validation: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
if validation.get("status") != "PASS" or validation.get("receiptStatus") != "PASS":
|
|
128
|
+
raise LifecycleError("worker-lease-validation-failed", "worker lease receipt did not pass", {"validation": validation})
|
|
129
|
+
return validation
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _now_iso() -> str:
|
|
133
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_iso(value: str, *, label: str, code: str) -> datetime:
|
|
137
|
+
if not isinstance(value, str) or not value:
|
|
138
|
+
raise LifecycleError(code, f"{label} must be an ISO timestamp")
|
|
139
|
+
normalized = value.replace("Z", "+00:00")
|
|
140
|
+
try:
|
|
141
|
+
parsed = datetime.fromisoformat(normalized)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
raise LifecycleError(code, f"{label} must be an ISO timestamp") from exc
|
|
144
|
+
if parsed.tzinfo is None:
|
|
145
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
146
|
+
return parsed.astimezone(UTC)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _iso(value: str, *, label: str, code: str) -> str:
|
|
150
|
+
_parse_iso(value, label=label, code=code)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _optional_iso(value: str | None, *, label: str, code: str) -> str | None:
|
|
155
|
+
if value is None:
|
|
156
|
+
return None
|
|
157
|
+
return _iso(value, label=label, code=code)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _checked_iso(value: Any, code: str, blockers: list[dict[str, Any]]) -> datetime | None:
|
|
161
|
+
if not isinstance(value, str) or not value:
|
|
162
|
+
blockers.append({"code": code})
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
return _parse_iso(value, label=code, code=code)
|
|
166
|
+
except LifecycleError:
|
|
167
|
+
blockers.append({"code": code})
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _checked_optional_iso(value: Any, code: str, blockers: list[dict[str, Any]]) -> datetime | None:
|
|
172
|
+
if value is None:
|
|
173
|
+
return None
|
|
174
|
+
return _checked_iso(value, code, blockers)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _lineage(value: dict[str, Any], *, code: str) -> dict[str, Any]:
|
|
178
|
+
if not isinstance(value, dict):
|
|
179
|
+
raise LifecycleError(code, "lineage must be an object")
|
|
180
|
+
return {key: value[key] for key in LINEAGE_KEYS if key in value}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _checked_lineage(value: Any, blockers: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
184
|
+
if not isinstance(value, dict):
|
|
185
|
+
blockers.append({"code": "worker-lease-lineage-missing"})
|
|
186
|
+
return None
|
|
187
|
+
for key in ("runId", "packageId", "planDigest", "sourceRevision"):
|
|
188
|
+
if not isinstance(value.get(key), str) or not value[key]:
|
|
189
|
+
blockers.append({"code": "worker-lease-lineage-field-missing", "field": key})
|
|
190
|
+
if not isinstance(value.get("planRevision"), int) or isinstance(value.get("planRevision"), bool) or value.get("planRevision") < 1:
|
|
191
|
+
blockers.append({"code": "worker-lease-lineage-plan-revision-invalid"})
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _compare_lineage(actual: dict[str, Any], expected: dict[str, Any], blockers: list[dict[str, Any]]) -> None:
|
|
196
|
+
for key in LINEAGE_KEYS:
|
|
197
|
+
if key in expected and actual.get(key) != expected.get(key):
|
|
198
|
+
blockers.append({"code": "worker-lease-lineage-mismatch", "field": key})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _required_string(value: Any, *, label: str, code: str) -> str:
|
|
202
|
+
if not isinstance(value, str) or not value:
|
|
203
|
+
raise LifecycleError(code, f"{label} is required")
|
|
204
|
+
return value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _string_list(value: Any, *, label: str, code: str, allow_empty: bool = False) -> list[str]:
|
|
208
|
+
if not isinstance(value, list) or (not allow_empty and not value) or not all(isinstance(item, str) and item for item in value):
|
|
209
|
+
raise LifecycleError(code, f"{label} must be a list of strings")
|
|
210
|
+
return list(value)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_string_list(value: Any, code: str, blockers: list[dict[str, Any]], *, allow_empty: bool = False) -> None:
|
|
214
|
+
if not isinstance(value, list) or (not allow_empty and not value) or not all(isinstance(item, str) and item for item in value):
|
|
215
|
+
blockers.append({"code": code})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_object_list(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
|
|
219
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
220
|
+
blockers.append({"code": code})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shared workflow and release lineage checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_lifecycle.contracts import canonical_digest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_lineage(
|
|
11
|
+
manifest: dict[str, Any],
|
|
12
|
+
*,
|
|
13
|
+
state: dict[str, Any] | None = None,
|
|
14
|
+
task_packet_index: dict[str, Any] | None = None,
|
|
15
|
+
final_audit: dict[str, Any] | None = None,
|
|
16
|
+
final_proof: dict[str, Any] | None = None,
|
|
17
|
+
release_inventory: dict[str, Any] | None = None,
|
|
18
|
+
lock: dict[str, Any] | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Compare frozen-plan identity across workflow and release artifacts."""
|
|
21
|
+
|
|
22
|
+
package = manifest.get("package", {})
|
|
23
|
+
expected = {
|
|
24
|
+
"packageId": package.get("id"),
|
|
25
|
+
"planRevision": manifest.get("planRevision"),
|
|
26
|
+
"planDigest": canonical_digest(manifest),
|
|
27
|
+
}
|
|
28
|
+
checks: list[dict[str, Any]] = []
|
|
29
|
+
_compare(checks, "manifest.packageId", expected["packageId"], package.get("id"))
|
|
30
|
+
_compare(checks, "manifest.planRevision", expected["planRevision"], manifest.get("planRevision"))
|
|
31
|
+
_compare(checks, "manifest.planDigest", expected["planDigest"], expected["planDigest"])
|
|
32
|
+
if lock is not None:
|
|
33
|
+
_compare(checks, "lock.packageId", expected["packageId"], lock.get("packageId"), optional_actual=True)
|
|
34
|
+
_compare(checks, "lock.planRevision", expected["planRevision"], lock.get("planRevision"))
|
|
35
|
+
_compare(checks, "lock.manifestHash", expected["planDigest"], lock.get("manifestHash"))
|
|
36
|
+
if state is not None:
|
|
37
|
+
_compare_artifact(checks, "state", expected, state)
|
|
38
|
+
_compare_required_tasks(checks, manifest, state)
|
|
39
|
+
if task_packet_index is not None:
|
|
40
|
+
_compare(checks, "taskPacketIndex.packageId", expected["packageId"], task_packet_index.get("packageId"), optional_actual=True)
|
|
41
|
+
_compare(checks, "taskPacketIndex.manifestDigest", expected["planDigest"], task_packet_index.get("manifestDigest"))
|
|
42
|
+
if final_audit is not None:
|
|
43
|
+
_compare_artifact(checks, "finalAudit", expected, final_audit, package_optional=True)
|
|
44
|
+
if final_proof is not None:
|
|
45
|
+
_compare_artifact(checks, "finalProof", expected, final_proof, package_optional=True)
|
|
46
|
+
if release_inventory is not None:
|
|
47
|
+
_compare_artifact(checks, "releaseInventory", expected, release_inventory, package_optional=True)
|
|
48
|
+
status = "PASS" if all(item["status"] == "PASS" for item in checks) else "FAIL"
|
|
49
|
+
return {
|
|
50
|
+
"schemaVersion": "agent-workflow-lineage-check.v1",
|
|
51
|
+
"status": status,
|
|
52
|
+
"packageId": expected["packageId"],
|
|
53
|
+
"planRevision": expected["planRevision"],
|
|
54
|
+
"planDigest": expected["planDigest"],
|
|
55
|
+
"lineageChecks": checks,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _compare_artifact(
|
|
60
|
+
checks: list[dict[str, Any]],
|
|
61
|
+
prefix: str,
|
|
62
|
+
expected: dict[str, Any],
|
|
63
|
+
artifact: dict[str, Any],
|
|
64
|
+
*,
|
|
65
|
+
package_optional: bool = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
_compare(
|
|
68
|
+
checks,
|
|
69
|
+
f"{prefix}.packageId",
|
|
70
|
+
expected["packageId"],
|
|
71
|
+
artifact.get("packageId"),
|
|
72
|
+
optional_actual=package_optional,
|
|
73
|
+
)
|
|
74
|
+
_compare(checks, f"{prefix}.planRevision", expected["planRevision"], artifact.get("planRevision"))
|
|
75
|
+
_compare(checks, f"{prefix}.planDigest", expected["planDigest"], artifact.get("planDigest"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _compare_required_tasks(
|
|
79
|
+
checks: list[dict[str, Any]],
|
|
80
|
+
manifest: dict[str, Any],
|
|
81
|
+
state: dict[str, Any],
|
|
82
|
+
) -> None:
|
|
83
|
+
expected = sorted(
|
|
84
|
+
str(item.get("id"))
|
|
85
|
+
for item in manifest.get("workstreams", [])
|
|
86
|
+
if isinstance(item, dict) and item.get("required", True)
|
|
87
|
+
)
|
|
88
|
+
actual = sorted(
|
|
89
|
+
str(item.get("id"))
|
|
90
|
+
for item in state.get("tasks", [])
|
|
91
|
+
if isinstance(item, dict) and item.get("required", True)
|
|
92
|
+
)
|
|
93
|
+
_compare(checks, "requiredTaskSet", expected, actual)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _compare(
|
|
97
|
+
checks: list[dict[str, Any]],
|
|
98
|
+
check_id: str,
|
|
99
|
+
expected: Any,
|
|
100
|
+
actual: Any,
|
|
101
|
+
*,
|
|
102
|
+
optional_actual: bool = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
if optional_actual and actual is None:
|
|
105
|
+
checks.append({"id": check_id, "status": "PASS", "expected": expected, "actual": actual, "skipped": True})
|
|
106
|
+
return
|
|
107
|
+
status = "PASS" if actual == expected else "FAIL"
|
|
108
|
+
checks.append({"id": check_id, "status": status, "expected": expected, "actual": actual})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Managed workflow step-function.
|
|
2
|
+
|
|
3
|
+
This module is intentionally read-only. It checks the frozen plan and durable
|
|
4
|
+
workflow state, then returns the next host action as a typed receipt. Host
|
|
5
|
+
adapters remain responsible for launching model/tool work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object
|
|
14
|
+
from agent_lifecycle.freeze import verify_plan_lock
|
|
15
|
+
from agent_lifecycle.workflow.next_action import MODEL_CALLS_STARTED, build_managed_next_action
|
|
16
|
+
from agent_lifecycle.workflow.implementation_audit_gate import implementation_audit_blockers
|
|
17
|
+
from agent_lifecycle.workflow.state import load_state, state_identity
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_managed_lifecycle_step(
|
|
21
|
+
*,
|
|
22
|
+
state_path: Path,
|
|
23
|
+
manifest_path: Path,
|
|
24
|
+
operation_id: str,
|
|
25
|
+
expected_revision: int,
|
|
26
|
+
source_revision: str,
|
|
27
|
+
lock_path: Path | None = None,
|
|
28
|
+
reason: str | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
blockers: list[dict[str, Any]] = []
|
|
31
|
+
manifest: dict[str, Any] | None = None
|
|
32
|
+
lock: dict[str, Any] | None = None
|
|
33
|
+
state: dict[str, Any] | None = None
|
|
34
|
+
next_action: dict[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
manifest = read_json_object(manifest_path, label="frozen plan manifest")
|
|
38
|
+
_require_frozen_manifest(manifest)
|
|
39
|
+
lock = _load_lock(manifest_path, lock_path)
|
|
40
|
+
verify_plan_lock(manifest, lock)
|
|
41
|
+
except LifecycleError as exc:
|
|
42
|
+
blockers.append(_blocker(exc.code, exc.message, exc.details))
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
state = load_state(state_path)
|
|
46
|
+
_require_state_lineage(
|
|
47
|
+
state,
|
|
48
|
+
manifest=manifest,
|
|
49
|
+
expected_revision=expected_revision,
|
|
50
|
+
source_revision=source_revision,
|
|
51
|
+
)
|
|
52
|
+
if not blockers:
|
|
53
|
+
next_action = build_managed_next_action(state)
|
|
54
|
+
audit_blockers = implementation_audit_blockers(state_path, state)
|
|
55
|
+
if audit_blockers:
|
|
56
|
+
blockers.extend(audit_blockers)
|
|
57
|
+
next_action = None
|
|
58
|
+
except LifecycleError as exc:
|
|
59
|
+
blockers.append(_blocker(exc.code, exc.message, exc.details))
|
|
60
|
+
|
|
61
|
+
status = "FAIL" if blockers else "PASS"
|
|
62
|
+
body = {
|
|
63
|
+
"schemaVersion": "agent-managed-lifecycle-runner-receipt.v1",
|
|
64
|
+
"status": status,
|
|
65
|
+
"operationId": operation_id,
|
|
66
|
+
"reason": reason,
|
|
67
|
+
"expectedRevision": expected_revision,
|
|
68
|
+
"sourceRevision": source_revision,
|
|
69
|
+
"state": _state_summary(state_path, state),
|
|
70
|
+
"plan": _plan_summary(manifest, manifest_path),
|
|
71
|
+
"nextAction": next_action or _blocked_next_action(blockers),
|
|
72
|
+
"blockers": blockers,
|
|
73
|
+
"modelCallsStarted": MODEL_CALLS_STARTED,
|
|
74
|
+
"stateWritten": False,
|
|
75
|
+
"hostLaunchStarted": False,
|
|
76
|
+
"productionPromotionClaimed": False,
|
|
77
|
+
}
|
|
78
|
+
return {**body, "receiptDigest": canonical_digest(body)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_lock(manifest_path: Path, lock_path: Path | None) -> dict[str, Any]:
|
|
82
|
+
path = lock_path or manifest_path.with_name("plan.lock.json")
|
|
83
|
+
return read_json_object(path, label="plan lock")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _require_frozen_manifest(manifest: dict[str, Any]) -> None:
|
|
87
|
+
if manifest.get("status") != "FROZEN":
|
|
88
|
+
raise LifecycleError("plan-not-frozen", "managed lifecycle run requires a FROZEN plan")
|
|
89
|
+
revision = manifest.get("planRevision")
|
|
90
|
+
if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1:
|
|
91
|
+
raise LifecycleError("invalid-plan-manifest", "planRevision must be a positive integer")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_state_lineage(
|
|
95
|
+
state: dict[str, Any],
|
|
96
|
+
*,
|
|
97
|
+
manifest: dict[str, Any] | None,
|
|
98
|
+
expected_revision: int,
|
|
99
|
+
source_revision: str,
|
|
100
|
+
) -> None:
|
|
101
|
+
if state["stateRevision"] != expected_revision:
|
|
102
|
+
raise LifecycleError(
|
|
103
|
+
"state-revision-mismatch",
|
|
104
|
+
"workflow state revision mismatch",
|
|
105
|
+
{"expected": expected_revision, "actual": state["stateRevision"]},
|
|
106
|
+
)
|
|
107
|
+
if state.get("sourceRevision") != source_revision:
|
|
108
|
+
raise LifecycleError("source-revision-mismatch", "source revision mismatch")
|
|
109
|
+
if manifest is None:
|
|
110
|
+
return
|
|
111
|
+
package = manifest.get("package", {}) if isinstance(manifest.get("package"), dict) else {}
|
|
112
|
+
package_id = package.get("id")
|
|
113
|
+
if isinstance(package_id, str) and state.get("packageId") != package_id:
|
|
114
|
+
raise LifecycleError("package-id-mismatch", "workflow state packageId does not match manifest")
|
|
115
|
+
if state.get("planRevision") != manifest.get("planRevision"):
|
|
116
|
+
raise LifecycleError("plan-revision-mismatch", "workflow state planRevision does not match manifest")
|
|
117
|
+
digest = canonical_digest(manifest)
|
|
118
|
+
if state.get("planDigest") != digest:
|
|
119
|
+
raise LifecycleError("plan-digest-mismatch", "workflow state planDigest does not match manifest")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _state_summary(state_path: Path, state: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
123
|
+
if state is None:
|
|
124
|
+
return None
|
|
125
|
+
return {
|
|
126
|
+
**state_identity(state_path, state),
|
|
127
|
+
"runId": state.get("runId"),
|
|
128
|
+
"packageId": state.get("packageId"),
|
|
129
|
+
"planRevision": state.get("planRevision"),
|
|
130
|
+
"planDigest": state.get("planDigest"),
|
|
131
|
+
"phase": state.get("phase"),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _plan_summary(manifest: dict[str, Any] | None, manifest_path: Path) -> dict[str, Any] | None:
|
|
136
|
+
if manifest is None:
|
|
137
|
+
return None
|
|
138
|
+
package = manifest.get("package", {}) if isinstance(manifest.get("package"), dict) else {}
|
|
139
|
+
return {
|
|
140
|
+
"path": manifest_path.as_posix(),
|
|
141
|
+
"packageId": package.get("id"),
|
|
142
|
+
"planRevision": manifest.get("planRevision"),
|
|
143
|
+
"planDigest": canonical_digest(manifest),
|
|
144
|
+
"status": manifest.get("status"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _blocked_next_action(blockers: list[dict[str, Any]]) -> dict[str, Any]:
|
|
149
|
+
action = {
|
|
150
|
+
"schemaVersion": "agent-managed-lifecycle-next-action.v1",
|
|
151
|
+
"type": "blocked",
|
|
152
|
+
"status": "BLOCKED",
|
|
153
|
+
"hostActionRequired": False,
|
|
154
|
+
"modelCallsStarted": MODEL_CALLS_STARTED,
|
|
155
|
+
"stateMutationRequired": False,
|
|
156
|
+
"projectedAction": None,
|
|
157
|
+
"taskIds": [],
|
|
158
|
+
"blockers": blockers,
|
|
159
|
+
}
|
|
160
|
+
return {**action, "actionDigest": canonical_digest(action)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _blocker(code: str, message: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
164
|
+
blocker: dict[str, Any] = {"code": code, "message": message}
|
|
165
|
+
if context:
|
|
166
|
+
blocker["context"] = context
|
|
167
|
+
return blocker
|