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,140 @@
|
|
|
1
|
+
"""Budget exceeded policy validation and deterministic action selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest
|
|
8
|
+
|
|
9
|
+
ALLOWED_BUDGET_ACTIONS = {
|
|
10
|
+
"continue-same-route",
|
|
11
|
+
"reroute-cheaper",
|
|
12
|
+
"reroute-stronger",
|
|
13
|
+
"split-task",
|
|
14
|
+
"abort",
|
|
15
|
+
}
|
|
16
|
+
BUDGET_MODES = {"metered", "subscription", "local"}
|
|
17
|
+
CRITICAL_DOWNGRADE_ACTIONS = {"reroute-cheaper"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_budget_exceeded_policy(policy: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
if policy.get("schemaVersion") != "agent-lifecycle-budget-exceeded-policy.v1":
|
|
22
|
+
raise LifecycleError("invalid-budget-policy", "unsupported budget exceeded policy schema")
|
|
23
|
+
mode = _required_str(policy, "mode")
|
|
24
|
+
if mode not in {"manual", "auto"}:
|
|
25
|
+
raise LifecycleError("invalid-budget-policy", "mode must be manual or auto", {"mode": mode})
|
|
26
|
+
allowed_actions = _required_string_list(policy, "allowedActions")
|
|
27
|
+
unknown = sorted(set(allowed_actions) - ALLOWED_BUDGET_ACTIONS)
|
|
28
|
+
if unknown:
|
|
29
|
+
raise LifecycleError("invalid-budget-policy", "allowedActions contains unsupported actions", {"actions": unknown})
|
|
30
|
+
if not allowed_actions:
|
|
31
|
+
raise LifecycleError("invalid-budget-policy", "allowedActions must not be empty")
|
|
32
|
+
forbid_critical = policy.get("forbidDowngradeForCriticalReview")
|
|
33
|
+
if not isinstance(forbid_critical, bool):
|
|
34
|
+
raise LifecycleError("invalid-budget-policy", "forbidDowngradeForCriticalReview must be boolean")
|
|
35
|
+
max_auto = policy.get("maxAutoReroutesPerTask")
|
|
36
|
+
if not isinstance(max_auto, int) or isinstance(max_auto, bool) or max_auto < 0:
|
|
37
|
+
raise LifecycleError("invalid-budget-policy", "maxAutoReroutesPerTask must be a non-negative integer")
|
|
38
|
+
default_auto = policy.get("defaultAutoAction")
|
|
39
|
+
if mode == "auto" and default_auto not in allowed_actions:
|
|
40
|
+
raise LifecycleError("invalid-budget-policy", "defaultAutoAction must be one of allowedActions")
|
|
41
|
+
budget_modes = policy.get("budgetModes")
|
|
42
|
+
if not isinstance(budget_modes, dict):
|
|
43
|
+
raise LifecycleError("invalid-budget-policy", "budgetModes must be an object")
|
|
44
|
+
mode_checks = [_budget_mode_check(name, budget_modes.get(name)) for name in sorted(BUDGET_MODES)]
|
|
45
|
+
if any(item["status"] == "FAIL" for item in mode_checks):
|
|
46
|
+
raise LifecycleError("invalid-budget-policy", "budget mode caps are invalid", {"checks": mode_checks})
|
|
47
|
+
return {
|
|
48
|
+
"schemaVersion": "agent-lifecycle-budget-exceeded-policy-validation.v1",
|
|
49
|
+
"status": "PASS",
|
|
50
|
+
"mode": mode,
|
|
51
|
+
"allowedActions": allowed_actions,
|
|
52
|
+
"policyDigest": canonical_digest(policy),
|
|
53
|
+
"checks": mode_checks,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def select_auto_budget_action(
|
|
58
|
+
policy: dict[str, Any],
|
|
59
|
+
*,
|
|
60
|
+
task: dict[str, Any],
|
|
61
|
+
route_decision: dict[str, Any],
|
|
62
|
+
) -> str:
|
|
63
|
+
validation = validate_budget_exceeded_policy(policy)
|
|
64
|
+
if validation["mode"] != "auto":
|
|
65
|
+
raise LifecycleError("invalid-budget-policy", "auto action selection requires auto mode")
|
|
66
|
+
reroutes = int(task.get("budgetAutoReroutes", 0))
|
|
67
|
+
if reroutes >= int(policy["maxAutoReroutesPerTask"]):
|
|
68
|
+
raise LifecycleError("budget-auto-reroute-limit", "max auto reroutes per task exhausted")
|
|
69
|
+
action = str(policy["defaultAutoAction"])
|
|
70
|
+
if (
|
|
71
|
+
policy.get("forbidDowngradeForCriticalReview") is True
|
|
72
|
+
and route_decision.get("criticalReview") is True
|
|
73
|
+
and action in CRITICAL_DOWNGRADE_ACTIONS
|
|
74
|
+
):
|
|
75
|
+
for candidate in ("reroute-stronger", "split-task", "continue-same-route", "abort"):
|
|
76
|
+
if candidate in policy["allowedActions"]:
|
|
77
|
+
return candidate
|
|
78
|
+
raise LifecycleError("budget-critical-downgrade", "critical review cannot auto-downgrade to a cheaper route")
|
|
79
|
+
return action
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _budget_mode_check(mode: str, config: Any) -> dict[str, Any]:
|
|
83
|
+
if not isinstance(config, dict):
|
|
84
|
+
return {"id": f"{mode}-cap-config", "status": "FAIL", "reason": "missing"}
|
|
85
|
+
if mode == "metered":
|
|
86
|
+
cap = config.get("budgetCapUsd")
|
|
87
|
+
threshold = config.get("meteredAskThreshold")
|
|
88
|
+
has_cap = _positive_number(cap)
|
|
89
|
+
threshold_present = threshold is not None
|
|
90
|
+
threshold_valid = (
|
|
91
|
+
not threshold_present
|
|
92
|
+
or (_positive_number(threshold) and has_cap and float(threshold) < float(cap))
|
|
93
|
+
)
|
|
94
|
+
return {
|
|
95
|
+
"id": "metered-usd-cap",
|
|
96
|
+
"status": "PASS" if has_cap and threshold_valid else "FAIL",
|
|
97
|
+
"hasBudgetCapUsd": has_cap,
|
|
98
|
+
"advisoryThresholdEnabled": threshold_present and threshold_valid,
|
|
99
|
+
"advisoryOnly": threshold_present,
|
|
100
|
+
}
|
|
101
|
+
if "meteredAskThreshold" in config:
|
|
102
|
+
return {
|
|
103
|
+
"id": f"{mode}-resource-caps",
|
|
104
|
+
"status": "FAIL",
|
|
105
|
+
"reason": "meteredAskThreshold is only valid for metered budget mode",
|
|
106
|
+
}
|
|
107
|
+
max_invocations = config.get("maxInvocations")
|
|
108
|
+
has_invocations = isinstance(max_invocations, int) and not isinstance(max_invocations, bool) and max_invocations > 0
|
|
109
|
+
has_tokens = (
|
|
110
|
+
isinstance(config.get("maxBillableTokens"), int)
|
|
111
|
+
and not isinstance(config.get("maxBillableTokens"), bool)
|
|
112
|
+
and config["maxBillableTokens"] > 0
|
|
113
|
+
)
|
|
114
|
+
has_wall = _positive_number(config.get("maxWallSeconds"))
|
|
115
|
+
return {
|
|
116
|
+
"id": f"{mode}-resource-caps",
|
|
117
|
+
"status": "PASS" if has_invocations and (has_tokens or has_wall) else "FAIL",
|
|
118
|
+
"hasMaxInvocations": has_invocations,
|
|
119
|
+
"hasTokenOrWallCap": has_tokens or has_wall,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _required_str(payload: dict[str, Any], key: str) -> str:
|
|
124
|
+
value = payload.get(key)
|
|
125
|
+
if not isinstance(value, str) or not value:
|
|
126
|
+
raise LifecycleError("invalid-budget-policy", f"{key} is required")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _required_string_list(payload: dict[str, Any], key: str) -> list[str]:
|
|
131
|
+
value = payload.get(key)
|
|
132
|
+
if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
|
|
133
|
+
raise LifecycleError("invalid-budget-policy", f"{key} must be a list of non-empty strings")
|
|
134
|
+
if len(value) != len(set(value)):
|
|
135
|
+
raise LifecycleError("invalid-budget-policy", f"{key} contains duplicates")
|
|
136
|
+
return list(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _positive_number(value: Any) -> bool:
|
|
140
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Budget decision receipt construction helpers."""
|
|
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 build_budget_decision_receipt(
|
|
11
|
+
*,
|
|
12
|
+
state: dict[str, Any],
|
|
13
|
+
task: dict[str, Any],
|
|
14
|
+
route_decision: dict[str, Any],
|
|
15
|
+
usage_receipt: dict[str, Any],
|
|
16
|
+
usage_identity: dict[str, Any],
|
|
17
|
+
policy: dict[str, Any],
|
|
18
|
+
selected_action: str,
|
|
19
|
+
validation: dict[str, Any],
|
|
20
|
+
expected_workflow_revision: int,
|
|
21
|
+
operator_identity_hash: str | None = None,
|
|
22
|
+
prior_route_digest: str | None = None,
|
|
23
|
+
usage_receipt_digest: str | None = None,
|
|
24
|
+
policy_digest: str | None = None,
|
|
25
|
+
overrun_reason: str | None = None,
|
|
26
|
+
next_route_decision: dict[str, Any] | None = None,
|
|
27
|
+
next_route_identity: dict[str, Any] | None = None,
|
|
28
|
+
split_packet_identity: dict[str, Any] | None = None,
|
|
29
|
+
cap_deltas: dict[str, Any] | None = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
receipt = {
|
|
32
|
+
"schemaVersion": "agent-lifecycle-budget-decision-receipt.v1",
|
|
33
|
+
"runId": state["runId"],
|
|
34
|
+
"packageId": state["packageId"],
|
|
35
|
+
"taskId": task["id"],
|
|
36
|
+
"attemptId": f"{task['id']}:attempt-{task.get('attempt')}",
|
|
37
|
+
"attempt": task.get("attempt"),
|
|
38
|
+
"sourceRevision": state["sourceRevision"],
|
|
39
|
+
"priorRouteDecisionDigest": prior_route_digest or route_digest(route_decision),
|
|
40
|
+
"usageReceiptDigest": usage_receipt_digest or canonical_digest(usage_receipt),
|
|
41
|
+
"usageReceipt": usage_identity,
|
|
42
|
+
"overrunReason": overrun_reason or overrun_reason_from_validation(validation),
|
|
43
|
+
"decisionMode": policy["mode"],
|
|
44
|
+
"selectedAction": selected_action,
|
|
45
|
+
"allowedActions": list(policy["allowedActions"]),
|
|
46
|
+
"operatorIdentityHash": operator_identity_hash,
|
|
47
|
+
"policyDigest": policy_digest or canonical_digest(policy),
|
|
48
|
+
"nextRouteDecisionDigest": route_digest(next_route_decision) if next_route_decision is not None else None,
|
|
49
|
+
"splitPacketIdentity": split_packet_identity,
|
|
50
|
+
"capDeltas": dict(cap_deltas or {}),
|
|
51
|
+
"resumePhase": "RUNNING",
|
|
52
|
+
"expectedWorkflowRevision": expected_workflow_revision,
|
|
53
|
+
"productionPromotionClaimed": False,
|
|
54
|
+
}
|
|
55
|
+
if next_route_identity is not None:
|
|
56
|
+
receipt["nextRouteDecision"] = next_route_identity
|
|
57
|
+
return receipt
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def route_digest(route_decision: dict[str, Any]) -> str:
|
|
61
|
+
return str(route_decision.get("decisionDigest") or canonical_digest(route_decision))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def overrun_reason_from_validation(validation: dict[str, Any]) -> str:
|
|
65
|
+
failed = [
|
|
66
|
+
str(check.get("id"))
|
|
67
|
+
for check in validation.get("checks", [])
|
|
68
|
+
if isinstance(check, dict) and check.get("status") == "FAIL"
|
|
69
|
+
]
|
|
70
|
+
return ",".join(failed) or "budget-overrun"
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Workflow gate helpers for the optional bug-forensics profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_lifecycle.audit.proof_integrity import validate_fix_impact_receipt
|
|
8
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest
|
|
9
|
+
from agent_lifecycle.quality.bug_forensics import (
|
|
10
|
+
build_bug_forensics_profile,
|
|
11
|
+
validate_bug_forensics_profile,
|
|
12
|
+
validate_bug_reproduction_receipt,
|
|
13
|
+
validate_failure_fingerprint,
|
|
14
|
+
validate_hypothesis_ledger,
|
|
15
|
+
validate_regression_proof_receipt,
|
|
16
|
+
)
|
|
17
|
+
from agent_lifecycle.quality.cross_check import validate_cross_check_receipt
|
|
18
|
+
from agent_lifecycle.quality.failure_classification import (
|
|
19
|
+
HIGH_RISK_FAILURE_CLASSES,
|
|
20
|
+
validate_failure_classification_receipt,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
BUG_FORENSICS_GATE_RECEIPT_SCHEMA = "agent-bug-forensics-gate-receipt.v1"
|
|
24
|
+
BUG_FORENSICS_GATE_VALIDATION_SCHEMA = "agent-bug-forensics-gate-validation.v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bug_forensics_activated(task: dict[str, Any]) -> bool:
|
|
28
|
+
"""Return true only when the task explicitly enables the optional profile."""
|
|
29
|
+
|
|
30
|
+
if not isinstance(task, dict):
|
|
31
|
+
return False
|
|
32
|
+
if task.get("qualityProfile") == "bug-forensics":
|
|
33
|
+
return True
|
|
34
|
+
profiles = task.get("qualityProfiles")
|
|
35
|
+
if isinstance(profiles, list) and "bug-forensics" in profiles:
|
|
36
|
+
return True
|
|
37
|
+
config = task.get("bugForensics")
|
|
38
|
+
return isinstance(config, dict) and config.get("enabled") is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_bug_forensics_gate_receipt(
|
|
42
|
+
*,
|
|
43
|
+
task: dict[str, Any],
|
|
44
|
+
profile: dict[str, Any] | None = None,
|
|
45
|
+
reproduction_receipt: dict[str, Any] | None = None,
|
|
46
|
+
failure_fingerprint: dict[str, Any] | None = None,
|
|
47
|
+
failure_classification: dict[str, Any] | None = None,
|
|
48
|
+
flake_signal: dict[str, Any] | None = None,
|
|
49
|
+
hypothesis_ledger: dict[str, Any] | None = None,
|
|
50
|
+
regression_proof: dict[str, Any] | None = None,
|
|
51
|
+
fix_impact_receipt: dict[str, Any] | None = None,
|
|
52
|
+
cross_check_receipt: dict[str, Any] | None = None,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Build a gate receipt for an optional bug-forensics run."""
|
|
55
|
+
|
|
56
|
+
selected_profile = profile or build_bug_forensics_profile()
|
|
57
|
+
activated = bug_forensics_activated(task)
|
|
58
|
+
blockers: list[dict[str, Any]] = []
|
|
59
|
+
validations: dict[str, Any] = {"profile": validate_bug_forensics_profile(selected_profile)}
|
|
60
|
+
if validations["profile"]["status"] != "PASS":
|
|
61
|
+
blockers.append({"code": "bug-forensics-profile-invalid", "validation": validations["profile"]})
|
|
62
|
+
|
|
63
|
+
if not activated:
|
|
64
|
+
status = "SKIPPED"
|
|
65
|
+
else:
|
|
66
|
+
_collect_active_gate_blockers(
|
|
67
|
+
blockers,
|
|
68
|
+
validations,
|
|
69
|
+
task=task,
|
|
70
|
+
reproduction_receipt=reproduction_receipt,
|
|
71
|
+
failure_fingerprint=failure_fingerprint,
|
|
72
|
+
failure_classification=failure_classification,
|
|
73
|
+
flake_signal=flake_signal,
|
|
74
|
+
hypothesis_ledger=hypothesis_ledger,
|
|
75
|
+
regression_proof=regression_proof,
|
|
76
|
+
fix_impact_receipt=fix_impact_receipt,
|
|
77
|
+
cross_check_receipt=cross_check_receipt,
|
|
78
|
+
)
|
|
79
|
+
status = "PASS" if not blockers else "FAIL"
|
|
80
|
+
|
|
81
|
+
evidence = {
|
|
82
|
+
"reproduction": _digest_ref(reproduction_receipt, "receiptDigest"),
|
|
83
|
+
"failureFingerprint": _digest_ref(failure_fingerprint, "fingerprintDigest"),
|
|
84
|
+
"failureClassification": _digest_ref(failure_classification, "classificationDigest"),
|
|
85
|
+
"flakeSignal": _flake_signal_summary(flake_signal),
|
|
86
|
+
"hypothesisLedger": _digest_ref(hypothesis_ledger, "ledgerDigest"),
|
|
87
|
+
"regressionProof": _digest_ref(regression_proof, "proofDigest"),
|
|
88
|
+
"fixImpact": _digest_ref(fix_impact_receipt, "impactDigest"),
|
|
89
|
+
"crossCheck": _digest_ref(cross_check_receipt, "receiptDigest"),
|
|
90
|
+
"validations": validations,
|
|
91
|
+
}
|
|
92
|
+
body = {
|
|
93
|
+
"schemaVersion": BUG_FORENSICS_GATE_RECEIPT_SCHEMA,
|
|
94
|
+
"status": status,
|
|
95
|
+
"taskId": task.get("taskId") or task.get("id"),
|
|
96
|
+
"profileId": selected_profile.get("profileId", "bug-forensics"),
|
|
97
|
+
"activated": activated,
|
|
98
|
+
"chainVerified": activated and status == "PASS",
|
|
99
|
+
"evidence": evidence,
|
|
100
|
+
"blockers": blockers,
|
|
101
|
+
"phase2Deferred": list(selected_profile.get("phase2Deferred", [])),
|
|
102
|
+
"productionPromotionClaimed": False,
|
|
103
|
+
}
|
|
104
|
+
return {**body, "receiptDigest": canonical_digest(body)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_bug_forensics_gate_receipt(receipt: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
blockers: list[dict[str, Any]] = []
|
|
109
|
+
if not isinstance(receipt, dict):
|
|
110
|
+
raise LifecycleError("invalid-bug-forensics-gate-receipt", "bug-forensics gate receipt must be an object")
|
|
111
|
+
if receipt.get("schemaVersion") != BUG_FORENSICS_GATE_RECEIPT_SCHEMA:
|
|
112
|
+
blockers.append({"code": "bug-forensics-gate-schema-invalid"})
|
|
113
|
+
gate_status = receipt.get("status")
|
|
114
|
+
if gate_status not in {"PASS", "FAIL", "SKIPPED"}:
|
|
115
|
+
blockers.append({"code": "bug-forensics-gate-status-invalid", "status": gate_status})
|
|
116
|
+
if not isinstance(receipt.get("profileId"), str) or not receipt["profileId"]:
|
|
117
|
+
blockers.append({"code": "bug-forensics-gate-profile-id-invalid"})
|
|
118
|
+
if not isinstance(receipt.get("activated"), bool):
|
|
119
|
+
blockers.append({"code": "bug-forensics-gate-activation-invalid"})
|
|
120
|
+
if not isinstance(receipt.get("chainVerified"), bool):
|
|
121
|
+
blockers.append({"code": "bug-forensics-gate-chain-invalid"})
|
|
122
|
+
if receipt.get("activated") is True and gate_status == "PASS" and receipt.get("chainVerified") is not True:
|
|
123
|
+
blockers.append({"code": "bug-forensics-gate-chain-not-verified"})
|
|
124
|
+
if receipt.get("activated") is True and gate_status == "SKIPPED":
|
|
125
|
+
blockers.append({"code": "bug-forensics-gate-skipped-while-activated"})
|
|
126
|
+
if not isinstance(receipt.get("evidence"), dict):
|
|
127
|
+
blockers.append({"code": "bug-forensics-gate-evidence-invalid"})
|
|
128
|
+
if not isinstance(receipt.get("blockers"), list) or not all(isinstance(item, dict) for item in receipt.get("blockers", [])):
|
|
129
|
+
blockers.append({"code": "bug-forensics-gate-blockers-invalid"})
|
|
130
|
+
if receipt.get("productionPromotionClaimed") is not False:
|
|
131
|
+
blockers.append({"code": "bug-forensics-gate-production-claim"})
|
|
132
|
+
expected_digest = canonical_digest({key: value for key, value in receipt.items() if key != "receiptDigest"})
|
|
133
|
+
if receipt.get("receiptDigest") != expected_digest:
|
|
134
|
+
blockers.append({"code": "bug-forensics-gate-digest-mismatch"})
|
|
135
|
+
if gate_status == "FAIL" and not receipt.get("blockers"):
|
|
136
|
+
blockers.append({"code": "bug-forensics-gate-fail-without-blockers"})
|
|
137
|
+
body = {
|
|
138
|
+
"schemaVersion": BUG_FORENSICS_GATE_VALIDATION_SCHEMA,
|
|
139
|
+
"status": "PASS" if not blockers else "FAIL",
|
|
140
|
+
"gateStatus": gate_status if isinstance(gate_status, str) else None,
|
|
141
|
+
"blockers": blockers,
|
|
142
|
+
"receiptDigest": receipt.get("receiptDigest"),
|
|
143
|
+
}
|
|
144
|
+
return {**body, "validationDigest": canonical_digest(body)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def require_bug_forensics_gate_pass(validation: dict[str, Any]) -> dict[str, Any]:
|
|
148
|
+
if validation.get("status") != "PASS" or validation.get("gateStatus") not in {"PASS", "SKIPPED"}:
|
|
149
|
+
raise LifecycleError("bug-forensics-gate-validation-failed", "bug-forensics gate did not pass", {"validation": validation})
|
|
150
|
+
return validation
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _collect_active_gate_blockers(
|
|
154
|
+
blockers: list[dict[str, Any]],
|
|
155
|
+
validations: dict[str, Any],
|
|
156
|
+
*,
|
|
157
|
+
task: dict[str, Any],
|
|
158
|
+
reproduction_receipt: dict[str, Any] | None,
|
|
159
|
+
failure_fingerprint: dict[str, Any] | None,
|
|
160
|
+
failure_classification: dict[str, Any] | None,
|
|
161
|
+
flake_signal: dict[str, Any] | None,
|
|
162
|
+
hypothesis_ledger: dict[str, Any] | None,
|
|
163
|
+
regression_proof: dict[str, Any] | None,
|
|
164
|
+
fix_impact_receipt: dict[str, Any] | None,
|
|
165
|
+
cross_check_receipt: dict[str, Any] | None,
|
|
166
|
+
) -> None:
|
|
167
|
+
for key, value, validator, missing_code in [
|
|
168
|
+
("reproduction", reproduction_receipt, validate_bug_reproduction_receipt, "bug-forensics-reproduction-missing"),
|
|
169
|
+
("failureFingerprint", failure_fingerprint, validate_failure_fingerprint, "bug-forensics-fingerprint-missing"),
|
|
170
|
+
("hypothesisLedger", hypothesis_ledger, validate_hypothesis_ledger, "bug-forensics-hypothesis-ledger-missing"),
|
|
171
|
+
("regressionProof", regression_proof, validate_regression_proof_receipt, "bug-forensics-regression-proof-missing"),
|
|
172
|
+
]:
|
|
173
|
+
if value is None:
|
|
174
|
+
blockers.append({"code": missing_code})
|
|
175
|
+
continue
|
|
176
|
+
validation = validator(value)
|
|
177
|
+
validations[key] = validation
|
|
178
|
+
if validation.get("status") != "PASS":
|
|
179
|
+
blockers.append({"code": f"{key}-invalid", "validation": validation})
|
|
180
|
+
if fix_impact_receipt is None:
|
|
181
|
+
blockers.append({"code": "bug-forensics-fix-impact-missing"})
|
|
182
|
+
else:
|
|
183
|
+
validation = validate_fix_impact_receipt(fix_impact_receipt)
|
|
184
|
+
validations["fixImpact"] = validation
|
|
185
|
+
if validation.get("status") != "PASS":
|
|
186
|
+
blockers.append({"code": "bug-forensics-fix-impact-invalid", "validation": validation})
|
|
187
|
+
if failure_fingerprint is not None and regression_proof is not None:
|
|
188
|
+
before = regression_proof.get("before") if isinstance(regression_proof, dict) else {}
|
|
189
|
+
if isinstance(before, dict) and before.get("fingerprintDigest") != failure_fingerprint.get("fingerprintDigest"):
|
|
190
|
+
blockers.append({"code": "bug-forensics-fingerprint-regression-mismatch"})
|
|
191
|
+
if _classification_required(task) and failure_classification is None:
|
|
192
|
+
blockers.append({"code": "bug-forensics-failure-classification-missing"})
|
|
193
|
+
if failure_classification is not None:
|
|
194
|
+
validation = validate_failure_classification_receipt(failure_classification)
|
|
195
|
+
validations["failureClassification"] = validation
|
|
196
|
+
if validation.get("status") != "PASS" or validation.get("receiptStatus") != "PASS":
|
|
197
|
+
blockers.append({"code": "bug-forensics-failure-classification-invalid", "validation": validation})
|
|
198
|
+
if flake_signal is not None:
|
|
199
|
+
validations["flakeSignal"] = _validate_flake_signal(flake_signal)
|
|
200
|
+
if validations["flakeSignal"]["status"] != "PASS":
|
|
201
|
+
blockers.append({"code": "bug-forensics-flake-signal-invalid", "validation": validations["flakeSignal"]})
|
|
202
|
+
if _cross_check_required(task, failure_classification):
|
|
203
|
+
if cross_check_receipt is None:
|
|
204
|
+
blockers.append({"code": "bug-forensics-cross-check-missing"})
|
|
205
|
+
else:
|
|
206
|
+
validation = validate_cross_check_receipt(cross_check_receipt)
|
|
207
|
+
validations["crossCheck"] = validation
|
|
208
|
+
if validation.get("status") != "PASS" or validation.get("receiptStatus") != "PASS":
|
|
209
|
+
blockers.append({"code": "bug-forensics-cross-check-invalid", "validation": validation})
|
|
210
|
+
elif cross_check_receipt is not None:
|
|
211
|
+
validation = validate_cross_check_receipt(cross_check_receipt)
|
|
212
|
+
validations["crossCheck"] = validation
|
|
213
|
+
if validation.get("status") != "PASS":
|
|
214
|
+
blockers.append({"code": "bug-forensics-cross-check-invalid", "validation": validation})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _cross_check_required(task: dict[str, Any], failure_classification: dict[str, Any] | None = None) -> bool:
|
|
218
|
+
if task.get("blockingCrossCheckRequired") is True or task.get("crossCheckRequired") is True:
|
|
219
|
+
return True
|
|
220
|
+
config = task.get("bugForensics")
|
|
221
|
+
if isinstance(config, dict) and config.get("crossCheckRequired") is True:
|
|
222
|
+
return True
|
|
223
|
+
if _high_risk_classification(task, failure_classification):
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _classification_required(task: dict[str, Any]) -> bool:
|
|
229
|
+
config = task.get("bugForensics")
|
|
230
|
+
if isinstance(config, dict) and config.get("requireFailureClassification") is True:
|
|
231
|
+
return True
|
|
232
|
+
return task.get("requireFailureClassification") is True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _high_risk_classification(task: dict[str, Any], failure_classification: dict[str, Any] | None) -> bool:
|
|
236
|
+
if not isinstance(failure_classification, dict):
|
|
237
|
+
return False
|
|
238
|
+
if failure_classification.get("failureClass") not in HIGH_RISK_FAILURE_CLASSES:
|
|
239
|
+
return False
|
|
240
|
+
risks = task.get("riskFlags", {})
|
|
241
|
+
active_security = bool(risks.get("security")) if isinstance(risks, dict) else "security" in risks if isinstance(risks, list) else False
|
|
242
|
+
return task.get("sddTier") == "S2" or active_security
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _digest_ref(value: dict[str, Any] | None, key: str) -> dict[str, Any] | None:
|
|
246
|
+
if value is None:
|
|
247
|
+
return None
|
|
248
|
+
return {"schemaVersion": value.get("schemaVersion"), "digest": value.get(key)}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _flake_signal_summary(value: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
252
|
+
if value is None:
|
|
253
|
+
return None
|
|
254
|
+
return {
|
|
255
|
+
"status": value.get("status") or value.get("flakeStatus"),
|
|
256
|
+
"runs": value.get("runs"),
|
|
257
|
+
"failures": value.get("failures"),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validate_flake_signal(value: dict[str, Any]) -> dict[str, Any]:
|
|
262
|
+
blockers: list[dict[str, Any]] = []
|
|
263
|
+
status = value.get("status") or value.get("flakeStatus")
|
|
264
|
+
if status not in {"stable-fail", "stable-pass", "flaky", "inconclusive"}:
|
|
265
|
+
blockers.append({"code": "flake-signal-status-invalid", "status": status})
|
|
266
|
+
for field in ("runs", "failures"):
|
|
267
|
+
if field in value and (not isinstance(value[field], int) or isinstance(value[field], bool) or value[field] < 0):
|
|
268
|
+
blockers.append({"code": "flake-signal-count-invalid", "field": field})
|
|
269
|
+
body = {
|
|
270
|
+
"schemaVersion": "agent-flake-signal-validation.v1",
|
|
271
|
+
"status": "PASS" if not blockers else "FAIL",
|
|
272
|
+
"flakeStatus": status if isinstance(status, str) else None,
|
|
273
|
+
"blockers": blockers,
|
|
274
|
+
}
|
|
275
|
+
return {**body, "validationDigest": canonical_digest(body)}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = [
|
|
279
|
+
"BUG_FORENSICS_GATE_RECEIPT_SCHEMA",
|
|
280
|
+
"build_bug_forensics_gate_receipt",
|
|
281
|
+
"bug_forensics_activated",
|
|
282
|
+
"require_bug_forensics_gate_pass",
|
|
283
|
+
"validate_bug_forensics_gate_receipt",
|
|
284
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Compatibility facade for the standalone workflow controller API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_lifecycle.workflow.finalization import finalize_run
|
|
6
|
+
from agent_lifecycle.workflow.lineage import check_lineage
|
|
7
|
+
from agent_lifecycle.workflow.budget_decisions import (
|
|
8
|
+
apply_budget_decision,
|
|
9
|
+
pause_for_budget_decision,
|
|
10
|
+
select_auto_budget_action,
|
|
11
|
+
validate_budget_exceeded_policy,
|
|
12
|
+
)
|
|
13
|
+
from agent_lifecycle.workflow.query import next_action, status
|
|
14
|
+
from agent_lifecycle.workflow.plan_adoption import adopt_plan, start_execution
|
|
15
|
+
from agent_lifecycle.workflow.run_transitions import (
|
|
16
|
+
block_run,
|
|
17
|
+
pause_for_external_action,
|
|
18
|
+
resolve_blocker,
|
|
19
|
+
resume_external_action,
|
|
20
|
+
)
|
|
21
|
+
from agent_lifecycle.workflow.task_transitions import (
|
|
22
|
+
accept_task,
|
|
23
|
+
commit_task_result,
|
|
24
|
+
start_task,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"accept_task",
|
|
29
|
+
"adopt_plan",
|
|
30
|
+
"apply_budget_decision",
|
|
31
|
+
"block_run",
|
|
32
|
+
"check_lineage",
|
|
33
|
+
"commit_task_result",
|
|
34
|
+
"finalize_run",
|
|
35
|
+
"next_action",
|
|
36
|
+
"pause_for_budget_decision",
|
|
37
|
+
"pause_for_external_action",
|
|
38
|
+
"resolve_blocker",
|
|
39
|
+
"resume_external_action",
|
|
40
|
+
"select_auto_budget_action",
|
|
41
|
+
"start_execution",
|
|
42
|
+
"start_task",
|
|
43
|
+
"status",
|
|
44
|
+
"validate_budget_exceeded_policy",
|
|
45
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Append-only workflow event log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_bytes
|
|
10
|
+
from agent_lifecycle.workflow.state import now_iso
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def event_log_path(state_path: Path, state: dict[str, Any]) -> Path:
|
|
14
|
+
raw = state.get("eventLog")
|
|
15
|
+
if not isinstance(raw, str) or not raw:
|
|
16
|
+
raise LifecycleError("invalid-workflow-state", "eventLog is required")
|
|
17
|
+
raw_path = Path(raw)
|
|
18
|
+
if raw_path.is_absolute():
|
|
19
|
+
raise LifecycleError("invalid-workflow-state", "eventLog must be relative")
|
|
20
|
+
package_root = state.get("packageRoot")
|
|
21
|
+
if isinstance(package_root, str) and package_root:
|
|
22
|
+
return (state_path.parent / package_root / raw_path).resolve()
|
|
23
|
+
return state_path.parent / raw_path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def append_event(
|
|
27
|
+
*,
|
|
28
|
+
state_path: Path,
|
|
29
|
+
state: dict[str, Any],
|
|
30
|
+
operation_id: str,
|
|
31
|
+
event_type: str,
|
|
32
|
+
payload: dict[str, Any],
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
event = {
|
|
35
|
+
"schemaVersion": "agent-workflow-event.v1",
|
|
36
|
+
"runId": state.get("runId"),
|
|
37
|
+
"packageId": state.get("packageId"),
|
|
38
|
+
"stateRevision": state["stateRevision"],
|
|
39
|
+
"operationId": operation_id,
|
|
40
|
+
"eventType": event_type,
|
|
41
|
+
"payload": payload,
|
|
42
|
+
"recordedAt": now_iso(),
|
|
43
|
+
}
|
|
44
|
+
path = event_log_path(state_path, state)
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with path.open("ab") as handle:
|
|
47
|
+
handle.write(canonical_bytes(event) + b"\n")
|
|
48
|
+
handle.flush()
|
|
49
|
+
os.fsync(handle.fileno())
|
|
50
|
+
return event
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Final-proof integration for proof-integrity receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_lifecycle.audit.proof_integrity import (
|
|
8
|
+
require_proof_integrity_pass,
|
|
9
|
+
validate_proof_integrity_receipt,
|
|
10
|
+
)
|
|
11
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def proof_integrity_required(state: dict[str, Any], final_audit: dict[str, Any] | None = None) -> bool:
|
|
15
|
+
"""Return whether the current run explicitly requires proof-integrity evidence."""
|
|
16
|
+
|
|
17
|
+
policy = state.get("proofIntegrityPolicy")
|
|
18
|
+
if state.get("proofIntegrityRequired") is True:
|
|
19
|
+
return True
|
|
20
|
+
if isinstance(policy, dict) and policy.get("mode") in {"required", "bug-forensics", "strict"}:
|
|
21
|
+
return True
|
|
22
|
+
if isinstance(final_audit, dict):
|
|
23
|
+
if final_audit.get("proofIntegrityRequired") is True:
|
|
24
|
+
return True
|
|
25
|
+
evidence = final_audit.get("proofIntegrityEvidence")
|
|
26
|
+
if isinstance(evidence, dict) and evidence.get("required") is True:
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_final_proof_integrity(
|
|
32
|
+
*,
|
|
33
|
+
state: dict[str, Any],
|
|
34
|
+
final_audit: dict[str, Any],
|
|
35
|
+
receipt: dict[str, Any] | None,
|
|
36
|
+
final_proof: dict[str, Any] | None = None,
|
|
37
|
+
required: bool | None = None,
|
|
38
|
+
new_run: bool = True,
|
|
39
|
+
) -> dict[str, Any] | None:
|
|
40
|
+
"""Validate optional final-proof integrity evidence and fail closed when required."""
|
|
41
|
+
|
|
42
|
+
must_validate = proof_integrity_required(state, final_audit) if required is None else required
|
|
43
|
+
if receipt is None:
|
|
44
|
+
if must_validate:
|
|
45
|
+
raise LifecycleError(
|
|
46
|
+
"proof-integrity-receipt-missing",
|
|
47
|
+
"final proof requires proof-integrity receipt",
|
|
48
|
+
)
|
|
49
|
+
return None
|
|
50
|
+
validation = validate_proof_integrity_receipt(
|
|
51
|
+
receipt,
|
|
52
|
+
state=state,
|
|
53
|
+
final_audit=final_audit,
|
|
54
|
+
final_proof=final_proof,
|
|
55
|
+
new_run=new_run,
|
|
56
|
+
)
|
|
57
|
+
require_proof_integrity_pass(validation)
|
|
58
|
+
if isinstance(final_proof, dict) and isinstance(final_proof.get("proofIntegrity"), dict):
|
|
59
|
+
_validate_embedded_identity(final_proof["proofIntegrity"], receipt)
|
|
60
|
+
return validation
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_embedded_identity(proof_integrity: dict[str, Any], receipt: dict[str, Any]) -> None:
|
|
64
|
+
receipt_identity = proof_integrity.get("receipt")
|
|
65
|
+
if not isinstance(receipt_identity, dict):
|
|
66
|
+
raise LifecycleError("proof-integrity-final-proof-mismatch", "final proof proofIntegrity.receipt must be an object")
|
|
67
|
+
if receipt_identity.get("sha256") != canonical_digest(receipt):
|
|
68
|
+
raise LifecycleError("proof-integrity-final-proof-mismatch", "final proof proofIntegrity receipt digest mismatch")
|
|
69
|
+
validation = proof_integrity.get("validation")
|
|
70
|
+
if not isinstance(validation, dict) or validation.get("receiptDigest") != receipt.get("receiptDigest"):
|
|
71
|
+
raise LifecycleError("proof-integrity-final-proof-mismatch", "final proof proofIntegrity validation mismatch")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["proof_integrity_required", "validate_final_proof_integrity"]
|