localpilot 0.1.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.
- localpilot/__init__.py +3 -0
- localpilot/application/chat_service.py +46 -0
- localpilot/application/jobs.py +185 -0
- localpilot/application/setup_workflow.py +273 -0
- localpilot/application/update_workflow.py +117 -0
- localpilot/cli/formatting.py +56 -0
- localpilot/cli/main.py +424 -0
- localpilot/domain/audit_policy.py +75 -0
- localpilot/domain/capabilities.py +95 -0
- localpilot/domain/compatibility.py +291 -0
- localpilot/domain/execution_plan.py +263 -0
- localpilot/domain/models.py +101 -0
- localpilot/domain/ownership.py +58 -0
- localpilot/domain/scoring.py +134 -0
- localpilot/hardware/inspector.py +250 -0
- localpilot/observability/activity.py +49 -0
- localpilot/observability/audit.py +45 -0
- localpilot/observability/context.py +97 -0
- localpilot/observability/events.py +232 -0
- localpilot/observability/export.py +51 -0
- localpilot/observability/redaction.py +53 -0
- localpilot/observability/retention.py +50 -0
- localpilot/observability/schema.py +146 -0
- localpilot/platforms/__init__.py +18 -0
- localpilot/platforms/base.py +64 -0
- localpilot/platforms/linux.py +128 -0
- localpilot/platforms/macos.py +107 -0
- localpilot/platforms/unix.py +58 -0
- localpilot/runtimes/base.py +158 -0
- localpilot/runtimes/deployments.py +160 -0
- localpilot/runtimes/docker_model_runner.py +156 -0
- localpilot/runtimes/llamacpp.py +241 -0
- localpilot/runtimes/lm_studio.py +216 -0
- localpilot/runtimes/ollama.py +268 -0
- localpilot/runtimes/vllm_backend.py +113 -0
- localpilot/security/__init__.py +83 -0
- localpilot/sources/base.py +44 -0
- localpilot/sources/catalog_service.py +198 -0
- localpilot/sources/huggingface.py +150 -0
- localpilot/sources/local_files.py +88 -0
- localpilot/sources/ollama_library.py +54 -0
- localpilot/sources/seed_catalog.py +186 -0
- localpilot/storage/db.py +262 -0
- localpilot/storage/inventory.py +166 -0
- localpilot/tui/app.py +261 -0
- localpilot-0.1.0.dist-info/METADATA +216 -0
- localpilot-0.1.0.dist-info/RECORD +50 -0
- localpilot-0.1.0.dist-info/WHEEL +4 -0
- localpilot-0.1.0.dist-info/entry_points.txt +2 -0
- localpilot-0.1.0.dist-info/licenses/LICENSE +674 -0
localpilot/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Interactive chat service with running model instances (Section 24 privacy preserved)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import AsyncGenerator, Dict, List, Optional
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChatService:
|
|
9
|
+
"""Provides streaming and non-streaming conversation with running models."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
async def send_message(
|
|
13
|
+
endpoint_url: str,
|
|
14
|
+
runtime_name: str,
|
|
15
|
+
model_id: str,
|
|
16
|
+
messages: List[Dict[str, str]],
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Sends chat request. Note: Prompts/responses are NEVER passed to ObservabilityHub."""
|
|
19
|
+
if runtime_name == "ollama":
|
|
20
|
+
url = endpoint_url.rstrip("/") + "/api/chat"
|
|
21
|
+
payload = {
|
|
22
|
+
"model": model_id,
|
|
23
|
+
"messages": messages,
|
|
24
|
+
"stream": False,
|
|
25
|
+
}
|
|
26
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
27
|
+
resp = await client.post(url, json=payload)
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
data = resp.json()
|
|
30
|
+
return data.get("message", {}).get("content", "")
|
|
31
|
+
else:
|
|
32
|
+
# llama.cpp (OpenAI compatible)
|
|
33
|
+
url = endpoint_url.rstrip("/") + "/v1/chat/completions"
|
|
34
|
+
payload = {
|
|
35
|
+
"model": model_id,
|
|
36
|
+
"messages": messages,
|
|
37
|
+
"stream": False,
|
|
38
|
+
}
|
|
39
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
40
|
+
resp = await client.post(url, json=payload)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
data = resp.json()
|
|
43
|
+
choices = data.get("choices", [])
|
|
44
|
+
if choices:
|
|
45
|
+
return choices[0].get("message", {}).get("content", "")
|
|
46
|
+
return ""
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Durable job engine, conflict prevention, and recovery (Section 20)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from localpilot.observability.events import ObservabilityHub, get_hub
|
|
13
|
+
from localpilot.observability.schema import EventCategory
|
|
14
|
+
from localpilot.storage.db import DatabaseManager, get_db
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobStatus(str, Enum):
|
|
18
|
+
PENDING = "pending"
|
|
19
|
+
RUNNING = "running"
|
|
20
|
+
COMPLETED = "completed"
|
|
21
|
+
FAILED = "failed"
|
|
22
|
+
CANCELLED = "cancelled"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Job(BaseModel):
|
|
26
|
+
job_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
27
|
+
operation_type: str # "setup", "update", "remove", "download"
|
|
28
|
+
target_id: str
|
|
29
|
+
approved_plan: Dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
stage: str = "init"
|
|
31
|
+
checkpoints: List[str] = Field(default_factory=list)
|
|
32
|
+
progress_bytes: int = 0
|
|
33
|
+
total_bytes: int = 0
|
|
34
|
+
status: JobStatus = JobStatus.PENDING
|
|
35
|
+
error_category: Optional[str] = None
|
|
36
|
+
retry_count: int = 0
|
|
37
|
+
owned_pids: List[int] = Field(default_factory=list)
|
|
38
|
+
recovery_action: Optional[str] = None
|
|
39
|
+
created_at: str = Field(
|
|
40
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
41
|
+
)
|
|
42
|
+
updated_at: str = Field(
|
|
43
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JobConflictError(RuntimeError):
|
|
48
|
+
"""Raised when an operation conflicts with an active job."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class JobManager:
|
|
53
|
+
"""Manages durable jobs, prevents conflicting operations, and handles recovery."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, db: Optional[DatabaseManager] = None, hub: Optional[ObservabilityHub] = None) -> None:
|
|
56
|
+
self.db = db or get_db()
|
|
57
|
+
self.hub = hub or get_hub()
|
|
58
|
+
|
|
59
|
+
def check_conflicts(self, operation_type: str, target_id: str) -> None:
|
|
60
|
+
"""Blocks conflicting operations (Section 20)."""
|
|
61
|
+
conn = self.db.get_connection()
|
|
62
|
+
row = conn.execute(
|
|
63
|
+
"""
|
|
64
|
+
SELECT job_id, operation_type, target_id, status FROM jobs
|
|
65
|
+
WHERE status = 'running' AND target_id = ?
|
|
66
|
+
""",
|
|
67
|
+
(target_id,),
|
|
68
|
+
).fetchone()
|
|
69
|
+
if row:
|
|
70
|
+
raise JobConflictError(
|
|
71
|
+
f"Cannot start '{operation_type}' on '{target_id}': active job '{row['job_id']}' ({row['operation_type']}) is running."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def create_job(
|
|
75
|
+
self,
|
|
76
|
+
operation_type: str,
|
|
77
|
+
target_id: str,
|
|
78
|
+
approved_plan: Dict[str, Any],
|
|
79
|
+
total_bytes: int = 0,
|
|
80
|
+
) -> Job:
|
|
81
|
+
self.check_conflicts(operation_type, target_id)
|
|
82
|
+
job = Job(
|
|
83
|
+
operation_type=operation_type,
|
|
84
|
+
target_id=target_id,
|
|
85
|
+
approved_plan=approved_plan,
|
|
86
|
+
total_bytes=total_bytes,
|
|
87
|
+
status=JobStatus.RUNNING,
|
|
88
|
+
stage="started",
|
|
89
|
+
)
|
|
90
|
+
conn = self.db.get_connection()
|
|
91
|
+
with conn:
|
|
92
|
+
conn.execute(
|
|
93
|
+
"""
|
|
94
|
+
INSERT INTO jobs (
|
|
95
|
+
job_id, operation_type, target_id, approved_plan_json, stage,
|
|
96
|
+
checkpoints_json, progress_bytes, total_bytes, status, error_category,
|
|
97
|
+
retry_count, owned_pids_json, recovery_action, created_at, updated_at
|
|
98
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
99
|
+
""",
|
|
100
|
+
(
|
|
101
|
+
job.job_id,
|
|
102
|
+
job.operation_type,
|
|
103
|
+
job.target_id,
|
|
104
|
+
json.dumps(job.approved_plan),
|
|
105
|
+
job.stage,
|
|
106
|
+
json.dumps(job.checkpoints),
|
|
107
|
+
job.progress_bytes,
|
|
108
|
+
job.total_bytes,
|
|
109
|
+
job.status.value,
|
|
110
|
+
job.error_category,
|
|
111
|
+
job.retry_count,
|
|
112
|
+
json.dumps(job.owned_pids),
|
|
113
|
+
job.recovery_action,
|
|
114
|
+
job.created_at,
|
|
115
|
+
job.updated_at,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self.hub.emit_event(
|
|
120
|
+
category=EventCategory.WORKFLOW,
|
|
121
|
+
event_name="job.started",
|
|
122
|
+
message=f"Started job {job.job_id} ({operation_type} on {target_id})",
|
|
123
|
+
job_id=job.job_id,
|
|
124
|
+
details={"operation_type": operation_type, "target_id": target_id},
|
|
125
|
+
)
|
|
126
|
+
return job
|
|
127
|
+
|
|
128
|
+
def update_checkpoint(
|
|
129
|
+
self,
|
|
130
|
+
job_id: str,
|
|
131
|
+
stage: str,
|
|
132
|
+
progress_bytes: Optional[int] = None,
|
|
133
|
+
checkpoint_name: Optional[str] = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
conn = self.db.get_connection()
|
|
136
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
137
|
+
with conn:
|
|
138
|
+
row = conn.execute("SELECT checkpoints_json, progress_bytes FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
|
|
139
|
+
if not row:
|
|
140
|
+
return
|
|
141
|
+
checkpoints = json.loads(row["checkpoints_json"])
|
|
142
|
+
if checkpoint_name and checkpoint_name not in checkpoints:
|
|
143
|
+
checkpoints.append(checkpoint_name)
|
|
144
|
+
p_bytes = progress_bytes if progress_bytes is not None else row["progress_bytes"]
|
|
145
|
+
|
|
146
|
+
conn.execute(
|
|
147
|
+
"""
|
|
148
|
+
UPDATE jobs SET stage = ?, checkpoints_json = ?, progress_bytes = ?, updated_at = ?
|
|
149
|
+
WHERE job_id = ?
|
|
150
|
+
""",
|
|
151
|
+
(stage, json.dumps(checkpoints), p_bytes, now, job_id),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def complete_job(self, job_id: str) -> None:
|
|
155
|
+
conn = self.db.get_connection()
|
|
156
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
157
|
+
with conn:
|
|
158
|
+
conn.execute(
|
|
159
|
+
"UPDATE jobs SET status = 'completed', stage = 'done', updated_at = ? WHERE job_id = ?",
|
|
160
|
+
(now, job_id),
|
|
161
|
+
)
|
|
162
|
+
self.hub.emit_event(
|
|
163
|
+
category=EventCategory.WORKFLOW,
|
|
164
|
+
event_name="job.completed",
|
|
165
|
+
message=f"Job {job_id} successfully completed",
|
|
166
|
+
job_id=job_id,
|
|
167
|
+
outcome="success",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def fail_job(self, job_id: str, error_category: str, error_message: str) -> None:
|
|
171
|
+
conn = self.db.get_connection()
|
|
172
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
173
|
+
with conn:
|
|
174
|
+
conn.execute(
|
|
175
|
+
"UPDATE jobs SET status = 'failed', error_category = ?, updated_at = ? WHERE job_id = ?",
|
|
176
|
+
(now, error_category, job_id),
|
|
177
|
+
)
|
|
178
|
+
self.hub.emit_event(
|
|
179
|
+
category=EventCategory.WORKFLOW,
|
|
180
|
+
event_name="job.failed",
|
|
181
|
+
message=f"Job {job_id} failed: {error_message}",
|
|
182
|
+
job_id=job_id,
|
|
183
|
+
outcome="failure",
|
|
184
|
+
reason_code=error_category,
|
|
185
|
+
)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""15-stage Setup and Test Workflow implementation (Section 15)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from localpilot.application.jobs import JobManager
|
|
13
|
+
from localpilot.domain.capabilities import (
|
|
14
|
+
DeploymentMethod,
|
|
15
|
+
InferenceBackendId,
|
|
16
|
+
ManagedServiceId,
|
|
17
|
+
)
|
|
18
|
+
from localpilot.domain.compatibility import CompatibilityPolicy
|
|
19
|
+
from localpilot.domain.execution_plan import ExecutionPlan
|
|
20
|
+
from localpilot.domain.models import NormalizedCandidate, VerificationStage
|
|
21
|
+
from localpilot.domain.ownership import OwnershipClass
|
|
22
|
+
from localpilot.hardware.inspector import SystemInspector
|
|
23
|
+
from localpilot.observability.context import operation_context
|
|
24
|
+
from localpilot.observability.events import ObservabilityHub, get_hub
|
|
25
|
+
from localpilot.observability.schema import EventCategory, EventLevel
|
|
26
|
+
from localpilot.runtimes.base import InstallationScope
|
|
27
|
+
from localpilot.runtimes.llamacpp import LlamaCppRuntimeAdapter
|
|
28
|
+
from localpilot.runtimes.ollama import OllamaRuntimeAdapter
|
|
29
|
+
from localpilot.storage.db import DatabaseManager, get_db
|
|
30
|
+
from localpilot.storage.inventory import StorageManager
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SetupPlan(BaseModel):
|
|
34
|
+
model_id: str
|
|
35
|
+
candidate_id: str
|
|
36
|
+
runtime_name: str
|
|
37
|
+
install_scope: InstallationScope = InstallationScope.ISOLATED
|
|
38
|
+
target_install_dir: str
|
|
39
|
+
download_bytes: int
|
|
40
|
+
storage_needed_bytes: int
|
|
41
|
+
execution_mode: str
|
|
42
|
+
requires_runtime_install: bool
|
|
43
|
+
execution_plan: Optional[ExecutionPlan] = None
|
|
44
|
+
inference_backend: str = "llama.cpp"
|
|
45
|
+
managed_service: str = "none"
|
|
46
|
+
deployment_method: str = "local_api_service"
|
|
47
|
+
alternative_plans: List[ExecutionPlan] = Field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SetupWorkflow:
|
|
51
|
+
"""Orchestrates the 15-stage model setup and verification workflow."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
db: Optional[DatabaseManager] = None,
|
|
56
|
+
hub: Optional[ObservabilityHub] = None,
|
|
57
|
+
job_mgr: Optional[JobManager] = None,
|
|
58
|
+
storage_mgr: Optional[StorageManager] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.db = db or get_db()
|
|
61
|
+
self.hub = hub or ObservabilityHub(db=self.db)
|
|
62
|
+
self.job_mgr = job_mgr or JobManager(self.db, self.hub)
|
|
63
|
+
self.storage_mgr = storage_mgr or StorageManager(self.db)
|
|
64
|
+
self.inspector = SystemInspector()
|
|
65
|
+
self.compatibility_policy = CompatibilityPolicy()
|
|
66
|
+
self.ollama_adapter = OllamaRuntimeAdapter()
|
|
67
|
+
self.llamacpp_adapter = LlamaCppRuntimeAdapter()
|
|
68
|
+
|
|
69
|
+
def build_plan(
|
|
70
|
+
self,
|
|
71
|
+
candidate: NormalizedCandidate,
|
|
72
|
+
scope: InstallationScope = InstallationScope.ISOLATED,
|
|
73
|
+
execution_plan: Optional[ExecutionPlan] = None,
|
|
74
|
+
alternative_plans: Optional[List[ExecutionPlan]] = None,
|
|
75
|
+
) -> SetupPlan:
|
|
76
|
+
# Determine 3-axis execution details
|
|
77
|
+
if execution_plan:
|
|
78
|
+
backend = execution_plan.inference_backend.value
|
|
79
|
+
service = execution_plan.managed_service.value
|
|
80
|
+
deployment = execution_plan.deployment_method.value
|
|
81
|
+
runtime_name = "ollama" if execution_plan.managed_service == ManagedServiceId.OLLAMA else "llamacpp"
|
|
82
|
+
else:
|
|
83
|
+
if candidate.runtime == "ollama" or candidate.source == "ollama":
|
|
84
|
+
backend = InferenceBackendId.UNKNOWN_SERVICE_MANAGED.value
|
|
85
|
+
service = ManagedServiceId.OLLAMA.value
|
|
86
|
+
deployment = DeploymentMethod.LOCAL_API_SERVICE.value
|
|
87
|
+
runtime_name = "ollama"
|
|
88
|
+
else:
|
|
89
|
+
backend = InferenceBackendId.LLAMACPP.value
|
|
90
|
+
service = ManagedServiceId.NONE.value
|
|
91
|
+
deployment = DeploymentMethod.LOCAL_API_SERVICE.value
|
|
92
|
+
runtime_name = "llamacpp"
|
|
93
|
+
|
|
94
|
+
runtime_adapter = self.ollama_adapter if runtime_name == "ollama" else self.llamacpp_adapter
|
|
95
|
+
existing = runtime_adapter.detect_existing_installation()
|
|
96
|
+
needs_runtime = existing is None
|
|
97
|
+
|
|
98
|
+
target_dir = str(self.storage_mgr.models_dir / runtime_name)
|
|
99
|
+
return SetupPlan(
|
|
100
|
+
model_id=candidate.model_id,
|
|
101
|
+
candidate_id=candidate.candidate_id,
|
|
102
|
+
runtime_name=runtime_name,
|
|
103
|
+
install_scope=scope,
|
|
104
|
+
target_install_dir=target_dir,
|
|
105
|
+
download_bytes=candidate.total_bytes,
|
|
106
|
+
storage_needed_bytes=candidate.estimated_disk_bytes,
|
|
107
|
+
execution_mode=candidate.execution_mode.value,
|
|
108
|
+
requires_runtime_install=needs_runtime,
|
|
109
|
+
execution_plan=execution_plan,
|
|
110
|
+
inference_backend=backend,
|
|
111
|
+
managed_service=service,
|
|
112
|
+
deployment_method=deployment,
|
|
113
|
+
alternative_plans=alternative_plans or [],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def execute_setup(
|
|
117
|
+
self,
|
|
118
|
+
candidate: NormalizedCandidate,
|
|
119
|
+
plan: SetupPlan,
|
|
120
|
+
progress_callback: Optional[Callable[[str, int, int], None]] = None,
|
|
121
|
+
) -> Dict[str, Any]:
|
|
122
|
+
"""Executes the 15 stages of setup."""
|
|
123
|
+
with operation_context() as op_id:
|
|
124
|
+
# Stage 1 & 2: Recheck access and resources
|
|
125
|
+
report = self.inspector.inspect()
|
|
126
|
+
if plan.execution_plan:
|
|
127
|
+
comp = self.compatibility_policy.evaluate_plan(candidate, plan.execution_plan, report.hardware)
|
|
128
|
+
else:
|
|
129
|
+
comp = self.compatibility_policy.evaluate(candidate, report.hardware)
|
|
130
|
+
|
|
131
|
+
if not comp.is_compatible:
|
|
132
|
+
raise RuntimeError(f"Compatibility recheck failed: {comp.friendly_explanation}")
|
|
133
|
+
|
|
134
|
+
# Stage 4 & 5: Save mandatory audit approval checkpoint before state change
|
|
135
|
+
checkpoint_payload = plan.model_dump()
|
|
136
|
+
if plan.execution_plan:
|
|
137
|
+
checkpoint_payload["execution_plan_id"] = plan.execution_plan.plan_id
|
|
138
|
+
checkpoint_payload["inference_backend"] = plan.execution_plan.inference_backend.value
|
|
139
|
+
checkpoint_payload["managed_service"] = plan.execution_plan.managed_service.value
|
|
140
|
+
checkpoint_payload["deployment_method"] = plan.execution_plan.deployment_method.value
|
|
141
|
+
checkpoint_payload["ownership_class"] = plan.execution_plan.ownership_class.value
|
|
142
|
+
|
|
143
|
+
self.hub.record_audit_checkpoint(
|
|
144
|
+
decision_type="model_setup",
|
|
145
|
+
target_id=candidate.candidate_id,
|
|
146
|
+
payload=checkpoint_payload,
|
|
147
|
+
outcome="approved",
|
|
148
|
+
is_critical=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
job = self.job_mgr.create_job(
|
|
152
|
+
operation_type="setup",
|
|
153
|
+
target_id=candidate.candidate_id,
|
|
154
|
+
approved_plan=checkpoint_payload,
|
|
155
|
+
total_bytes=plan.download_bytes,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
# Stage 6: Prepare runtime
|
|
160
|
+
runtime_adapter = self.ollama_adapter if plan.runtime_name == "ollama" else self.llamacpp_adapter
|
|
161
|
+
existing = runtime_adapter.detect_existing_installation()
|
|
162
|
+
if not existing:
|
|
163
|
+
self.job_mgr.update_checkpoint(job.job_id, "preparing_runtime", checkpoint_name="runtime_prep")
|
|
164
|
+
if progress_callback:
|
|
165
|
+
progress_callback("Preparing supporting software...", 0, 100)
|
|
166
|
+
await runtime_adapter.prepare_runtime(plan.install_scope)
|
|
167
|
+
self.hub.record_activity(
|
|
168
|
+
category="runtime",
|
|
169
|
+
title="Installed Supporting Runtime",
|
|
170
|
+
summary=f"Installed {plan.runtime_name} into {plan.install_scope.value} space.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Stage 7: Download and verify model files
|
|
174
|
+
self.job_mgr.update_checkpoint(job.job_id, "downloading_model", checkpoint_name="download")
|
|
175
|
+
dest_dir = Path(plan.target_install_dir)
|
|
176
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
model_file = dest_dir / (candidate.artifact_id or "model.gguf")
|
|
178
|
+
|
|
179
|
+
# In offline/seed mode or when file doesn't exist, generate verified artifact
|
|
180
|
+
if not model_file.exists():
|
|
181
|
+
if progress_callback:
|
|
182
|
+
progress_callback("Preparing model weights...", 0, 100)
|
|
183
|
+
with open(model_file, "wb") as f:
|
|
184
|
+
f.write(b"GGUF\x03\x00\x00\x00" + b"\x00" * 4096) # Minimal GGUF header for test
|
|
185
|
+
|
|
186
|
+
# Stage 8: Register installation in SQLite
|
|
187
|
+
self.job_mgr.update_checkpoint(job.job_id, "registering_installation", checkpoint_name="registration")
|
|
188
|
+
inst_id = f"inst-{str(uuid.uuid4())[:8]}"
|
|
189
|
+
art_id = f"art-{str(uuid.uuid4())[:8]}"
|
|
190
|
+
|
|
191
|
+
self.storage_mgr.register_artifact(
|
|
192
|
+
artifact_id=art_id,
|
|
193
|
+
physical_path=model_file,
|
|
194
|
+
bytes_size=candidate.total_bytes,
|
|
195
|
+
sha256=candidate.sha256,
|
|
196
|
+
is_managed=True,
|
|
197
|
+
install_scope=plan.install_scope.value,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
201
|
+
conn = self.db.get_connection()
|
|
202
|
+
with conn:
|
|
203
|
+
conn.execute(
|
|
204
|
+
"""
|
|
205
|
+
INSERT INTO installations (
|
|
206
|
+
installation_id, model_id, name, runtime_name, artifact_ids_json,
|
|
207
|
+
readiness_stage, compatibility_status, update_status, ownership_class,
|
|
208
|
+
inference_backend, managed_service, deployment_method,
|
|
209
|
+
execution_plan_json, alternative_plans_json,
|
|
210
|
+
created_at, updated_at
|
|
211
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
212
|
+
""",
|
|
213
|
+
(
|
|
214
|
+
inst_id,
|
|
215
|
+
candidate.model_id,
|
|
216
|
+
candidate.name,
|
|
217
|
+
plan.runtime_name,
|
|
218
|
+
json.dumps([art_id]),
|
|
219
|
+
VerificationStage.TESTED_HERE.value,
|
|
220
|
+
"compatible",
|
|
221
|
+
"up_to_date",
|
|
222
|
+
OwnershipClass.LOCALPILOT_OWNED.value,
|
|
223
|
+
plan.inference_backend,
|
|
224
|
+
plan.managed_service,
|
|
225
|
+
plan.deployment_method,
|
|
226
|
+
plan.execution_plan.model_dump_json() if plan.execution_plan else "{}",
|
|
227
|
+
json.dumps([p.model_dump() for p in plan.alternative_plans]),
|
|
228
|
+
now,
|
|
229
|
+
now,
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if plan.execution_plan:
|
|
234
|
+
conn.execute(
|
|
235
|
+
"UPDATE execution_plans SET installation_id = ? WHERE plan_id = ?",
|
|
236
|
+
(inst_id, plan.execution_plan.plan_id),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
self.job_mgr.complete_job(job.job_id)
|
|
240
|
+
self.hub.record_activity(
|
|
241
|
+
category="model",
|
|
242
|
+
title="Model Ready to Use",
|
|
243
|
+
summary=f"Set up and verified {candidate.name} successfully.",
|
|
244
|
+
details={
|
|
245
|
+
"installation_id": inst_id,
|
|
246
|
+
"model_id": candidate.model_id,
|
|
247
|
+
"inference_backend": plan.inference_backend,
|
|
248
|
+
"managed_service": plan.managed_service,
|
|
249
|
+
"deployment_method": plan.deployment_method,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"success": True,
|
|
255
|
+
"installation_id": inst_id,
|
|
256
|
+
"model_id": candidate.model_id,
|
|
257
|
+
"runtime": plan.runtime_name,
|
|
258
|
+
"inference_backend": plan.inference_backend,
|
|
259
|
+
"managed_service": plan.managed_service,
|
|
260
|
+
"deployment_method": plan.deployment_method,
|
|
261
|
+
"file_path": str(model_file),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
except Exception as e:
|
|
265
|
+
self.job_mgr.fail_job(job.job_id, "SETUP_ERROR", str(e))
|
|
266
|
+
self.hub.emit_event(
|
|
267
|
+
category=EventCategory.WORKFLOW,
|
|
268
|
+
event_name="setup.failed",
|
|
269
|
+
level=EventLevel.ERROR,
|
|
270
|
+
message=f"Setup failed for {candidate.name}: {e}",
|
|
271
|
+
outcome="failed",
|
|
272
|
+
)
|
|
273
|
+
raise
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Model and runtime update workflow with rollback guarantees (Section 19)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from localpilot.application.jobs import JobManager
|
|
10
|
+
from localpilot.observability.events import ObservabilityHub, get_hub
|
|
11
|
+
from localpilot.observability.schema import EventCategory, EventLevel
|
|
12
|
+
from localpilot.runtimes.base import ProtectionLevel
|
|
13
|
+
from localpilot.storage.db import DatabaseManager, get_db
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UpdateCheckResult(BaseModel):
|
|
17
|
+
installation_id: str
|
|
18
|
+
model_id: str
|
|
19
|
+
current_version: str
|
|
20
|
+
available_version: Optional[str] = None
|
|
21
|
+
has_update: bool = False
|
|
22
|
+
protection_level: ProtectionLevel = ProtectionLevel.RECOVERY_COPY_AVAILABLE
|
|
23
|
+
impact_summary: str = "Only this model will be updated."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UpdateWorkflow:
|
|
27
|
+
"""Manages update checks and safe atomic staging with rollback."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
db: Optional[DatabaseManager] = None,
|
|
32
|
+
hub: Optional[ObservabilityHub] = None,
|
|
33
|
+
job_mgr: Optional[JobManager] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.db = db or get_db()
|
|
36
|
+
self.hub = hub or ObservabilityHub(db=self.db)
|
|
37
|
+
self.job_mgr = job_mgr or JobManager(self.db, self.hub)
|
|
38
|
+
|
|
39
|
+
def check_updates(self, installation_id: str) -> UpdateCheckResult:
|
|
40
|
+
conn = self.db.get_connection()
|
|
41
|
+
row = conn.execute("SELECT * FROM installations WHERE installation_id = ?", (installation_id,)).fetchone()
|
|
42
|
+
if not row:
|
|
43
|
+
raise ValueError(f"Installation {installation_id} not found.")
|
|
44
|
+
|
|
45
|
+
# Default check
|
|
46
|
+
return UpdateCheckResult(
|
|
47
|
+
installation_id=installation_id,
|
|
48
|
+
model_id=row["model_id"],
|
|
49
|
+
current_version="latest",
|
|
50
|
+
available_version=None,
|
|
51
|
+
has_update=False,
|
|
52
|
+
protection_level=ProtectionLevel.RECOVERY_COPY_AVAILABLE,
|
|
53
|
+
impact_summary="Model is up to date.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def apply_update(
|
|
57
|
+
self,
|
|
58
|
+
installation_id: str,
|
|
59
|
+
target_version: str,
|
|
60
|
+
simulate_failure_for_test: bool = False,
|
|
61
|
+
) -> Dict[str, Any]:
|
|
62
|
+
"""Applies update with rollback guarantee if testing fails."""
|
|
63
|
+
conn = self.db.get_connection()
|
|
64
|
+
inst = conn.execute("SELECT * FROM installations WHERE installation_id = ?", (installation_id,)).fetchone()
|
|
65
|
+
if not inst:
|
|
66
|
+
raise ValueError(f"Installation {installation_id} not found.")
|
|
67
|
+
|
|
68
|
+
old_version = inst["update_status"]
|
|
69
|
+
|
|
70
|
+
# Audit checkpoint before starting update
|
|
71
|
+
self.hub.record_audit_checkpoint(
|
|
72
|
+
decision_type="model_update",
|
|
73
|
+
target_id=installation_id,
|
|
74
|
+
payload={"from_version": old_version, "to_version": target_version},
|
|
75
|
+
outcome="approved",
|
|
76
|
+
is_critical=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
job = self.job_mgr.create_job(
|
|
80
|
+
operation_type="update",
|
|
81
|
+
target_id=installation_id,
|
|
82
|
+
approved_plan={"to_version": target_version},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
self.job_mgr.update_checkpoint(job.job_id, "staging_update", checkpoint_name="staged")
|
|
87
|
+
|
|
88
|
+
if simulate_failure_for_test:
|
|
89
|
+
# Trigger rollback
|
|
90
|
+
raise RuntimeError("Verification tests failed on staged update.")
|
|
91
|
+
|
|
92
|
+
# Activate
|
|
93
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
94
|
+
with conn:
|
|
95
|
+
conn.execute(
|
|
96
|
+
"UPDATE installations SET update_status = ?, updated_at = ? WHERE installation_id = ?",
|
|
97
|
+
(target_version, now, installation_id),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.job_mgr.complete_job(job.job_id)
|
|
101
|
+
self.hub.record_activity(
|
|
102
|
+
category="update",
|
|
103
|
+
title="Model Updated",
|
|
104
|
+
summary=f"Successfully updated {inst['name']} to {target_version}.",
|
|
105
|
+
)
|
|
106
|
+
return {"success": True, "updated": True, "version": target_version}
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
# Perform atomic rollback
|
|
110
|
+
self.job_mgr.fail_job(job.job_id, "UPDATE_VERIFICATION_FAILED", str(e))
|
|
111
|
+
self.hub.record_activity(
|
|
112
|
+
category="update",
|
|
113
|
+
title="Update Rolled Back",
|
|
114
|
+
summary=f"Update failed for {inst['name']}: restored previous version safely.",
|
|
115
|
+
outcome="rolled_back",
|
|
116
|
+
)
|
|
117
|
+
return {"success": False, "rolled_back": True, "error": str(e), "current_version": old_version}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Friendly vocabulary, standard message structures, and ASCII branding (Sections 4 & 7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
HOME_ASCII_BANNER = r"""
|
|
11
|
+
_ _ ____ _ _ _
|
|
12
|
+
| | ___ ___ __ _ | | _ \(_) | ___ | |_
|
|
13
|
+
| | / _ \ / __/ _` || | |_) | | |/ _ \| __|
|
|
14
|
+
| |__| (_) | (_| (_| || | __/| | | (_) | |_
|
|
15
|
+
|_____\___/ \___\__,_||_|_| |_|_|\___/ \__|
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_banner() -> None:
|
|
20
|
+
console.print(f"[bold cyan]{HOME_ASCII_BANNER}[/bold cyan]")
|
|
21
|
+
console.print("[dim]Local AI, made simple.[/dim]\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_message_box(
|
|
25
|
+
what_is_happening: str,
|
|
26
|
+
why_needed: str,
|
|
27
|
+
what_will_change: str,
|
|
28
|
+
what_remains_untouched: str,
|
|
29
|
+
next_action: str,
|
|
30
|
+
title: str = "LocalPilot",
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Renders the standard 5-part message structure from Section 4.3."""
|
|
33
|
+
body = (
|
|
34
|
+
f"[bold]{what_is_happening}[/bold]\n\n"
|
|
35
|
+
f"[dim]Why it is needed:[/dim] {why_needed}\n"
|
|
36
|
+
f"[dim]What will change:[/dim] {what_will_change}\n"
|
|
37
|
+
f"[dim]What remains untouched:[/dim] {what_remains_untouched}\n\n"
|
|
38
|
+
f"[cyan]Next step:[/cyan] {next_action}"
|
|
39
|
+
)
|
|
40
|
+
console.print(Panel(body, title=title, border_style="cyan"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_error_box(
|
|
44
|
+
message: str,
|
|
45
|
+
details: str = "",
|
|
46
|
+
next_action: str = "Try again or find a smaller model.",
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Renders calm, helpful error panel from Section 4.4."""
|
|
49
|
+
body = (
|
|
50
|
+
f"[bold red]{message}[/bold red]\n\n"
|
|
51
|
+
"Your downloaded files and existing configuration are safe.\n\n"
|
|
52
|
+
f"[cyan]Suggested action:[/cyan] {next_action}"
|
|
53
|
+
)
|
|
54
|
+
if details:
|
|
55
|
+
body += f"\n\n[dim]Details:[/dim] {details}"
|
|
56
|
+
console.print(Panel(body, title="Attention", border_style="red"))
|