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.
- engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +32 -0
- engineering_platform/ENGINEERING_PLATFORM_VERSION.json +15 -0
- engineering_platform/__init__.py +1 -0
- engineering_platform/__main__.py +7 -0
- engineering_platform/agent_state.py +530 -0
- engineering_platform/agent_trust.py +174 -0
- engineering_platform/assets/dashboard.css +1317 -0
- engineering_platform/assets/dashboard.js +8534 -0
- engineering_platform/assets/dashboard_locales.mjs +4049 -0
- engineering_platform/assets/dashboard_status_store.mjs +41 -0
- engineering_platform/assets/operations-console/apple-touch-icon-dark.png +0 -0
- engineering_platform/assets/operations-console/apple-touch-icon-light.png +0 -0
- engineering_platform/assets/operations-console/icon-dark.png +0 -0
- engineering_platform/assets/operations-console/icon-light.png +0 -0
- engineering_platform/assets/operations-console/icon-transparent.png +0 -0
- engineering_platform/assets/operations-console/manifest.webmanifest +11 -0
- engineering_platform/capability_preflight.py +285 -0
- engineering_platform/capability_review.py +261 -0
- engineering_platform/central_data_transfer.py +195 -0
- engineering_platform/central_database.py +245 -0
- engineering_platform/central_store_migration.py +1672 -0
- engineering_platform/codex_capacity.py +81 -0
- engineering_platform/codex_chat.py +226 -0
- engineering_platform/codex_observability.py +153 -0
- engineering_platform/component_lock.py +40 -0
- engineering_platform/component_logging.py +420 -0
- engineering_platform/console_presentation.py +14 -0
- engineering_platform/console_route_ownership.py +83 -0
- engineering_platform/contracts/__init__.py +38 -0
- engineering_platform/contracts/ep_consumer.py +391 -0
- engineering_platform/contracts/models.py +105 -0
- engineering_platform/contracts/projection.py +401 -0
- engineering_platform/dashboard_browser_validation.py +206 -0
- engineering_platform/dashboard_state.py +630 -0
- engineering_platform/dashboard_supervisor.swift +105 -0
- engineering_platform/dashboard_translation.py +129 -0
- engineering_platform/dependabot_producer.py +349 -0
- engineering_platform/drift_diagnostics.py +144 -0
- engineering_platform/emergency_recovery.py +268 -0
- engineering_platform/engineering_memory.py +139 -0
- engineering_platform/ep_consumer_credentials.py +473 -0
- engineering_platform/evidence_projection.py +213 -0
- engineering_platform/execution_activity.py +218 -0
- engineering_platform/execution_context.py +132 -0
- engineering_platform/execution_errors.py +42 -0
- engineering_platform/execution_evidence.py +24 -0
- engineering_platform/execution_executor.py +730 -0
- engineering_platform/execution_finalization.py +44 -0
- engineering_platform/execution_host.py +3306 -0
- engineering_platform/execution_lease.py +365 -0
- engineering_platform/execution_lifecycle.py +447 -0
- engineering_platform/execution_models.py +43 -0
- engineering_platform/execution_readiness.py +166 -0
- engineering_platform/execution_reporting.py +1607 -0
- engineering_platform/execution_repository.py +253 -0
- engineering_platform/execution_timeout_policy.py +56 -0
- engineering_platform/execution_timing.py +440 -0
- engineering_platform/execution_transaction.py +28 -0
- engineering_platform/external_producer_binding.py +235 -0
- engineering_platform/file_inbox.py +249 -0
- engineering_platform/forensic_attribution.py +338 -0
- engineering_platform/forensic_attribution_v2.py +134 -0
- engineering_platform/forensic_delta.py +299 -0
- engineering_platform/golden_scenario.py +63 -0
- engineering_platform/historical_dashboard_configuration.py +171 -0
- engineering_platform/host_admin.py +199 -0
- engineering_platform/host_preflight.py +231 -0
- engineering_platform/installation_relocation.py +122 -0
- engineering_platform/investigation_ledger.py +89 -0
- engineering_platform/legacy_inbox_migration.py +79 -0
- engineering_platform/lifecycle_worker.py +223 -0
- engineering_platform/live_status.py +267 -0
- engineering_platform/local_api.py +209 -0
- engineering_platform/local_api_keychain.py +51 -0
- engineering_platform/local_repository_binding.py +138 -0
- engineering_platform/managed_autonomy.py +509 -0
- engineering_platform/managed_codex_runtime.py +105 -0
- engineering_platform/parity_context.py +203 -0
- engineering_platform/parity_lifecycle_dispatcher.py +488 -0
- engineering_platform/platform_admin.py +13 -0
- engineering_platform/platform_api.py +428 -0
- engineering_platform/platform_bootstrap.py +385 -0
- engineering_platform/platform_components.py +65 -0
- engineering_platform/platform_version.py +171 -0
- engineering_platform/pr_check_repair.py +276 -0
- engineering_platform/pr_evidence_backfill.py +278 -0
- engineering_platform/producer.py +209 -0
- engineering_platform/project_agent.py +366 -0
- engineering_platform/project_agent_service.py +244 -0
- engineering_platform/project_topology.py +126 -0
- engineering_platform/prompt_history.py +591 -0
- engineering_platform/provider_context.py +136 -0
- engineering_platform/provider_context_benchmark.py +41 -0
- engineering_platform/provider_context_scope.py +90 -0
- engineering_platform/provider_interruption.py +168 -0
- engineering_platform/provider_process_identity.py +80 -0
- engineering_platform/provider_readiness.py +138 -0
- engineering_platform/provider_recovery.py +647 -0
- engineering_platform/provider_usage.py +497 -0
- engineering_platform/providers.py +471 -0
- engineering_platform/qualification.py +220 -0
- engineering_platform/recommendation_handoff.py +238 -0
- engineering_platform/report_analysis.py +193 -0
- engineering_platform/repository_attachment.py +171 -0
- engineering_platform/repository_handoff.py +95 -0
- engineering_platform/resources.py +38 -0
- engineering_platform/reviewer_evidence.py +70 -0
- engineering_platform/schemas/repository-attachment.schema.json +61 -0
- engineering_platform/server.py +3679 -0
- engineering_platform/server_console_services.py +2024 -0
- engineering_platform/server_relay.py +172 -0
- engineering_platform/server_service.py +122 -0
- engineering_platform/status_model.py +135 -0
- engineering_platform/status_reconciliation.py +34 -0
- engineering_platform/storage.py +2440 -0
- engineering_platform/submission_cli.py +77 -0
- engineering_platform/submission_intake.py +45 -0
- engineering_platform/submission_service.py +317 -0
- engineering_platform/telemetry.py +951 -0
- engineering_platform/templates/workspace-config.json +25 -0
- engineering_platform/validation_identity.py +50 -0
- engineering_platform/validation_profile.py +211 -0
- engineering_platform/workspace_preflight.py +263 -0
- engineering_platform/worktree_provenance.py +147 -0
- engineering_platform/worktree_tooling.py +18 -0
- engineering_platform-2.2.0.dist-info/METADATA +18 -0
- engineering_platform-2.2.0.dist-info/RECORD +130 -0
- engineering_platform-2.2.0.dist-info/WHEEL +5 -0
- engineering_platform-2.2.0.dist-info/entry_points.txt +6 -0
- 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())
|