multi-forge 0.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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Backend management for Forge.
|
|
2
|
+
|
|
3
|
+
Backends are underlying services that proxies depend on (e.g., LiteLLM).
|
|
4
|
+
They have their own lifecycle, registry, and CLI commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from forge.backend.registry import (
|
|
15
|
+
BackendInstance,
|
|
16
|
+
BackendRegistry,
|
|
17
|
+
BackendRegistryStore,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BackendEnsureResult:
|
|
23
|
+
"""Result of ensure_backend() operation."""
|
|
24
|
+
|
|
25
|
+
instance: BackendInstance
|
|
26
|
+
source: Literal["reuse", "start"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackendAdapter(ABC):
|
|
30
|
+
"""Abstract base class for backend lifecycle management."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def start(self, backend_id: str, config_path: Path, port: int) -> BackendInstance:
|
|
34
|
+
"""Start backend, return instance details.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
backend_id: Unique instance ID (e.g., "litellm-4000")
|
|
38
|
+
config_path: Path to backend config file
|
|
39
|
+
port: Port number to bind
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
BackendInstance with PID and status
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
BackendStartError: If backend fails to start
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def stop(self, instance: BackendInstance) -> None:
|
|
50
|
+
"""Stop backend (best effort).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
instance: Backend instance to stop
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def health_check(self, instance: BackendInstance) -> bool:
|
|
58
|
+
"""Check if backend is healthy.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
instance: Backend instance to check
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if healthy, False otherwise
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BackendStartError(Exception):
|
|
69
|
+
"""Raised when backend fails to start."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BackendManager:
|
|
73
|
+
"""Orchestrates backends via adapters."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, registry_store: BackendRegistryStore) -> None:
|
|
76
|
+
"""Initialize backend manager.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
registry_store: Backend registry store
|
|
80
|
+
"""
|
|
81
|
+
self.registry_store = registry_store
|
|
82
|
+
self.adapters: dict[str, BackendAdapter] = {}
|
|
83
|
+
|
|
84
|
+
def register_adapter(self, adapter_type: str, adapter: BackendAdapter) -> None:
|
|
85
|
+
"""Register a backend adapter.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
adapter_type: Adapter type (e.g., "litellm")
|
|
89
|
+
adapter: Adapter instance
|
|
90
|
+
"""
|
|
91
|
+
self.adapters[adapter_type] = adapter
|
|
92
|
+
|
|
93
|
+
def ensure_backend(self, backend_id: str, adapter_type: str, port: int) -> BackendEnsureResult:
|
|
94
|
+
"""Ensure backend is running (reuse -> start pattern).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
backend_id: Backend instance ID (e.g., "litellm-4000")
|
|
98
|
+
adapter_type: Adapter type (e.g., "litellm")
|
|
99
|
+
port: Port number
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
BackendEnsureResult with instance and source ("reuse" or "start")
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
BackendStartError: If backend fails to start
|
|
106
|
+
"""
|
|
107
|
+
from forge.backend.creation import get_backend_config_path
|
|
108
|
+
|
|
109
|
+
adapter = self.adapters.get(adapter_type)
|
|
110
|
+
if not adapter:
|
|
111
|
+
raise ValueError(f"No adapter registered for type: {adapter_type}")
|
|
112
|
+
|
|
113
|
+
registry = self.registry_store.read()
|
|
114
|
+
existing = registry.backends.get(backend_id)
|
|
115
|
+
|
|
116
|
+
if existing:
|
|
117
|
+
# health_check works with or without PID (port probe fallback)
|
|
118
|
+
if adapter.health_check(existing):
|
|
119
|
+
return BackendEnsureResult(instance=existing, source="reuse")
|
|
120
|
+
|
|
121
|
+
def remove_dead(reg: BackendRegistry) -> None:
|
|
122
|
+
reg.backends.pop(backend_id, None)
|
|
123
|
+
|
|
124
|
+
self.registry_store.update(timeout_s=10.0, mutate=remove_dead)
|
|
125
|
+
|
|
126
|
+
config_path = get_backend_config_path(adapter_type)
|
|
127
|
+
if not config_path.exists():
|
|
128
|
+
raise BackendStartError(
|
|
129
|
+
f"Backend config not found: {config_path}\n" f"Create it with: forge backend create {adapter_type}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
instance = adapter.start(backend_id, config_path, port)
|
|
133
|
+
|
|
134
|
+
def add_instance(reg: BackendRegistry) -> None:
|
|
135
|
+
reg.backends[backend_id] = instance
|
|
136
|
+
|
|
137
|
+
self.registry_store.update(timeout_s=10.0, mutate=add_instance)
|
|
138
|
+
|
|
139
|
+
return BackendEnsureResult(instance=instance, source="start")
|
|
140
|
+
|
|
141
|
+
def stop_backend(self, backend_id: str) -> None:
|
|
142
|
+
"""Stop backend and remove from registry.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
backend_id: Backend instance ID
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: If backend not found
|
|
149
|
+
"""
|
|
150
|
+
registry = self.registry_store.read()
|
|
151
|
+
instance = registry.backends.get(backend_id)
|
|
152
|
+
|
|
153
|
+
if not instance:
|
|
154
|
+
raise ValueError(f"Backend not found: {backend_id}")
|
|
155
|
+
|
|
156
|
+
adapter = self.adapters.get(instance.adapter_type)
|
|
157
|
+
if adapter:
|
|
158
|
+
adapter.stop(instance)
|
|
159
|
+
|
|
160
|
+
def remove_instance(reg: BackendRegistry) -> None:
|
|
161
|
+
reg.backends.pop(backend_id, None)
|
|
162
|
+
|
|
163
|
+
self.registry_store.update(timeout_s=10.0, mutate=remove_instance)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = [
|
|
167
|
+
"BackendAdapter",
|
|
168
|
+
"BackendEnsureResult",
|
|
169
|
+
"BackendInstance",
|
|
170
|
+
"BackendManager",
|
|
171
|
+
"BackendRegistry",
|
|
172
|
+
"BackendRegistryStore",
|
|
173
|
+
"BackendStartError",
|
|
174
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Backend adapters for different backend types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forge.backend import BackendAdapter
|
|
6
|
+
from forge.backend.adapters.litellm import LiteLLMAdapter
|
|
7
|
+
|
|
8
|
+
# Registry of known adapter types
|
|
9
|
+
_ADAPTER_REGISTRY: dict[str, type["BackendAdapter"]] = {
|
|
10
|
+
"litellm": LiteLLMAdapter,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_adapter(adapter_type: str) -> "BackendAdapter":
|
|
15
|
+
"""Get an adapter instance by type.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
adapter_type: Adapter type (e.g., "litellm")
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
New adapter instance
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If adapter type is unknown
|
|
25
|
+
"""
|
|
26
|
+
adapter_class = _ADAPTER_REGISTRY.get(adapter_type)
|
|
27
|
+
if adapter_class is None:
|
|
28
|
+
available = ", ".join(sorted(_ADAPTER_REGISTRY.keys()))
|
|
29
|
+
raise ValueError(f"Unknown adapter type: '{adapter_type}'. Available: {available}")
|
|
30
|
+
return adapter_class()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_supported_adapters() -> list[str]:
|
|
34
|
+
"""Get list of supported adapter types."""
|
|
35
|
+
return sorted(_ADAPTER_REGISTRY.keys())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["LiteLLMAdapter", "get_adapter", "get_supported_adapters"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""LiteLLM backend adapter.
|
|
2
|
+
|
|
3
|
+
Manages LiteLLM proxy processes for multi-provider model access.
|
|
4
|
+
LiteLLM supports many providers (Gemini, OpenAI, Anthropic, etc.) -
|
|
5
|
+
the specific provider is determined by the config file and env vars.
|
|
6
|
+
|
|
7
|
+
Env var validation happens BEFORE this adapter is called (in _ensure_dependency_backend),
|
|
8
|
+
so this adapter just passes through whatever is in os.environ.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from forge.backend import BackendAdapter, BackendStartError
|
|
22
|
+
from forge.backend.registry import BackendInstance
|
|
23
|
+
from forge.core.paths import get_forge_home
|
|
24
|
+
from forge.core.state import now_iso
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LiteLLMAdapter(BackendAdapter):
|
|
28
|
+
"""Adapter for managing LiteLLM backend processes.
|
|
29
|
+
|
|
30
|
+
This adapter is provider-agnostic - it starts LiteLLM with whatever
|
|
31
|
+
config and environment variables are provided. The specific provider
|
|
32
|
+
(Gemini, OpenAI, etc.) is determined by:
|
|
33
|
+
1. The LiteLLM config file (model_list with provider prefixes)
|
|
34
|
+
2. Environment variables (GEMINI_API_KEY, OPENAI_API_KEY, etc.)
|
|
35
|
+
|
|
36
|
+
Env var validation is handled by the caller (_ensure_dependency_backend)
|
|
37
|
+
which checks BackendDependency.required_env_vars before starting.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def _wait_for_health(self, port: int, timeout: float = 15.0) -> bool:
|
|
41
|
+
"""Wait for backend to become healthy.
|
|
42
|
+
|
|
43
|
+
Uses /health/liveliness (not /health) because the full health endpoint
|
|
44
|
+
runs model-level checks against remote providers, which can take 5-10s.
|
|
45
|
+
The liveliness endpoint just verifies the server process is accepting
|
|
46
|
+
requests (~5ms).
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
port: Port to check
|
|
50
|
+
timeout: Timeout in seconds
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if healthy within timeout, False otherwise
|
|
54
|
+
"""
|
|
55
|
+
start_time = time.time()
|
|
56
|
+
url = f"http://localhost:{port}/health/liveliness"
|
|
57
|
+
|
|
58
|
+
while time.time() - start_time < timeout:
|
|
59
|
+
try:
|
|
60
|
+
with httpx.Client(timeout=httpx.Timeout(2.0)) as client:
|
|
61
|
+
response = client.get(url)
|
|
62
|
+
if response.status_code == 200:
|
|
63
|
+
return True
|
|
64
|
+
except (httpx.RequestError, httpx.TimeoutException):
|
|
65
|
+
pass
|
|
66
|
+
time.sleep(0.5)
|
|
67
|
+
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def start(self, backend_id: str, config_path: Path, port: int) -> BackendInstance:
|
|
71
|
+
"""Start LiteLLM backend.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
backend_id: Backend instance ID (e.g., "litellm-4000")
|
|
75
|
+
config_path: Path to LiteLLM config file
|
|
76
|
+
port: Port number to bind
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
BackendInstance with PID and status
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
BackendStartError: If backend fails to start
|
|
83
|
+
|
|
84
|
+
Note:
|
|
85
|
+
Required env vars (GEMINI_API_KEY, OPENAI_API_KEY, etc.) should
|
|
86
|
+
already be in os.environ - validation happens before this method
|
|
87
|
+
is called. We pass through the full environment to the subprocess.
|
|
88
|
+
"""
|
|
89
|
+
# Build command — resolve the litellm binary from the same venv as
|
|
90
|
+
# sys.executable, rather than relying on a bare 'litellm' on PATH.
|
|
91
|
+
# Console scripts (pip, litellm, etc.) are always siblings of python
|
|
92
|
+
# in the venv's bin/ directory.
|
|
93
|
+
litellm_bin = str(Path(sys.executable).parent / "litellm")
|
|
94
|
+
cmd = [litellm_bin, "--config", str(config_path), "--port", str(port)]
|
|
95
|
+
|
|
96
|
+
# Pass through current environment (includes API keys loaded by load_config)
|
|
97
|
+
log_file = get_forge_home() / "logs" / "backend" / f"litellm-{port}.log"
|
|
98
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
with log_file.open("a") as log:
|
|
101
|
+
proc = subprocess.Popen(
|
|
102
|
+
cmd,
|
|
103
|
+
env=os.environ.copy(),
|
|
104
|
+
stdout=log,
|
|
105
|
+
stderr=subprocess.STDOUT,
|
|
106
|
+
start_new_session=True, # Detach from parent
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Wait for health (timeout 10s)
|
|
110
|
+
if not self._wait_for_health(port, timeout=10):
|
|
111
|
+
try:
|
|
112
|
+
proc.kill()
|
|
113
|
+
except OSError:
|
|
114
|
+
pass # Process already exited
|
|
115
|
+
raise BackendStartError(f"LiteLLM failed to start on port {port}\nCheck logs: {log_file}")
|
|
116
|
+
|
|
117
|
+
return BackendInstance(
|
|
118
|
+
backend_id=backend_id,
|
|
119
|
+
adapter_type="litellm",
|
|
120
|
+
port=port,
|
|
121
|
+
pid=proc.pid,
|
|
122
|
+
status="healthy",
|
|
123
|
+
created_at=now_iso(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def stop(self, instance: BackendInstance) -> None:
|
|
127
|
+
"""Stop LiteLLM backend (best effort).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
instance: Backend instance to stop
|
|
131
|
+
"""
|
|
132
|
+
if instance.pid is None:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
os.kill(instance.pid, 15) # SIGTERM
|
|
137
|
+
except (ProcessLookupError, PermissionError):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def health_check(self, instance: BackendInstance) -> bool:
|
|
141
|
+
"""Check if LiteLLM backend is healthy.
|
|
142
|
+
|
|
143
|
+
Uses /health/liveliness for fast checks (~5ms) rather than the full
|
|
144
|
+
/health endpoint which contacts all model providers (~5-10s).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
instance: Backend instance to check
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if healthy, False otherwise
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
url = f"http://localhost:{instance.port}/health/liveliness"
|
|
154
|
+
with httpx.Client(timeout=httpx.Timeout(2.0)) as client:
|
|
155
|
+
response = client.get(url)
|
|
156
|
+
return response.status_code == 200
|
|
157
|
+
except (httpx.RequestError, httpx.TimeoutException):
|
|
158
|
+
return False
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Backend config creation (copy templates to installed location)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forge.config.loader import get_defaults_dir
|
|
10
|
+
from forge.core.paths import get_forge_home
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_backend_config(
|
|
14
|
+
adapter_type: str,
|
|
15
|
+
source_config: Path | None = None,
|
|
16
|
+
) -> Path:
|
|
17
|
+
"""Create backend config by copying to installed location.
|
|
18
|
+
|
|
19
|
+
Config is shared by all instances of the same adapter type.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
adapter_type: Adapter type (e.g., "litellm")
|
|
23
|
+
source_config: Source config file (defaults to defaults/backends/{adapter}.yaml)
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Path to created config file
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If adapter type is unknown or source config not found
|
|
30
|
+
"""
|
|
31
|
+
backend_dir = get_forge_home() / "backends" / adapter_type
|
|
32
|
+
backend_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
# Determine source config
|
|
35
|
+
if source_config is None:
|
|
36
|
+
# Use convention-based path: defaults/backends/{adapter}.yaml
|
|
37
|
+
defaults_dir = get_defaults_dir() # src/forge/config/defaults/
|
|
38
|
+
source_config = defaults_dir / "backends" / f"{adapter_type}.yaml"
|
|
39
|
+
|
|
40
|
+
if not source_config.exists():
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"No default config for adapter '{adapter_type}': {source_config}\n"
|
|
43
|
+
f"Either provide --config or create {source_config}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Copy config (idempotent - overwrites if exists)
|
|
47
|
+
dest_config = backend_dir / "config.yaml"
|
|
48
|
+
shutil.copy(source_config, dest_config)
|
|
49
|
+
dest_config.chmod(0o600)
|
|
50
|
+
|
|
51
|
+
return dest_config
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_backend_config_path(adapter_type: str) -> Path:
|
|
55
|
+
"""Get path to backend config file.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
adapter_type: Adapter type (e.g., "litellm")
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Path to config file (may not exist yet)
|
|
62
|
+
"""
|
|
63
|
+
return get_forge_home() / "backends" / adapter_type / "config.yaml"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_backend_config_outdated(adapter_type: str) -> bool:
|
|
67
|
+
"""Check if installed backend config differs from the default.
|
|
68
|
+
|
|
69
|
+
Compares SHA256 digests of the installed config and the default template.
|
|
70
|
+
Returns True if the installed config exists but differs from the default
|
|
71
|
+
(meaning new models or settings may be available).
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
adapter_type: Adapter type (e.g., "litellm")
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if installed config is outdated, False otherwise.
|
|
78
|
+
"""
|
|
79
|
+
installed = get_backend_config_path(adapter_type)
|
|
80
|
+
if not installed.exists():
|
|
81
|
+
return False # No config yet — will be created on first use
|
|
82
|
+
|
|
83
|
+
default = get_defaults_dir() / "backends" / f"{adapter_type}.yaml"
|
|
84
|
+
if not default.exists():
|
|
85
|
+
return False # No default to compare against
|
|
86
|
+
|
|
87
|
+
installed_digest = hashlib.sha256(installed.read_bytes()).hexdigest()
|
|
88
|
+
default_digest = hashlib.sha256(default.read_bytes()).hexdigest()
|
|
89
|
+
return installed_digest != default_digest
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Backend registry for Forge backend services.
|
|
2
|
+
|
|
3
|
+
A backend is a service that proxies depend on (e.g., LiteLLM on port 4000).
|
|
4
|
+
The backend registry is stored at:
|
|
5
|
+
|
|
6
|
+
- ~/.forge/backends/index.json
|
|
7
|
+
|
|
8
|
+
This module implements a small, versioned JSON store with atomic writes.
|
|
9
|
+
|
|
10
|
+
Ownership: Forge Backend Manager (`forge backend` CLI).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, Literal
|
|
20
|
+
|
|
21
|
+
import dacite
|
|
22
|
+
|
|
23
|
+
from forge.core.paths import get_forge_home
|
|
24
|
+
from forge.core.state import (
|
|
25
|
+
StateCorruptedError,
|
|
26
|
+
atomic_write_json,
|
|
27
|
+
file_lock_for_target,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
BACKEND_REGISTRY_VERSION = 1
|
|
33
|
+
BACKENDS_DIR = "backends"
|
|
34
|
+
BACKEND_INDEX_FILENAME = "index.json"
|
|
35
|
+
|
|
36
|
+
CLI_LOCK_TIMEOUT_S = 5.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
from forge.core.process import is_pid_alive as is_pid_alive # noqa: E402, F401 # re-export
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BackendRegistryCorruptedError(StateCorruptedError):
|
|
43
|
+
"""Raised when the backend registry cannot be parsed."""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class BackendInstance:
|
|
50
|
+
"""A backend instance (used both in registry and at runtime).
|
|
51
|
+
|
|
52
|
+
Timestamps are stored as ISO8601 strings.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
backend_id: str # e.g., "litellm-4000"
|
|
56
|
+
adapter_type: str # e.g., "litellm"
|
|
57
|
+
port: int
|
|
58
|
+
pid: int | None = None
|
|
59
|
+
status: Literal["healthy", "unhealthy", "stopped", "unknown"] = "unknown"
|
|
60
|
+
created_at: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BackendRegistry:
|
|
65
|
+
"""Backend registry file format."""
|
|
66
|
+
|
|
67
|
+
version: int = BACKEND_REGISTRY_VERSION
|
|
68
|
+
backends: dict[str, BackendInstance] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_backend_registry_path() -> Path:
|
|
72
|
+
"""Return the full path to the backend registry file."""
|
|
73
|
+
|
|
74
|
+
return get_forge_home() / BACKENDS_DIR / BACKEND_INDEX_FILENAME
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BackendRegistryStore:
|
|
78
|
+
"""Manage the backend registry at ~/.forge/backends/index.json.
|
|
79
|
+
|
|
80
|
+
Error handling:
|
|
81
|
+
- Missing file: returns empty registry (self-healing)
|
|
82
|
+
- Corrupted file: raises BackendRegistryCorruptedError
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, registry_path: Path | None = None) -> None:
|
|
86
|
+
self._registry_path = registry_path or get_backend_registry_path()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def registry_path(self) -> Path:
|
|
90
|
+
return self._registry_path
|
|
91
|
+
|
|
92
|
+
def exists(self) -> bool:
|
|
93
|
+
return self._registry_path.is_file()
|
|
94
|
+
|
|
95
|
+
def read(self) -> BackendRegistry:
|
|
96
|
+
if not self.exists():
|
|
97
|
+
return BackendRegistry()
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
with open(self._registry_path, encoding="utf-8") as f:
|
|
101
|
+
data = json.load(f)
|
|
102
|
+
except json.JSONDecodeError as e:
|
|
103
|
+
raise BackendRegistryCorruptedError(str(self._registry_path), f"invalid JSON: {e}")
|
|
104
|
+
except OSError as e:
|
|
105
|
+
raise BackendRegistryCorruptedError(str(self._registry_path), f"read error: {e}")
|
|
106
|
+
|
|
107
|
+
version = data.get("version")
|
|
108
|
+
if version is None:
|
|
109
|
+
raise BackendRegistryCorruptedError(str(self._registry_path), "missing version field")
|
|
110
|
+
if version != BACKEND_REGISTRY_VERSION:
|
|
111
|
+
raise BackendRegistryCorruptedError(
|
|
112
|
+
str(self._registry_path),
|
|
113
|
+
f"incompatible version {version} (this Forge expects {BACKEND_REGISTRY_VERSION}). "
|
|
114
|
+
f"Delete this file and retry.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
return dacite.from_dict(
|
|
119
|
+
data_class=BackendRegistry,
|
|
120
|
+
data=data,
|
|
121
|
+
config=dacite.Config(strict=True),
|
|
122
|
+
)
|
|
123
|
+
except (dacite.DaciteError, TypeError, KeyError) as e:
|
|
124
|
+
raise BackendRegistryCorruptedError(str(self._registry_path), f"deserialization error: {e}")
|
|
125
|
+
|
|
126
|
+
def write(self, registry: BackendRegistry) -> None:
|
|
127
|
+
data = asdict(registry)
|
|
128
|
+
atomic_write_json(self._registry_path, data)
|
|
129
|
+
|
|
130
|
+
def update(self, *, timeout_s: float, mutate: Callable[[BackendRegistry], None]) -> BackendRegistry:
|
|
131
|
+
"""Update registry via a locked read-modify-write cycle."""
|
|
132
|
+
|
|
133
|
+
with file_lock_for_target(target_path=self._registry_path, timeout_s=timeout_s):
|
|
134
|
+
registry = self.read()
|
|
135
|
+
mutate(registry)
|
|
136
|
+
self.write(registry)
|
|
137
|
+
return registry
|
|
138
|
+
|
|
139
|
+
def prune_dead_pids(self, *, timeout_s: float = CLI_LOCK_TIMEOUT_S) -> list[str]:
|
|
140
|
+
"""Remove registry entries whose Forge-spawned pid is no longer running.
|
|
141
|
+
|
|
142
|
+
Definition of stale (normative):
|
|
143
|
+
- Only entries with pid != None are considered (Forge-spawned backends).
|
|
144
|
+
- Entries with pid == None are never auto-pruned.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of backend IDs removed from the registry.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
with file_lock_for_target(target_path=self._registry_path, timeout_s=timeout_s):
|
|
151
|
+
registry = self.read()
|
|
152
|
+
|
|
153
|
+
stale_ids: list[str] = []
|
|
154
|
+
for backend_id, entry in list(registry.backends.items()):
|
|
155
|
+
if entry.pid is None:
|
|
156
|
+
continue
|
|
157
|
+
if not is_pid_alive(entry.pid):
|
|
158
|
+
del registry.backends[backend_id]
|
|
159
|
+
stale_ids.append(backend_id)
|
|
160
|
+
|
|
161
|
+
if stale_ids:
|
|
162
|
+
self.write(registry)
|
|
163
|
+
|
|
164
|
+
return stale_ids
|
|
165
|
+
|
|
166
|
+
def list_backends(self) -> list[BackendInstance]:
|
|
167
|
+
"""List all backends (prunes dead PIDs first).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of backend instances, ordered by creation time (oldest first).
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
self.prune_dead_pids()
|
|
174
|
+
registry = self.read()
|
|
175
|
+
|
|
176
|
+
backends = list(registry.backends.values())
|
|
177
|
+
backends.sort(key=lambda x: x.created_at or "")
|
|
178
|
+
return backends
|
forge/cli/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Forge CLI - Command line interface for Multi-Forge.
|
|
2
|
+
|
|
3
|
+
Entry point: `forge` command (installed via pyproject.toml scripts).
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
forge session start [name] # Create and start a new session
|
|
7
|
+
forge session resume <name> # Resume an existing session (reattach or --fresh for context assembly)
|
|
8
|
+
forge session list # List all sessions
|
|
9
|
+
forge session delete <name> # Delete a session
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .main import main
|
|
15
|
+
|
|
16
|
+
__all__ = ["main"]
|