okstra 0.163.2 → 0.164.0
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.
- package/README.md +7 -5
- package/docs/architecture.md +12 -7
- package/docs/cli.md +8 -5
- package/docs/project-structure-overview.md +7 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -0
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/usage.sh +5 -5
- package/runtime/bin/okstra.sh +87 -91
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
- package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/application/__init__.py +1 -0
- package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
- package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
- package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
- package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
- package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
- package/runtime/python/okstra_ctl/application/start_run.py +21 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
- package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
- package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
- package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
- package/runtime/python/okstra_ctl/domain/host.py +100 -0
- package/runtime/python/okstra_ctl/domain/provider.py +66 -0
- package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
- package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
- package/runtime/python/okstra_ctl/models.py +54 -269
- package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
- package/runtime/python/okstra_ctl/ports/host.py +32 -0
- package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
- package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
- package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
- package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
- package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
- package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
- package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
- package/runtime/python/okstra_ctl/render.py +145 -47
- package/runtime/python/okstra_ctl/run.py +80 -58
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/team.py +44 -32
- package/runtime/python/okstra_ctl/wizard.py +162 -64
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
- package/runtime/python/okstra_token_usage/collect.py +34 -6
- package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
- package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
- package/runtime/skills/okstra-run/SKILL.md +73 -28
- package/runtime/templates/implementation-worker-preamble.md +1 -1
- package/runtime/templates/reports/final-report.template.md +3 -3
- package/runtime/templates/reports/html/i18n/en.json +3 -1
- package/runtime/templates/reports/html/i18n/ko.json +3 -1
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +17 -22
- package/runtime/validators/validate-report-views.py +0 -39
- package/runtime/validators/validate-run.py +8 -7
- package/runtime/validators/validate_session_conformance.py +7 -2
- package/src/commands/execute/render-bundle.mjs +4 -4
- package/src/commands/execute/run.mjs +8 -25
- package/src/commands/execute/wizard.mjs +33 -13
- package/src/commands/lifecycle/doctor.mjs +10 -10
- package/src/commands/lifecycle/install.mjs +53 -30
- package/src/commands/lifecycle/preflight.mjs +14 -4
- package/src/lib/host-registry-client.mjs +176 -0
- package/src/lib/runtime-manifest.mjs +6 -8
- package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
- package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
- package/src/lib/runtime-readiness.mjs +0 -90
- package/src/lib/runtime-resolver.mjs +0 -123
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Discover host manifests inside explicitly trusted adapter roots."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..domain.host import HostAdapterContractError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class HostManifest:
|
|
14
|
+
path: Path
|
|
15
|
+
host_id: str
|
|
16
|
+
factory_ref: str
|
|
17
|
+
native_provider_id: str
|
|
18
|
+
required_executables: tuple[str, ...]
|
|
19
|
+
relay_contract: Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def discover_host_manifests(roots: Iterable[Path]) -> tuple[HostManifest, ...]:
|
|
23
|
+
manifests: list[HostManifest] = []
|
|
24
|
+
for manifest_path in _manifest_paths(roots):
|
|
25
|
+
manifests.append(_read_manifest(manifest_path))
|
|
26
|
+
return tuple(manifests)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _manifest_paths(roots: Iterable[Path]) -> tuple[Path, ...]:
|
|
30
|
+
manifests: list[Path] = []
|
|
31
|
+
for root in roots:
|
|
32
|
+
if root.is_symlink():
|
|
33
|
+
raise HostAdapterContractError(
|
|
34
|
+
f"host discovery root is symbolic link: {root}"
|
|
35
|
+
)
|
|
36
|
+
if not root.is_dir():
|
|
37
|
+
continue
|
|
38
|
+
resolved_root = root.resolve()
|
|
39
|
+
for manifest_path in sorted(root.glob("*/manifest.json")):
|
|
40
|
+
resolved_manifest = manifest_path.resolve()
|
|
41
|
+
if resolved_root not in resolved_manifest.parents:
|
|
42
|
+
raise HostAdapterContractError(
|
|
43
|
+
f"host adapter escapes discovery root: {manifest_path}"
|
|
44
|
+
)
|
|
45
|
+
manifests.append(resolved_manifest)
|
|
46
|
+
return tuple(manifests)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_manifest(manifest_path: Path) -> HostManifest:
|
|
50
|
+
raw = _read_json_object(manifest_path)
|
|
51
|
+
host_id = _required_id(raw, "id", manifest_path)
|
|
52
|
+
factory_ref = _required_string(raw, "factory", manifest_path)
|
|
53
|
+
native_provider_id = _optional_id(raw, "nativeProviderId", manifest_path)
|
|
54
|
+
required_executables = _string_tuple(raw, "requiredExecutables", manifest_path)
|
|
55
|
+
relay_ref = _required_string(raw, "relayContract", manifest_path)
|
|
56
|
+
relay_contract = _resolve_relay_contract(manifest_path, relay_ref)
|
|
57
|
+
return HostManifest(
|
|
58
|
+
path=manifest_path,
|
|
59
|
+
host_id=host_id,
|
|
60
|
+
factory_ref=factory_ref,
|
|
61
|
+
native_provider_id=native_provider_id,
|
|
62
|
+
required_executables=required_executables,
|
|
63
|
+
relay_contract=relay_contract,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_json_object(manifest_path: Path) -> dict[str, object]:
|
|
68
|
+
try:
|
|
69
|
+
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
70
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
71
|
+
raise HostAdapterContractError(
|
|
72
|
+
f"invalid host manifest: {manifest_path}"
|
|
73
|
+
) from exc
|
|
74
|
+
if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
|
|
75
|
+
raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
|
|
76
|
+
return raw
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _required_id(raw: dict[str, object], key: str, manifest_path: Path) -> str:
|
|
80
|
+
value = _required_string(raw, key, manifest_path).strip().lower()
|
|
81
|
+
if not value:
|
|
82
|
+
raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _optional_id(raw: dict[str, object], key: str, manifest_path: Path) -> str:
|
|
87
|
+
value = raw.get(key, "")
|
|
88
|
+
if not isinstance(value, str):
|
|
89
|
+
raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
|
|
90
|
+
return value.strip().lower()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _required_string(
|
|
94
|
+
raw: dict[str, object], key: str, manifest_path: Path
|
|
95
|
+
) -> str:
|
|
96
|
+
value = raw.get(key)
|
|
97
|
+
if not isinstance(value, str) or not value:
|
|
98
|
+
raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _string_tuple(
|
|
103
|
+
raw: dict[str, object], key: str, manifest_path: Path
|
|
104
|
+
) -> tuple[str, ...]:
|
|
105
|
+
value = raw.get(key)
|
|
106
|
+
if not isinstance(value, list) or not all(
|
|
107
|
+
isinstance(item, str) and item for item in value
|
|
108
|
+
):
|
|
109
|
+
raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
|
|
110
|
+
return tuple(value)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_relay_contract(manifest_path: Path, relay_ref: str) -> Path:
|
|
114
|
+
adapter_root = manifest_path.parent.resolve()
|
|
115
|
+
if Path(relay_ref).is_absolute():
|
|
116
|
+
raise HostAdapterContractError(
|
|
117
|
+
f"relay contract escapes adapter root: {relay_ref!r}"
|
|
118
|
+
)
|
|
119
|
+
relay_contract = (adapter_root / relay_ref).resolve()
|
|
120
|
+
if adapter_root not in relay_contract.parents or not relay_contract.is_file():
|
|
121
|
+
raise HostAdapterContractError(
|
|
122
|
+
f"relay contract escapes adapter root: {relay_ref!r}"
|
|
123
|
+
)
|
|
124
|
+
return relay_contract
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Register and resolve discovered host adapter strategies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from okstra_project.dirs import okstra_home
|
|
9
|
+
|
|
10
|
+
from ..domain.host import (
|
|
11
|
+
HostAdapterContractError,
|
|
12
|
+
HostClaim,
|
|
13
|
+
HostDescriptor,
|
|
14
|
+
HostNotRegistered,
|
|
15
|
+
HostResolutionContext,
|
|
16
|
+
HostUnavailable,
|
|
17
|
+
ProviderNotRegistered,
|
|
18
|
+
)
|
|
19
|
+
from ..ports import HostAdapter
|
|
20
|
+
from .factory_loader import FactoryLoadError, load_relative_factory
|
|
21
|
+
from .host_discovery import HostManifest, discover_host_manifests
|
|
22
|
+
from .provider_registry import ProviderRegistry, default_provider_registry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_ADAPTER_METHODS = (
|
|
26
|
+
"detect",
|
|
27
|
+
"probe",
|
|
28
|
+
"interaction",
|
|
29
|
+
"lead_session",
|
|
30
|
+
"worker_dispatch",
|
|
31
|
+
"usage_accounting",
|
|
32
|
+
)
|
|
33
|
+
_PORT_METHODS = {
|
|
34
|
+
"interaction": ("plan",),
|
|
35
|
+
"lead_session": ("build_start", "build_resume"),
|
|
36
|
+
"worker_dispatch": ("build_plan",),
|
|
37
|
+
"usage_accounting": ("collect",),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RegisteredHostAdapter:
|
|
42
|
+
def __init__(self, descriptor: HostDescriptor, adapter: object) -> None:
|
|
43
|
+
self.descriptor = descriptor
|
|
44
|
+
self._adapter = adapter
|
|
45
|
+
|
|
46
|
+
def detect(self, context):
|
|
47
|
+
return self._adapter.detect(context)
|
|
48
|
+
|
|
49
|
+
def probe(self, context):
|
|
50
|
+
return self._adapter.probe(context)
|
|
51
|
+
|
|
52
|
+
def interaction(self):
|
|
53
|
+
return self._adapter.interaction()
|
|
54
|
+
|
|
55
|
+
def lead_session(self):
|
|
56
|
+
return self._adapter.lead_session()
|
|
57
|
+
|
|
58
|
+
def worker_dispatch(self):
|
|
59
|
+
return self._adapter.worker_dispatch()
|
|
60
|
+
|
|
61
|
+
def usage_accounting(self):
|
|
62
|
+
return self._adapter.usage_accounting()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HostAdapterRegistry:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
adapters: Mapping[str, HostAdapter],
|
|
69
|
+
aliases: Mapping[str, HostAdapter],
|
|
70
|
+
) -> None:
|
|
71
|
+
self._adapters = dict(adapters)
|
|
72
|
+
self._aliases = dict(aliases)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_roots(
|
|
76
|
+
cls,
|
|
77
|
+
roots: Iterable[Path],
|
|
78
|
+
provider_registry: ProviderRegistry,
|
|
79
|
+
) -> "HostAdapterRegistry":
|
|
80
|
+
entries = _load_entries(roots, provider_registry)
|
|
81
|
+
adapters = _index_host_ids(entries)
|
|
82
|
+
aliases = _index_aliases(entries, adapters)
|
|
83
|
+
return cls(adapters, aliases)
|
|
84
|
+
|
|
85
|
+
def resolve(self, host_id_or_alias: str) -> HostAdapter:
|
|
86
|
+
normalized = host_id_or_alias.strip().lower()
|
|
87
|
+
adapter = self._aliases.get(normalized)
|
|
88
|
+
if adapter is not None:
|
|
89
|
+
return adapter
|
|
90
|
+
allowed = ", ".join(self._adapters)
|
|
91
|
+
raise HostNotRegistered(
|
|
92
|
+
f"unknown host {host_id_or_alias!r}. Allowed values: {allowed}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def resolve_request(
|
|
96
|
+
self,
|
|
97
|
+
requested_host: str,
|
|
98
|
+
context: HostResolutionContext,
|
|
99
|
+
) -> HostDescriptor:
|
|
100
|
+
descriptor, _, _ = self.resolve_request_details(requested_host, context)
|
|
101
|
+
return descriptor
|
|
102
|
+
|
|
103
|
+
def resolve_request_details(
|
|
104
|
+
self,
|
|
105
|
+
requested_host: str,
|
|
106
|
+
context: HostResolutionContext,
|
|
107
|
+
) -> tuple[HostDescriptor, str, str]:
|
|
108
|
+
normalized = requested_host.strip().lower()
|
|
109
|
+
if normalized and normalized != "auto":
|
|
110
|
+
descriptor = self.resolve(normalized).descriptor
|
|
111
|
+
return descriptor, "explicit", (
|
|
112
|
+
f"Explicit runtime {descriptor.id!r} requested."
|
|
113
|
+
)
|
|
114
|
+
env_host = context.explicit_env_host.strip()
|
|
115
|
+
if env_host and env_host.lower() != "auto":
|
|
116
|
+
descriptor = self.resolve(env_host).descriptor
|
|
117
|
+
return descriptor, descriptor.id, (
|
|
118
|
+
f"OKSTRA_RUNTIME_HOST={env_host} selected runtime {descriptor.id!r}."
|
|
119
|
+
)
|
|
120
|
+
adapter, claim = self._resolve_claim(context)
|
|
121
|
+
return adapter.descriptor, adapter.descriptor.id, claim.reason
|
|
122
|
+
|
|
123
|
+
def ids(self) -> tuple[str, ...]:
|
|
124
|
+
return tuple(self._adapters)
|
|
125
|
+
|
|
126
|
+
def catalog(self) -> tuple[HostDescriptor, ...]:
|
|
127
|
+
return tuple(adapter.descriptor for adapter in self._adapters.values())
|
|
128
|
+
|
|
129
|
+
def _resolve_claim(
|
|
130
|
+
self, context: HostResolutionContext
|
|
131
|
+
) -> tuple[HostAdapter, HostClaim]:
|
|
132
|
+
claims = tuple(
|
|
133
|
+
claim
|
|
134
|
+
for adapter in self._adapters.values()
|
|
135
|
+
if (claim := _detect_claim(adapter, context)) is not None
|
|
136
|
+
)
|
|
137
|
+
if not claims:
|
|
138
|
+
raise HostUnavailable("no host adapter claimed the current session")
|
|
139
|
+
highest_priority = max(claim.priority for claim in claims)
|
|
140
|
+
winners = tuple(
|
|
141
|
+
claim for claim in claims if claim.priority == highest_priority
|
|
142
|
+
)
|
|
143
|
+
if len(winners) != 1:
|
|
144
|
+
host_ids = ", ".join(sorted(claim.host_id for claim in winners))
|
|
145
|
+
raise HostUnavailable(f"ambiguous host adapter claims: {host_ids}")
|
|
146
|
+
winner = winners[0]
|
|
147
|
+
return self.resolve(winner.host_id), winner
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def default_host_registry(
|
|
151
|
+
provider_registry: ProviderRegistry | None = None,
|
|
152
|
+
) -> HostAdapterRegistry:
|
|
153
|
+
bundled_root = Path(__file__).resolve().parents[1] / "adapters" / "hosts"
|
|
154
|
+
user_root = okstra_home() / "adapters" / "hosts"
|
|
155
|
+
providers = provider_registry or default_provider_registry()
|
|
156
|
+
return HostAdapterRegistry.from_roots((bundled_root, user_root), providers)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _load_entries(
|
|
160
|
+
roots: Iterable[Path], provider_registry: ProviderRegistry
|
|
161
|
+
) -> tuple[tuple[HostManifest, HostAdapter], ...]:
|
|
162
|
+
entries: list[tuple[HostManifest, HostAdapter]] = []
|
|
163
|
+
for manifest in discover_host_manifests(roots):
|
|
164
|
+
_validate_provider(manifest, provider_registry)
|
|
165
|
+
entries.append((manifest, _load_host_adapter(manifest, provider_registry)))
|
|
166
|
+
return tuple(entries)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_provider(
|
|
170
|
+
manifest: HostManifest, provider_registry: ProviderRegistry
|
|
171
|
+
) -> None:
|
|
172
|
+
if not manifest.native_provider_id:
|
|
173
|
+
return
|
|
174
|
+
if manifest.native_provider_id not in provider_registry.providers:
|
|
175
|
+
raise ProviderNotRegistered(
|
|
176
|
+
f"native provider {manifest.native_provider_id!r} is not registered"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_host_adapter(
|
|
181
|
+
manifest: HostManifest,
|
|
182
|
+
provider_registry: ProviderRegistry,
|
|
183
|
+
) -> HostAdapter:
|
|
184
|
+
try:
|
|
185
|
+
factory = load_relative_factory(manifest.path, manifest.factory_ref)
|
|
186
|
+
parameters = inspect.signature(factory).parameters.values()
|
|
187
|
+
accepts_registry = any(
|
|
188
|
+
parameter.name == "provider_registry"
|
|
189
|
+
or parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
190
|
+
for parameter in parameters
|
|
191
|
+
)
|
|
192
|
+
adapter = (
|
|
193
|
+
factory(provider_registry=provider_registry)
|
|
194
|
+
if accepts_registry
|
|
195
|
+
else factory()
|
|
196
|
+
)
|
|
197
|
+
except FactoryLoadError as exc:
|
|
198
|
+
raise HostAdapterContractError(str(exc)) from exc
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
raise HostAdapterContractError(
|
|
201
|
+
f"host factory failed for {manifest.host_id!r}"
|
|
202
|
+
) from exc
|
|
203
|
+
descriptor = _validated_descriptor(adapter, manifest)
|
|
204
|
+
return _RegisteredHostAdapter(descriptor, adapter)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _validated_descriptor(adapter: object, manifest: HostManifest) -> HostDescriptor:
|
|
208
|
+
for method_name in _ADAPTER_METHODS:
|
|
209
|
+
if not callable(getattr(adapter, method_name, None)):
|
|
210
|
+
raise HostAdapterContractError(
|
|
211
|
+
f"host factory returned invalid adapter {manifest.host_id!r}"
|
|
212
|
+
)
|
|
213
|
+
_validate_ports(adapter, manifest.host_id)
|
|
214
|
+
descriptor = _copy_descriptor(getattr(adapter, "descriptor", None), manifest.host_id)
|
|
215
|
+
if descriptor.id != manifest.host_id:
|
|
216
|
+
raise HostAdapterContractError(f"host id mismatch for {manifest.host_id!r}")
|
|
217
|
+
if descriptor.native_provider_id != manifest.native_provider_id:
|
|
218
|
+
raise HostAdapterContractError(
|
|
219
|
+
f"native provider mismatch for {manifest.host_id!r}"
|
|
220
|
+
)
|
|
221
|
+
if descriptor.required_executables != manifest.required_executables:
|
|
222
|
+
raise HostAdapterContractError(
|
|
223
|
+
f"required executables mismatch for {manifest.host_id!r}"
|
|
224
|
+
)
|
|
225
|
+
if Path(descriptor.relay_contract).resolve() != manifest.relay_contract:
|
|
226
|
+
raise HostAdapterContractError(
|
|
227
|
+
f"relay contract mismatch for {manifest.host_id!r}"
|
|
228
|
+
)
|
|
229
|
+
return descriptor
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate_ports(adapter: object, host_id: str) -> None:
|
|
233
|
+
for accessor, required_methods in _PORT_METHODS.items():
|
|
234
|
+
try:
|
|
235
|
+
port = getattr(adapter, accessor)()
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
raise HostAdapterContractError(
|
|
238
|
+
f"host {host_id!r} {accessor} port could not be created"
|
|
239
|
+
) from exc
|
|
240
|
+
missing = tuple(
|
|
241
|
+
method for method in required_methods
|
|
242
|
+
if not callable(getattr(port, method, None))
|
|
243
|
+
)
|
|
244
|
+
if missing:
|
|
245
|
+
required = ", ".join(missing)
|
|
246
|
+
raise HostAdapterContractError(
|
|
247
|
+
f"host {host_id!r} {accessor} port is missing {required}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _copy_descriptor(raw: object, host_id: str) -> HostDescriptor:
|
|
252
|
+
try:
|
|
253
|
+
descriptor = HostDescriptor(
|
|
254
|
+
id=raw.id,
|
|
255
|
+
aliases=raw.aliases,
|
|
256
|
+
native_provider_id=raw.native_provider_id,
|
|
257
|
+
required_executables=raw.required_executables,
|
|
258
|
+
launch_mode=raw.launch_mode,
|
|
259
|
+
install_targets=raw.install_targets,
|
|
260
|
+
agent_id=raw.agent_id,
|
|
261
|
+
agent_label=raw.agent_label,
|
|
262
|
+
role=raw.role,
|
|
263
|
+
dispatch_mode=raw.dispatch_mode,
|
|
264
|
+
session_accounting=raw.session_accounting,
|
|
265
|
+
has_claude_session=raw.has_claude_session,
|
|
266
|
+
relay_contract=raw.relay_contract,
|
|
267
|
+
)
|
|
268
|
+
except (AttributeError, TypeError) as exc:
|
|
269
|
+
raise HostAdapterContractError(
|
|
270
|
+
f"host factory returned invalid descriptor {host_id!r}"
|
|
271
|
+
) from exc
|
|
272
|
+
if not _descriptor_fields_are_valid(descriptor):
|
|
273
|
+
raise HostAdapterContractError(
|
|
274
|
+
f"host factory returned invalid descriptor {host_id!r}"
|
|
275
|
+
)
|
|
276
|
+
return descriptor
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _descriptor_fields_are_valid(descriptor: HostDescriptor) -> bool:
|
|
280
|
+
normalized_ids = (descriptor.id, descriptor.native_provider_id)
|
|
281
|
+
if not all(isinstance(value, str) for value in normalized_ids):
|
|
282
|
+
return False
|
|
283
|
+
if not descriptor.id or descriptor.id != descriptor.id.strip().lower():
|
|
284
|
+
return False
|
|
285
|
+
if descriptor.native_provider_id != descriptor.native_provider_id.strip().lower():
|
|
286
|
+
return False
|
|
287
|
+
if not isinstance(descriptor.aliases, tuple) or not all(
|
|
288
|
+
isinstance(alias, str) and alias == alias.strip().lower() and alias
|
|
289
|
+
for alias in descriptor.aliases
|
|
290
|
+
):
|
|
291
|
+
return False
|
|
292
|
+
if not isinstance(descriptor.required_executables, tuple) or not all(
|
|
293
|
+
isinstance(executable, str) and executable
|
|
294
|
+
for executable in descriptor.required_executables
|
|
295
|
+
):
|
|
296
|
+
return False
|
|
297
|
+
if descriptor.launch_mode not in {"lead", "team"}:
|
|
298
|
+
return False
|
|
299
|
+
if not isinstance(descriptor.install_targets, frozenset) or not all(
|
|
300
|
+
isinstance(target, str) and target for target in descriptor.install_targets
|
|
301
|
+
):
|
|
302
|
+
return False
|
|
303
|
+
text_fields = (
|
|
304
|
+
descriptor.agent_id,
|
|
305
|
+
descriptor.agent_label,
|
|
306
|
+
descriptor.role,
|
|
307
|
+
descriptor.dispatch_mode,
|
|
308
|
+
descriptor.session_accounting,
|
|
309
|
+
descriptor.relay_contract,
|
|
310
|
+
)
|
|
311
|
+
return all(isinstance(value, str) and value for value in text_fields) and isinstance(
|
|
312
|
+
descriptor.has_claude_session, bool
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _index_host_ids(
|
|
317
|
+
entries: tuple[tuple[HostManifest, HostAdapter], ...]
|
|
318
|
+
) -> dict[str, HostAdapter]:
|
|
319
|
+
adapters: dict[str, HostAdapter] = {}
|
|
320
|
+
for manifest, adapter in entries:
|
|
321
|
+
if manifest.host_id in adapters:
|
|
322
|
+
raise HostAdapterContractError(
|
|
323
|
+
f"duplicate host id {manifest.host_id!r}"
|
|
324
|
+
)
|
|
325
|
+
adapters[manifest.host_id] = adapter
|
|
326
|
+
return adapters
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _index_aliases(
|
|
330
|
+
entries: tuple[tuple[HostManifest, HostAdapter], ...],
|
|
331
|
+
adapters: Mapping[str, HostAdapter],
|
|
332
|
+
) -> dict[str, HostAdapter]:
|
|
333
|
+
aliases = dict(adapters)
|
|
334
|
+
for _, adapter in entries:
|
|
335
|
+
for raw_alias in adapter.descriptor.aliases:
|
|
336
|
+
alias = raw_alias.strip().lower()
|
|
337
|
+
existing = aliases.get(alias)
|
|
338
|
+
if not alias or (existing is not None and existing is not adapter):
|
|
339
|
+
raise HostAdapterContractError(f"duplicate host key {alias!r}")
|
|
340
|
+
aliases[alias] = adapter
|
|
341
|
+
return aliases
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _detect_claim(
|
|
345
|
+
adapter: HostAdapter, context: HostResolutionContext
|
|
346
|
+
) -> HostClaim | None:
|
|
347
|
+
try:
|
|
348
|
+
raw_claim = adapter.detect(context)
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
raise HostAdapterContractError(
|
|
351
|
+
f"host detector failed for {adapter.descriptor.id!r}"
|
|
352
|
+
) from exc
|
|
353
|
+
if raw_claim is None:
|
|
354
|
+
return None
|
|
355
|
+
try:
|
|
356
|
+
claim = HostClaim(raw_claim.host_id, raw_claim.priority, raw_claim.reason)
|
|
357
|
+
except (AttributeError, TypeError) as exc:
|
|
358
|
+
raise HostAdapterContractError(
|
|
359
|
+
f"host detector returned invalid claim for {adapter.descriptor.id!r}"
|
|
360
|
+
) from exc
|
|
361
|
+
if claim.host_id != adapter.descriptor.id or not isinstance(claim.priority, int):
|
|
362
|
+
raise HostAdapterContractError(
|
|
363
|
+
f"host detector returned invalid claim for {adapter.descriptor.id!r}"
|
|
364
|
+
)
|
|
365
|
+
return claim
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Discover provider adapters from the bundled and Okstra-user locations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from okstra_project.dirs import okstra_home
|
|
10
|
+
|
|
11
|
+
from ..domain.provider import ProviderSpec, UnknownProviderError
|
|
12
|
+
from .factory_loader import FactoryLoadError, load_relative_factory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProviderAdapterContractError(ValueError):
|
|
16
|
+
"""Raised when a discovered provider adapter violates its contract."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProviderRegistry:
|
|
20
|
+
"""Resolved provider specifications, indexed by their normalized id."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, providers: Mapping[str, ProviderSpec]) -> None:
|
|
23
|
+
self._providers = dict(providers)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_roots(cls, roots: Iterable[Path]) -> "ProviderRegistry":
|
|
27
|
+
providers: dict[str, ProviderSpec] = {}
|
|
28
|
+
for manifest_path, provider_id, factory_ref in _manifest_entries(roots):
|
|
29
|
+
spec = _load_provider_spec(manifest_path, provider_id, factory_ref)
|
|
30
|
+
providers[provider_id] = spec
|
|
31
|
+
return cls(providers)
|
|
32
|
+
|
|
33
|
+
def resolve(self, provider_id: str) -> ProviderSpec:
|
|
34
|
+
normalized = provider_id.strip().lower()
|
|
35
|
+
try:
|
|
36
|
+
return self._providers[normalized]
|
|
37
|
+
except KeyError as exc:
|
|
38
|
+
allowed = ", ".join(self._providers)
|
|
39
|
+
raise UnknownProviderError(
|
|
40
|
+
f"unknown provider {provider_id!r}. Allowed values: {allowed}"
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
def ids(self, role: str | None = None) -> tuple[str, ...]:
|
|
44
|
+
if role is None:
|
|
45
|
+
return tuple(self._providers)
|
|
46
|
+
return tuple(
|
|
47
|
+
provider_id
|
|
48
|
+
for provider_id, spec in self._providers.items()
|
|
49
|
+
if role in spec.supported_roles
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def providers(self) -> Mapping[str, ProviderSpec]:
|
|
54
|
+
return dict(self._providers)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_provider_registry() -> ProviderRegistry:
|
|
58
|
+
"""Load bundled providers plus only adapters owned by the Okstra user home."""
|
|
59
|
+
bundled_root = Path(__file__).resolve().parents[1] / "adapters" / "providers"
|
|
60
|
+
user_root = okstra_home() / "adapters" / "providers"
|
|
61
|
+
if user_root.is_symlink():
|
|
62
|
+
raise ProviderAdapterContractError(
|
|
63
|
+
f"provider discovery root is symbolic link: {user_root}"
|
|
64
|
+
)
|
|
65
|
+
return _registry_for_roots(bundled_root.resolve(), user_root.resolve())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@lru_cache(maxsize=None)
|
|
69
|
+
def _registry_for_roots(
|
|
70
|
+
bundled_root: Path,
|
|
71
|
+
user_root: Path,
|
|
72
|
+
) -> ProviderRegistry:
|
|
73
|
+
discovered = ProviderRegistry.from_roots((bundled_root, user_root)).providers
|
|
74
|
+
bundled_order = ("claude", "antigravity", "codex", "grok", "kimi")
|
|
75
|
+
ordered = {provider_id: discovered[provider_id] for provider_id in bundled_order}
|
|
76
|
+
ordered.update(
|
|
77
|
+
(provider_id, spec)
|
|
78
|
+
for provider_id, spec in discovered.items()
|
|
79
|
+
if provider_id not in ordered
|
|
80
|
+
)
|
|
81
|
+
return ProviderRegistry(ordered)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _manifest_entries(roots: Iterable[Path]) -> list[tuple[Path, str, str]]:
|
|
85
|
+
entries: list[tuple[Path, str, str]] = []
|
|
86
|
+
provider_paths: dict[str, Path] = {}
|
|
87
|
+
for manifest_path in _manifest_paths(roots):
|
|
88
|
+
provider_id, factory_ref = _read_manifest(manifest_path)
|
|
89
|
+
if provider_id in provider_paths:
|
|
90
|
+
raise ProviderAdapterContractError(
|
|
91
|
+
f"duplicate provider id {provider_id!r}"
|
|
92
|
+
)
|
|
93
|
+
provider_paths[provider_id] = manifest_path
|
|
94
|
+
entries.append((manifest_path, provider_id, factory_ref))
|
|
95
|
+
return entries
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _manifest_paths(roots: Iterable[Path]) -> tuple[Path, ...]:
|
|
99
|
+
manifests: list[Path] = []
|
|
100
|
+
for root in roots:
|
|
101
|
+
if root.is_symlink():
|
|
102
|
+
raise ProviderAdapterContractError(
|
|
103
|
+
f"provider discovery root is symbolic link: {root}"
|
|
104
|
+
)
|
|
105
|
+
if not root.is_dir():
|
|
106
|
+
continue
|
|
107
|
+
resolved_root = root.resolve()
|
|
108
|
+
for manifest_path in sorted(root.glob("*/manifest.json")):
|
|
109
|
+
resolved_manifest = manifest_path.resolve()
|
|
110
|
+
if resolved_root not in resolved_manifest.parents:
|
|
111
|
+
raise ProviderAdapterContractError(
|
|
112
|
+
f"provider adapter escapes discovery root: {manifest_path}"
|
|
113
|
+
)
|
|
114
|
+
manifests.append(resolved_manifest)
|
|
115
|
+
return tuple(manifests)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_manifest(manifest_path: Path) -> tuple[str, str]:
|
|
119
|
+
try:
|
|
120
|
+
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
121
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
122
|
+
raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}") from exc
|
|
123
|
+
if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
|
|
124
|
+
raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
|
|
125
|
+
provider_id = raw.get("id")
|
|
126
|
+
factory_ref = raw.get("factory")
|
|
127
|
+
if not isinstance(provider_id, str) or not provider_id.strip():
|
|
128
|
+
raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
|
|
129
|
+
if not isinstance(factory_ref, str) or not factory_ref:
|
|
130
|
+
raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
|
|
131
|
+
return provider_id.strip().lower(), factory_ref
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_provider_spec(
|
|
135
|
+
manifest_path: Path, provider_id: str, factory_ref: str,
|
|
136
|
+
) -> ProviderSpec:
|
|
137
|
+
try:
|
|
138
|
+
spec = load_relative_factory(manifest_path, factory_ref)()
|
|
139
|
+
except FactoryLoadError as exc:
|
|
140
|
+
raise ProviderAdapterContractError(str(exc)) from exc
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise ProviderAdapterContractError(
|
|
143
|
+
f"provider factory failed for {provider_id!r}"
|
|
144
|
+
) from exc
|
|
145
|
+
if not isinstance(spec, ProviderSpec) or spec.provider != provider_id:
|
|
146
|
+
raise ProviderAdapterContractError(
|
|
147
|
+
f"provider factory returned invalid provider {provider_id!r}"
|
|
148
|
+
)
|
|
149
|
+
return spec
|