engineering-platform 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +32 -0
  2. engineering_platform/ENGINEERING_PLATFORM_VERSION.json +15 -0
  3. engineering_platform/__init__.py +1 -0
  4. engineering_platform/__main__.py +7 -0
  5. engineering_platform/agent_state.py +530 -0
  6. engineering_platform/agent_trust.py +174 -0
  7. engineering_platform/assets/dashboard.css +1317 -0
  8. engineering_platform/assets/dashboard.js +8534 -0
  9. engineering_platform/assets/dashboard_locales.mjs +4049 -0
  10. engineering_platform/assets/dashboard_status_store.mjs +41 -0
  11. engineering_platform/assets/operations-console/apple-touch-icon-dark.png +0 -0
  12. engineering_platform/assets/operations-console/apple-touch-icon-light.png +0 -0
  13. engineering_platform/assets/operations-console/icon-dark.png +0 -0
  14. engineering_platform/assets/operations-console/icon-light.png +0 -0
  15. engineering_platform/assets/operations-console/icon-transparent.png +0 -0
  16. engineering_platform/assets/operations-console/manifest.webmanifest +11 -0
  17. engineering_platform/capability_preflight.py +285 -0
  18. engineering_platform/capability_review.py +261 -0
  19. engineering_platform/central_data_transfer.py +195 -0
  20. engineering_platform/central_database.py +245 -0
  21. engineering_platform/central_store_migration.py +1672 -0
  22. engineering_platform/codex_capacity.py +81 -0
  23. engineering_platform/codex_chat.py +226 -0
  24. engineering_platform/codex_observability.py +153 -0
  25. engineering_platform/component_lock.py +40 -0
  26. engineering_platform/component_logging.py +420 -0
  27. engineering_platform/console_presentation.py +14 -0
  28. engineering_platform/console_route_ownership.py +83 -0
  29. engineering_platform/contracts/__init__.py +38 -0
  30. engineering_platform/contracts/ep_consumer.py +391 -0
  31. engineering_platform/contracts/models.py +105 -0
  32. engineering_platform/contracts/projection.py +401 -0
  33. engineering_platform/dashboard_browser_validation.py +206 -0
  34. engineering_platform/dashboard_state.py +630 -0
  35. engineering_platform/dashboard_supervisor.swift +105 -0
  36. engineering_platform/dashboard_translation.py +129 -0
  37. engineering_platform/dependabot_producer.py +349 -0
  38. engineering_platform/drift_diagnostics.py +144 -0
  39. engineering_platform/emergency_recovery.py +268 -0
  40. engineering_platform/engineering_memory.py +139 -0
  41. engineering_platform/ep_consumer_credentials.py +473 -0
  42. engineering_platform/evidence_projection.py +213 -0
  43. engineering_platform/execution_activity.py +218 -0
  44. engineering_platform/execution_context.py +132 -0
  45. engineering_platform/execution_errors.py +42 -0
  46. engineering_platform/execution_evidence.py +24 -0
  47. engineering_platform/execution_executor.py +730 -0
  48. engineering_platform/execution_finalization.py +44 -0
  49. engineering_platform/execution_host.py +3306 -0
  50. engineering_platform/execution_lease.py +365 -0
  51. engineering_platform/execution_lifecycle.py +447 -0
  52. engineering_platform/execution_models.py +43 -0
  53. engineering_platform/execution_readiness.py +166 -0
  54. engineering_platform/execution_reporting.py +1607 -0
  55. engineering_platform/execution_repository.py +253 -0
  56. engineering_platform/execution_timeout_policy.py +56 -0
  57. engineering_platform/execution_timing.py +440 -0
  58. engineering_platform/execution_transaction.py +28 -0
  59. engineering_platform/external_producer_binding.py +235 -0
  60. engineering_platform/file_inbox.py +249 -0
  61. engineering_platform/forensic_attribution.py +338 -0
  62. engineering_platform/forensic_attribution_v2.py +134 -0
  63. engineering_platform/forensic_delta.py +299 -0
  64. engineering_platform/golden_scenario.py +63 -0
  65. engineering_platform/historical_dashboard_configuration.py +171 -0
  66. engineering_platform/host_admin.py +199 -0
  67. engineering_platform/host_preflight.py +231 -0
  68. engineering_platform/installation_relocation.py +122 -0
  69. engineering_platform/investigation_ledger.py +89 -0
  70. engineering_platform/legacy_inbox_migration.py +79 -0
  71. engineering_platform/lifecycle_worker.py +223 -0
  72. engineering_platform/live_status.py +267 -0
  73. engineering_platform/local_api.py +209 -0
  74. engineering_platform/local_api_keychain.py +51 -0
  75. engineering_platform/local_repository_binding.py +138 -0
  76. engineering_platform/managed_autonomy.py +509 -0
  77. engineering_platform/managed_codex_runtime.py +105 -0
  78. engineering_platform/parity_context.py +203 -0
  79. engineering_platform/parity_lifecycle_dispatcher.py +488 -0
  80. engineering_platform/platform_admin.py +13 -0
  81. engineering_platform/platform_api.py +428 -0
  82. engineering_platform/platform_bootstrap.py +385 -0
  83. engineering_platform/platform_components.py +65 -0
  84. engineering_platform/platform_version.py +171 -0
  85. engineering_platform/pr_check_repair.py +276 -0
  86. engineering_platform/pr_evidence_backfill.py +278 -0
  87. engineering_platform/producer.py +209 -0
  88. engineering_platform/project_agent.py +366 -0
  89. engineering_platform/project_agent_service.py +244 -0
  90. engineering_platform/project_topology.py +126 -0
  91. engineering_platform/prompt_history.py +591 -0
  92. engineering_platform/provider_context.py +136 -0
  93. engineering_platform/provider_context_benchmark.py +41 -0
  94. engineering_platform/provider_context_scope.py +90 -0
  95. engineering_platform/provider_interruption.py +168 -0
  96. engineering_platform/provider_process_identity.py +80 -0
  97. engineering_platform/provider_readiness.py +138 -0
  98. engineering_platform/provider_recovery.py +647 -0
  99. engineering_platform/provider_usage.py +497 -0
  100. engineering_platform/providers.py +471 -0
  101. engineering_platform/qualification.py +220 -0
  102. engineering_platform/recommendation_handoff.py +238 -0
  103. engineering_platform/report_analysis.py +193 -0
  104. engineering_platform/repository_attachment.py +171 -0
  105. engineering_platform/repository_handoff.py +95 -0
  106. engineering_platform/resources.py +38 -0
  107. engineering_platform/reviewer_evidence.py +70 -0
  108. engineering_platform/schemas/repository-attachment.schema.json +61 -0
  109. engineering_platform/server.py +3679 -0
  110. engineering_platform/server_console_services.py +2024 -0
  111. engineering_platform/server_relay.py +172 -0
  112. engineering_platform/server_service.py +122 -0
  113. engineering_platform/status_model.py +135 -0
  114. engineering_platform/status_reconciliation.py +34 -0
  115. engineering_platform/storage.py +2440 -0
  116. engineering_platform/submission_cli.py +77 -0
  117. engineering_platform/submission_intake.py +45 -0
  118. engineering_platform/submission_service.py +317 -0
  119. engineering_platform/telemetry.py +951 -0
  120. engineering_platform/templates/workspace-config.json +25 -0
  121. engineering_platform/validation_identity.py +50 -0
  122. engineering_platform/validation_profile.py +211 -0
  123. engineering_platform/workspace_preflight.py +263 -0
  124. engineering_platform/worktree_provenance.py +147 -0
  125. engineering_platform/worktree_tooling.py +18 -0
  126. engineering_platform-2.2.0.dist-info/METADATA +18 -0
  127. engineering_platform-2.2.0.dist-info/RECORD +130 -0
  128. engineering_platform-2.2.0.dist-info/WHEEL +5 -0
  129. engineering_platform-2.2.0.dist-info/entry_points.txt +6 -0
  130. engineering_platform-2.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ """Producer Contract parsing for producer-neutral Execution Host metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import re
8
+
9
+ from .recommendation_handoff import ForgeGovernanceHandoffError, validate_forge_governance_handoff
10
+ from .validation_profile import ValidationProfileResolutionError, resolve_producer_profile
11
+
12
+
13
+ _FIELD_LIMIT = 160
14
+ _PRODUCER_TYPES = frozenset({"HUMAN", "FORGE", "EXTERNAL", "UNKNOWN"})
15
+ _FIELD_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,159}$")
16
+ ENVELOPE_CONTRACT_NAME = "engineering_platform.producer_submission"
17
+ ENVELOPE_CONTRACT_VERSION = "1.0"
18
+ # This is accepted only to ingest immutable predecessor evidence during the
19
+ # transition. EP never emits it as a current producer contract.
20
+ LEGACY_ENVELOPE_CONTRACT_NAME = "djconnect.producer_submission"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ProducerMetadata:
25
+ """Immutable provenance supplied by a Producer, never execution input."""
26
+
27
+ producer_id: str = "legacy"
28
+ producer_type: str = "HUMAN"
29
+ producer_version: str | None = None
30
+ correlation_id: str | None = None
31
+ mission_id: str | None = None
32
+ engineering_action_id: str | None = None
33
+ execution_constraint_version: str | None = None
34
+
35
+
36
+ class ProducerSubmissionError(ValueError):
37
+ """Raised when a JSON Producer Submission Envelope is not safe to claim."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ProducerSubmission:
42
+ """A validated, producer-owned submission with no derived runtime semantics."""
43
+
44
+ prompt: str
45
+ producer: ProducerMetadata
46
+ submission_id: str | None
47
+ contract_version: str | None
48
+ execution_context: dict[str, object] | None
49
+ forge_governance_handoff: dict[str, object] | None
50
+ envelope: dict[str, object]
51
+ is_legacy: bool
52
+
53
+
54
+ def _object(value: object, label: str) -> dict[str, object]:
55
+ if not isinstance(value, dict):
56
+ raise ProducerSubmissionError(f"Producer Submission Envelope {label} must be an object.")
57
+ return value
58
+
59
+
60
+ def _required_token(value: object, label: str) -> str:
61
+ normalized = _value(value)
62
+ if normalized is None:
63
+ raise ProducerSubmissionError(f"Producer Submission Envelope {label} is invalid.")
64
+ return normalized
65
+
66
+
67
+ def _optional_token(value: object, label: str) -> str | None:
68
+ if value is None:
69
+ return None
70
+ return _required_token(value, label)
71
+
72
+
73
+ def _optional_object(value: object, label: str) -> None:
74
+ if value is not None and not isinstance(value, dict):
75
+ raise ProducerSubmissionError(f"Producer Submission Envelope {label} must be an object when supplied.")
76
+
77
+
78
+ def parse_producer_submission(content: str) -> ProducerSubmission:
79
+ """Validate one atomic Producer Submission Envelope or map a legacy prompt.
80
+
81
+ JSON-looking submissions are always envelopes and fail closed. Plain text
82
+ remains the canonical Human Producer compatibility path. This function
83
+ never interprets prompt text or derives Execution Context semantics.
84
+ """
85
+ if not isinstance(content, str) or not content.strip():
86
+ raise ProducerSubmissionError("Producer submission must contain a prompt.")
87
+ if not content.lstrip().startswith("{"):
88
+ return ProducerSubmission(
89
+ prompt=content,
90
+ producer=parse_producer_metadata(content),
91
+ submission_id=None,
92
+ contract_version=None,
93
+ execution_context=None,
94
+ forge_governance_handoff=None,
95
+ envelope={"kind": "legacy_prompt", "prompt": content},
96
+ is_legacy=True,
97
+ )
98
+ try:
99
+ envelope = json.loads(content)
100
+ except json.JSONDecodeError as error:
101
+ raise ProducerSubmissionError("Producer Submission Envelope is not valid JSON.") from error
102
+ envelope = _object(envelope, "root")
103
+ contract = _object(envelope.get("contract"), "contract")
104
+ if contract.get("name") not in {ENVELOPE_CONTRACT_NAME, LEGACY_ENVELOPE_CONTRACT_NAME}:
105
+ raise ProducerSubmissionError("Producer Submission Envelope contract name is unsupported.")
106
+ contract_version = contract.get("version")
107
+ if contract_version != ENVELOPE_CONTRACT_VERSION:
108
+ raise ProducerSubmissionError("Producer Submission Envelope contract version is unsupported.")
109
+ submission = _object(envelope.get("submission"), "submission")
110
+ submission_id = _required_token(submission.get("id"), "submission.id")
111
+ _optional_object(submission.get("metadata"), "submission.metadata")
112
+ if submission.get("submitted_at") is not None and not isinstance(submission.get("submitted_at"), str):
113
+ raise ProducerSubmissionError("Producer Submission Envelope submission.submitted_at must be a string.")
114
+ producer_payload = _object(envelope.get("producer"), "producer")
115
+ producer_id = _required_token(producer_payload.get("id"), "producer.id")
116
+ producer_type = _required_token(producer_payload.get("type"), "producer.type").upper()
117
+ for name in ("version", "correlation_id", "mission_id", "engineering_action_id", "execution_constraint_version"):
118
+ _optional_token(producer_payload.get(name), f"producer.{name}")
119
+ _optional_object(producer_payload.get("metadata"), "producer.metadata")
120
+ prompt_payload = _object(envelope.get("prompt"), "prompt")
121
+ prompt = prompt_payload.get("text")
122
+ if not isinstance(prompt, str) or not prompt.strip():
123
+ raise ProducerSubmissionError("Producer Submission Envelope prompt.text must be a non-empty string.")
124
+ _optional_object(prompt_payload.get("metadata"), "prompt.metadata")
125
+ context = envelope.get("execution_context")
126
+ if context is not None:
127
+ context = _object(context, "execution_context")
128
+ _required_token(context.get("context_version"), "execution_context.context_version")
129
+ intent = context.get("action_intent")
130
+ if intent is not None and intent not in {"MUTATING_DELIVERY", "VALIDATION_ONLY"}:
131
+ raise ProducerSubmissionError("Producer Submission Envelope execution_context.action_intent is invalid.")
132
+ profile = context.get("validation_profile")
133
+ if intent == "VALIDATION_ONLY" and profile is None:
134
+ raise ProducerSubmissionError(
135
+ "Producer Submission Envelope execution_context.validation_profile is required for VALIDATION_ONLY."
136
+ )
137
+ if profile is not None:
138
+ profile = _object(profile, "execution_context.validation_profile")
139
+ _required_token(profile.get("tier"), "execution_context.validation_profile.tier")
140
+ _required_token(profile.get("version"), "execution_context.validation_profile.version")
141
+ controls = profile.get("required_controls")
142
+ if not isinstance(controls, list) or not controls or any(_optional_token(item, "execution_context.validation_profile.required_controls") is None for item in controls):
143
+ raise ProducerSubmissionError("Producer Submission Envelope execution_context.validation_profile.required_controls is invalid.")
144
+ try:
145
+ resolve_producer_profile(profile)
146
+ except ValidationProfileResolutionError as error:
147
+ raise ProducerSubmissionError(
148
+ "Producer Submission Envelope execution_context.validation_profile is invalid."
149
+ ) from error
150
+ handoff = envelope.get("forge_governance_handoff")
151
+ if handoff is not None:
152
+ try:
153
+ handoff = validate_forge_governance_handoff(handoff)
154
+ except ForgeGovernanceHandoffError as error:
155
+ raise ProducerSubmissionError(str(error)) from error
156
+ return ProducerSubmission(
157
+ prompt=prompt,
158
+ producer=ProducerMetadata(
159
+ producer_id=producer_id,
160
+ producer_type=producer_type,
161
+ producer_version=_optional_token(producer_payload.get("version"), "producer.version"),
162
+ correlation_id=_optional_token(producer_payload.get("correlation_id"), "producer.correlation_id"),
163
+ mission_id=_optional_token(producer_payload.get("mission_id"), "producer.mission_id"),
164
+ engineering_action_id=_optional_token(producer_payload.get("engineering_action_id"), "producer.engineering_action_id"),
165
+ execution_constraint_version=_optional_token(producer_payload.get("execution_constraint_version"), "producer.execution_constraint_version"),
166
+ ),
167
+ submission_id=submission_id,
168
+ contract_version=contract_version,
169
+ execution_context=context,
170
+ forge_governance_handoff=handoff,
171
+ envelope=envelope,
172
+ is_legacy=False,
173
+ )
174
+
175
+
176
+ def _value(raw: object) -> str | None:
177
+ if not isinstance(raw, str):
178
+ return None
179
+ normalized = raw.strip()
180
+ return normalized if _FIELD_PATTERN.fullmatch(normalized) else None
181
+
182
+
183
+ def _field(prompt: str, label: str) -> str | None:
184
+ match = re.search(rf"(?mi)^\s*{re.escape(label)}\s*:\s*([^\r\n]+?)\s*$", prompt)
185
+ return _value(match.group(1)) if match else None
186
+
187
+
188
+ def parse_producer_metadata(prompt: str) -> ProducerMetadata:
189
+ """Consume only declared Producer Contract metadata with legacy defaults.
190
+
191
+ The returned value is provenance. No caller may use it for admission,
192
+ scheduling, lifecycle, reviewer, or execution decisions.
193
+ """
194
+ producer_id = _field(prompt, "Producer ID") or "legacy"
195
+ raw_type = _field(prompt, "Producer Type")
196
+ producer_type = raw_type.upper() if raw_type else "HUMAN"
197
+ # The contract's known values are explicit, while a valid future producer
198
+ # token remains observable without forcing an Engineering Platform release.
199
+ if not _FIELD_PATTERN.fullmatch(producer_type):
200
+ producer_type = "UNKNOWN"
201
+ return ProducerMetadata(
202
+ producer_id=producer_id,
203
+ producer_type=producer_type if raw_type else "HUMAN",
204
+ producer_version=_field(prompt, "Producer Version"),
205
+ correlation_id=_field(prompt, "Producer Correlation ID") or _field(prompt, "Correlation ID"),
206
+ mission_id=_field(prompt, "Mission ID"),
207
+ engineering_action_id=_field(prompt, "Engineering Action ID"),
208
+ execution_constraint_version=_field(prompt, "Execution Constraint Version"),
209
+ )
@@ -0,0 +1,366 @@
1
+ """Standalone, local-only Project Agent foundation.
2
+
3
+ The Project Agent observes one Host/OS-user context and zero or more explicit
4
+ repository roots. It is an execution edge only: no durable execution state,
5
+ admission, scheduling, locking, or Server authority belongs here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from dataclasses import asdict, dataclass
12
+ from datetime import datetime, timezone
13
+ import getpass
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import platform
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from typing import Protocol, Sequence
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.request import Request, urlopen
24
+ from uuid import uuid4
25
+
26
+ from . import agent_trust
27
+ from .repository_attachment import RepositoryAttachmentError, load_repository_attachment
28
+
29
+
30
+ IDENTITY_FORMAT = "engineering-platform-project-agent/v1"
31
+ DEFAULT_TOOLCHAINS = ("python3", "node", "npm", "go", "cargo", "rustc", "java", "docker", "podman")
32
+ DEFAULT_PROVIDER_CLIS = ("gh", "glab", "az", "codex")
33
+ AGENT_CONFIGURATION_VERSION = 1
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HostIdentity:
38
+ """Observed Host/OS-user boundary; this is not a pairing credential."""
39
+
40
+ hostname: str
41
+ os_user: str
42
+ operating_system: str
43
+ architecture: str
44
+
45
+ @property
46
+ def context_key(self) -> str:
47
+ return ":".join((self.hostname, self.os_user, self.operating_system, self.architecture))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class AgentIdentity:
52
+ """Stable local installation identity reserved for B6 pairing."""
53
+
54
+ agent_id: str
55
+ identity_format: str
56
+ host_context_key: str
57
+ created_at: str
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ToolCapability:
62
+ name: str
63
+ available: bool
64
+ path: str | None
65
+ version: str | None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class CapabilitySnapshot:
70
+ host: HostIdentity
71
+ git: ToolCapability
72
+ toolchains: tuple[ToolCapability, ...]
73
+ provider_clis: tuple[ToolCapability, ...]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class RepositoryEnvironment:
78
+ """A local repository observation, not an attachment or execution claim."""
79
+
80
+ requested_root: str
81
+ resolved_root: str | None
82
+ exists: bool
83
+ is_git_repository: bool
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class AgentSnapshot:
88
+ identity: AgentIdentity
89
+ capabilities: CapabilitySnapshot
90
+ repositories: tuple[RepositoryEnvironment, ...]
91
+ observed_at: str
92
+
93
+ def payload(self) -> dict[str, object]:
94
+ value = asdict(self)
95
+ value["capabilities"]["host"]["context_key"] = self.capabilities.host.context_key
96
+ return value
97
+
98
+
99
+ class AgentControlPlaneClient(Protocol):
100
+ """B6 placeholder: transport and pairing are intentionally unspecified."""
101
+
102
+ def publish_observation(self, snapshot: AgentSnapshot) -> None: ...
103
+
104
+
105
+ def observe_host_identity() -> HostIdentity:
106
+ return HostIdentity(
107
+ hostname=platform.node() or "unknown-host",
108
+ os_user=getpass.getuser() or "unknown-user",
109
+ operating_system=platform.system() or "unknown-os",
110
+ architecture=platform.machine() or "unknown-architecture",
111
+ )
112
+
113
+
114
+ def default_identity_path() -> Path:
115
+ configured = os.environ.get("ENGINEERING_PLATFORM_AGENT_IDENTITY_PATH")
116
+ if configured:
117
+ return Path(configured).expanduser()
118
+ if sys.platform == "darwin":
119
+ return Path.home() / "Library" / "Application Support" / "Engineering Platform" / "Project Agent" / "identity.json"
120
+ return Path.home() / ".config" / "engineering-platform" / "project-agent-identity.json"
121
+
122
+
123
+ def default_configuration_path() -> Path:
124
+ return default_identity_path().with_name("project-agent-server.json")
125
+
126
+
127
+ def _write_private_json(path: Path, value: object) -> None:
128
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
129
+ temporary = path.with_suffix(path.suffix + ".tmp")
130
+ temporary.write_text(json.dumps(value, sort_keys=True) + "\n", encoding="utf-8")
131
+ temporary.chmod(0o600)
132
+ os.replace(temporary, path)
133
+
134
+
135
+ def _post(endpoint: str, route: str, body: dict[str, object], token: str | None = None) -> tuple[dict[str, object], str]:
136
+ if not endpoint.startswith("http://127.0.0.1:") and not endpoint.startswith("http://localhost:"):
137
+ raise ValueError("insecure non-loopback Server endpoint is forbidden")
138
+ headers = {"Content-Type": "application/json"}
139
+ if token: headers["Authorization"] = f"Bearer {token}"
140
+ request = Request(endpoint.rstrip("/") + route, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
141
+ try:
142
+ with urlopen(request, timeout=5) as response:
143
+ instance = response.headers.get("EP-Server-Instance")
144
+ raw = json.loads(response.read().decode("utf-8"))
145
+ except (HTTPError, URLError, OSError, json.JSONDecodeError) as error:
146
+ raise ValueError("EP Server request was rejected or unavailable") from error
147
+ if not isinstance(raw, dict) or not isinstance(instance, str) or not instance:
148
+ raise ValueError("EP Server identity response is invalid")
149
+ return raw, instance
150
+
151
+
152
+ def _attachment_reports(repository_roots: Sequence[Path]) -> list[dict[str, object]]:
153
+ reports: list[dict[str, object]] = []
154
+ for root in repository_roots:
155
+ try:
156
+ reports.append({"attachment": load_repository_attachment(root).agent_read_surface()})
157
+ except RepositoryAttachmentError:
158
+ continue
159
+ return reports
160
+
161
+
162
+ def pair(endpoint: str, pairing_code: str, *, identity_path: Path | None = None, configuration_path: Path | None = None) -> dict[str, str]:
163
+ snapshot = observe((), identity_path=identity_path)
164
+ response, instance = _post(endpoint, "/v1/agent/pair", {"protocol_version": agent_trust.PROTOCOL_VERSION, "agent_id": snapshot.identity.agent_id, "pairing_code": pairing_code})
165
+ credential = response.get("credential")
166
+ if not isinstance(credential, str) or response.get("agent_id") != snapshot.identity.agent_id:
167
+ raise ValueError("EP Server pairing response is invalid")
168
+ _write_private_json(configuration_path or default_configuration_path(), {"version": AGENT_CONFIGURATION_VERSION, "endpoint": endpoint.rstrip("/"), "server_instance_id": instance, "agent_id": snapshot.identity.agent_id, "credential": credential})
169
+ return {"agent_id": snapshot.identity.agent_id, "server_instance_id": instance, "paired": "true"}
170
+
171
+
172
+ def _configuration(path: Path | None = None) -> dict[str, str]:
173
+ try:
174
+ raw = json.loads((path or default_configuration_path()).read_text(encoding="utf-8"))
175
+ except (OSError, json.JSONDecodeError) as error:
176
+ raise ValueError("Agent pairing configuration is unavailable") from error
177
+ if not isinstance(raw, dict) or set(raw) != {"version", "endpoint", "server_instance_id", "agent_id", "credential"} or raw.get("version") != AGENT_CONFIGURATION_VERSION or not all(isinstance(raw.get(k), str) and raw[k] for k in ("endpoint", "server_instance_id", "agent_id", "credential")):
178
+ raise ValueError("Agent pairing configuration is invalid")
179
+ return raw # type: ignore[return-value]
180
+
181
+
182
+ def register(repository_roots: Sequence[Path] = (), *, identity_path: Path | None = None, configuration_path: Path | None = None) -> dict[str, object]:
183
+ config, snapshot = _configuration(configuration_path), observe(repository_roots, identity_path=identity_path)
184
+ if config["agent_id"] != snapshot.identity.agent_id:
185
+ raise ValueError("Agent installation identity differs from pairing configuration")
186
+ capabilities = snapshot.payload()["capabilities"]
187
+ response, instance = _post(config["endpoint"], "/v1/agent/register", {"protocol_version": agent_trust.PROTOCOL_VERSION, "agent_id": snapshot.identity.agent_id, "host": asdict(snapshot.capabilities.host), "capabilities": capabilities, "repositories": _attachment_reports(repository_roots)}, config["credential"])
188
+ if instance != config["server_instance_id"]:
189
+ raise ValueError("EP Server identity changed; re-pair explicitly")
190
+ return response
191
+
192
+
193
+ def heartbeat(*, configuration_path: Path | None = None) -> dict[str, object]:
194
+ config = _configuration(configuration_path)
195
+ response, instance = _post(config["endpoint"], "/v1/agent/heartbeat", {"protocol_version": agent_trust.PROTOCOL_VERSION, "agent_id": config["agent_id"]}, config["credential"])
196
+ if instance != config["server_instance_id"]:
197
+ raise ValueError("EP Server identity changed; re-pair explicitly")
198
+ return response
199
+
200
+
201
+ def attach(repository_root: Path, *, identity_path: Path | None = None, configuration_path: Path | None = None) -> dict[str, object]:
202
+ """Read one explicit root and register only its validated declaration.
203
+
204
+ The checkout path remains local to the Agent and is intentionally absent
205
+ from the request and Server topology.
206
+ """
207
+ config, snapshot = _configuration(configuration_path), observe((), identity_path=identity_path)
208
+ if config["agent_id"] != snapshot.identity.agent_id:
209
+ raise ValueError("Agent installation identity differs from pairing configuration")
210
+ try:
211
+ declaration = load_repository_attachment(repository_root).agent_read_surface()
212
+ except RepositoryAttachmentError as error:
213
+ raise ValueError("Repository attachment declaration is unavailable or invalid") from error
214
+ response, instance = _post(config["endpoint"], "/v1/agent/attachment", {"protocol_version": agent_trust.PROTOCOL_VERSION, "agent_id": snapshot.identity.agent_id, "attachment": declaration, "availability": "AVAILABLE"}, config["credential"])
215
+ if instance != config["server_instance_id"]:
216
+ raise ValueError("EP Server identity changed; re-pair explicitly")
217
+ return response
218
+
219
+
220
+ def load_or_create_identity(host: HostIdentity, path: Path | None = None) -> AgentIdentity:
221
+ """Persist only installation identity; never execution, queue, or lock data."""
222
+ identity_path = path or default_identity_path()
223
+ try:
224
+ raw = json.loads(identity_path.read_text(encoding="utf-8"))
225
+ identity = AgentIdentity(**raw)
226
+ if identity.identity_format == IDENTITY_FORMAT and identity.host_context_key == host.context_key:
227
+ # Identity metadata is not a credential, but it is still per-user
228
+ # installation state and must not be readable by other accounts.
229
+ identity_path.chmod(0o600)
230
+ return identity
231
+ except (OSError, TypeError, ValueError, json.JSONDecodeError):
232
+ pass
233
+ identity = AgentIdentity(str(uuid4()), IDENTITY_FORMAT, host.context_key, datetime.now(timezone.utc).isoformat())
234
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
235
+ temporary = identity_path.with_suffix(identity_path.suffix + ".tmp")
236
+ temporary.write_text(json.dumps(asdict(identity), sort_keys=True) + "\n", encoding="utf-8")
237
+ temporary.chmod(0o600)
238
+ os.replace(temporary, identity_path)
239
+ identity_path.chmod(0o600)
240
+ return identity
241
+
242
+
243
+ def _version(executable: str) -> str | None:
244
+ try:
245
+ result = subprocess.run((executable, "--version"), capture_output=True, text=True, timeout=2, check=False)
246
+ except (OSError, subprocess.SubprocessError):
247
+ return None
248
+ if result.returncode != 0:
249
+ return None
250
+ output = (result.stdout or result.stderr).strip().splitlines()
251
+ return output[0][:200] if output else None
252
+
253
+
254
+ def discover_tool(name: str) -> ToolCapability:
255
+ executable = shutil.which(name)
256
+ return ToolCapability(name, executable is not None, executable, _version(executable) if executable else None)
257
+
258
+
259
+ def discover_capabilities() -> CapabilitySnapshot:
260
+ return CapabilitySnapshot(
261
+ host=observe_host_identity(),
262
+ git=discover_tool("git"),
263
+ toolchains=tuple(discover_tool(name) for name in DEFAULT_TOOLCHAINS),
264
+ provider_clis=tuple(discover_tool(name) for name in DEFAULT_PROVIDER_CLIS),
265
+ )
266
+
267
+
268
+ def inventory_repositories(roots: Sequence[Path]) -> tuple[RepositoryEnvironment, ...]:
269
+ """Inspect only explicitly supplied roots; discovery never creates attachments."""
270
+ inventory: list[RepositoryEnvironment] = []
271
+ for requested in roots:
272
+ resolved = requested.expanduser().resolve()
273
+ exists = resolved.is_dir()
274
+ is_git = False
275
+ if exists:
276
+ try:
277
+ result = subprocess.run(("git", "-C", str(resolved), "rev-parse", "--is-inside-work-tree"), capture_output=True, text=True, timeout=2, check=False)
278
+ is_git = result.returncode == 0 and result.stdout.strip() == "true"
279
+ except (OSError, subprocess.SubprocessError):
280
+ pass
281
+ inventory.append(RepositoryEnvironment(str(requested), str(resolved) if exists else None, exists, is_git))
282
+ return tuple(inventory)
283
+
284
+
285
+ def observe(repository_roots: Sequence[Path] = (), *, identity_path: Path | None = None) -> AgentSnapshot:
286
+ capabilities = discover_capabilities()
287
+ return AgentSnapshot(
288
+ identity=load_or_create_identity(capabilities.host, identity_path),
289
+ capabilities=capabilities,
290
+ repositories=inventory_repositories(repository_roots),
291
+ observed_at=datetime.now(timezone.utc).isoformat(),
292
+ )
293
+
294
+
295
+ def build_parser() -> argparse.ArgumentParser:
296
+ parser = argparse.ArgumentParser(prog="engineering-project-agent", description="Observe or pair the Project Agent with EP Server.")
297
+ parser.add_argument("command", choices=("observe", "pair", "register", "heartbeat", "attach"), nargs="?", default="observe")
298
+ parser.add_argument("--repository-root", action="append", default=[], type=Path, help="Repository root to inspect; may be repeated.")
299
+ parser.add_argument("--identity-path", type=Path, help="Local installation identity file; contains no execution state.")
300
+ parser.add_argument("--configuration-path", type=Path, help="Private Agent pairing configuration path.")
301
+ parser.add_argument("--server-endpoint")
302
+ parser.add_argument("--pairing-code")
303
+ return parser
304
+
305
+
306
+ def main(argv: list[str] | None = None) -> int:
307
+ supplied = list(argv) if argv is not None else sys.argv[1:]
308
+ if supplied and supplied[0] in {"install", "uninstall", "start", "stop", "restart", "status", "service"}:
309
+ return service_main(supplied)
310
+ parser = build_parser()
311
+ args = parser.parse_args(supplied)
312
+ if args.command == "observe": result = observe(args.repository_root, identity_path=args.identity_path).payload()
313
+ elif args.command == "pair":
314
+ if not args.server_endpoint or not args.pairing_code: parser.error("pair requires --server-endpoint and --pairing-code")
315
+ result = pair(args.server_endpoint, args.pairing_code, identity_path=args.identity_path, configuration_path=args.configuration_path)
316
+ elif args.command == "register": result = register(args.repository_root, identity_path=args.identity_path, configuration_path=args.configuration_path)
317
+ elif args.command == "heartbeat": result = heartbeat(configuration_path=args.configuration_path)
318
+ else:
319
+ if len(args.repository_root) != 1: parser.error("attach requires exactly one --repository-root")
320
+ result = attach(args.repository_root[0], identity_path=args.identity_path, configuration_path=args.configuration_path)
321
+ print(json.dumps(result, indent=2, sort_keys=True))
322
+ return 0
323
+
324
+
325
+ def service_main(argv: list[str]) -> int:
326
+ """Dispatch packaging lifecycle commands without changing B4 observation CLI."""
327
+ from . import project_agent_service as service
328
+ parser = argparse.ArgumentParser(prog="engineering-project-agent")
329
+ commands = parser.add_subparsers(dest="command", required=True)
330
+ for name in ("install", "uninstall", "start", "stop", "restart", "status"):
331
+ commands.add_parser(name)
332
+ run_parser = commands.add_parser("service")
333
+ run_commands = run_parser.add_subparsers(dest="service_command", required=True)
334
+ run = run_commands.add_parser("run")
335
+ run.add_argument("--config", required=True, type=Path)
336
+ args = parser.parse_args(argv)
337
+ try:
338
+ if args.command == "install":
339
+ # Entry-point invocation is the authoritative installed artifact;
340
+ # consulting PATH here could select a different developer install.
341
+ result: object = service.install(executable=Path(sys.argv[0]))
342
+ elif args.command == "uninstall":
343
+ service.uninstall()
344
+ result = {"state": "uninstalled"}
345
+ elif args.command == "start":
346
+ service.start()
347
+ result = service.status()
348
+ elif args.command == "stop":
349
+ service.stop()
350
+ result = service.status()
351
+ elif args.command == "restart":
352
+ service.stop()
353
+ service.start()
354
+ result = service.status()
355
+ elif args.command == "status":
356
+ result = service.status()
357
+ else:
358
+ return service.run(args.config)
359
+ except service.AgentServiceError as error:
360
+ parser.error(str(error))
361
+ print(json.dumps(result, sort_keys=True))
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__":
366
+ raise SystemExit(main())