loki-mode 5.53.0 → 5.55.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +939 -4
- package/autonomy/migration-agents.sh +500 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/activity_logger.py +231 -0
- package/dashboard/failure_extractor.py +228 -0
- package/dashboard/migration_engine.py +886 -0
- package/dashboard/prompt_optimizer.py +281 -0
- package/dashboard/rigour_integration.py +331 -0
- package/dashboard/server.py +428 -0
- package/dashboard/static/index.html +1667 -350
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
package/dashboard/server.py
CHANGED
|
@@ -10,6 +10,7 @@ import logging
|
|
|
10
10
|
import os
|
|
11
11
|
import time
|
|
12
12
|
from collections import defaultdict
|
|
13
|
+
from dataclasses import asdict
|
|
13
14
|
from contextlib import asynccontextmanager
|
|
14
15
|
from datetime import datetime, timedelta, timezone
|
|
15
16
|
from pathlib import Path as _Path
|
|
@@ -49,6 +50,7 @@ from . import audit
|
|
|
49
50
|
from . import app_secrets as secrets_mod
|
|
50
51
|
from . import telemetry as _telemetry
|
|
51
52
|
from .control import atomic_write_json
|
|
53
|
+
from .activity_logger import get_activity_logger
|
|
52
54
|
|
|
53
55
|
try:
|
|
54
56
|
from . import __version__ as _version
|
|
@@ -3843,6 +3845,72 @@ async def get_playwright_screenshot():
|
|
|
3843
3845
|
return FileResponse(str(screenshots[0]), media_type="image/png")
|
|
3844
3846
|
|
|
3845
3847
|
|
|
3848
|
+
# =============================================================================
|
|
3849
|
+
# Failure Analysis & Prompt Optimization (v5.54.0)
|
|
3850
|
+
# =============================================================================
|
|
3851
|
+
|
|
3852
|
+
_failure_extractor = None
|
|
3853
|
+
_prompt_optimizer = None
|
|
3854
|
+
|
|
3855
|
+
|
|
3856
|
+
def _get_failure_extractor():
|
|
3857
|
+
"""Lazy-initialise the shared FailureExtractor instance."""
|
|
3858
|
+
global _failure_extractor
|
|
3859
|
+
if _failure_extractor is None:
|
|
3860
|
+
from .failure_extractor import FailureExtractor
|
|
3861
|
+
_failure_extractor = FailureExtractor()
|
|
3862
|
+
return _failure_extractor
|
|
3863
|
+
|
|
3864
|
+
|
|
3865
|
+
def _get_prompt_optimizer():
|
|
3866
|
+
"""Lazy-initialise the shared PromptOptimizer instance."""
|
|
3867
|
+
global _prompt_optimizer
|
|
3868
|
+
if _prompt_optimizer is None:
|
|
3869
|
+
from .prompt_optimizer import PromptOptimizer
|
|
3870
|
+
_prompt_optimizer = PromptOptimizer()
|
|
3871
|
+
return _prompt_optimizer
|
|
3872
|
+
|
|
3873
|
+
|
|
3874
|
+
@app.get("/api/failures")
|
|
3875
|
+
def get_failures(sessions: int = 10):
|
|
3876
|
+
"""Get failure patterns from recent sessions."""
|
|
3877
|
+
if sessions < 1 or sessions > 1000:
|
|
3878
|
+
raise HTTPException(status_code=400, detail="sessions must be between 1 and 1000")
|
|
3879
|
+
if not _read_limiter.check("failures"):
|
|
3880
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3881
|
+
try:
|
|
3882
|
+
return _get_failure_extractor().extract(sessions=sessions)
|
|
3883
|
+
except Exception as exc:
|
|
3884
|
+
logger.error("Failure extraction error: %s", exc)
|
|
3885
|
+
raise HTTPException(status_code=500, detail="Failed to extract failure patterns")
|
|
3886
|
+
|
|
3887
|
+
|
|
3888
|
+
@app.get("/api/prompt-versions")
|
|
3889
|
+
def get_prompt_versions():
|
|
3890
|
+
"""Get current prompt optimization status."""
|
|
3891
|
+
if not _read_limiter.check("prompt_versions"):
|
|
3892
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3893
|
+
try:
|
|
3894
|
+
return _get_prompt_optimizer().get_current_version()
|
|
3895
|
+
except Exception as exc:
|
|
3896
|
+
logger.error("Prompt version read error: %s", exc)
|
|
3897
|
+
raise HTTPException(status_code=500, detail="Failed to read prompt versions")
|
|
3898
|
+
|
|
3899
|
+
|
|
3900
|
+
@app.post("/api/prompt-optimize", dependencies=[Depends(auth.require_scope("control"))])
|
|
3901
|
+
def optimize_prompts(sessions: int = 10, dry_run: bool = True):
|
|
3902
|
+
"""Run prompt optimization from failure analysis."""
|
|
3903
|
+
if sessions < 1 or sessions > 1000:
|
|
3904
|
+
raise HTTPException(status_code=400, detail="sessions must be between 1 and 1000")
|
|
3905
|
+
if not _control_limiter.check("prompt_optimize"):
|
|
3906
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3907
|
+
try:
|
|
3908
|
+
return _get_prompt_optimizer().optimize(sessions=sessions, dry_run=dry_run)
|
|
3909
|
+
except Exception as exc:
|
|
3910
|
+
logger.error("Prompt optimization error: %s", exc)
|
|
3911
|
+
raise HTTPException(status_code=500, detail="Failed to run prompt optimization")
|
|
3912
|
+
|
|
3913
|
+
|
|
3846
3914
|
# =============================================================================
|
|
3847
3915
|
# Static File Serving (Production/Docker)
|
|
3848
3916
|
# =============================================================================
|
|
@@ -3888,6 +3956,65 @@ if STATIC_DIR:
|
|
|
3888
3956
|
if os.path.isdir(ASSETS_DIR):
|
|
3889
3957
|
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
|
|
3890
3958
|
|
|
3959
|
+
# ---------------------------------------------------------------------------
|
|
3960
|
+
# Activity Logger & Session Diff
|
|
3961
|
+
# ---------------------------------------------------------------------------
|
|
3962
|
+
|
|
3963
|
+
@app.get("/api/activity")
|
|
3964
|
+
def get_activity(since: Optional[str] = None, limit: int = Query(default=100, ge=1, le=1000)):
|
|
3965
|
+
"""Get activity log entries, optionally filtered by timestamp."""
|
|
3966
|
+
if not _read_limiter.check("activity"):
|
|
3967
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3968
|
+
try:
|
|
3969
|
+
activity_logger = get_activity_logger()
|
|
3970
|
+
if since:
|
|
3971
|
+
entries = activity_logger.query_since(since)
|
|
3972
|
+
else:
|
|
3973
|
+
# Return all entries from the current log file, up to limit
|
|
3974
|
+
entries = activity_logger.query_since("1970-01-01T00:00:00+00:00")
|
|
3975
|
+
return entries[-limit:]
|
|
3976
|
+
except Exception as exc:
|
|
3977
|
+
logger.error("Activity read error: %s", exc)
|
|
3978
|
+
raise HTTPException(status_code=500, detail="Failed to read activity log")
|
|
3979
|
+
|
|
3980
|
+
|
|
3981
|
+
@app.get("/api/session-diff")
|
|
3982
|
+
def get_session_diff(since: Optional[str] = None):
|
|
3983
|
+
"""Get structured session diff since timestamp. Defaults to last 24h."""
|
|
3984
|
+
if not _read_limiter.check("session-diff"):
|
|
3985
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3986
|
+
try:
|
|
3987
|
+
activity_logger = get_activity_logger()
|
|
3988
|
+
return activity_logger.get_session_diff(since_timestamp=since)
|
|
3989
|
+
except Exception as exc:
|
|
3990
|
+
logger.error("Session diff error: %s", exc)
|
|
3991
|
+
raise HTTPException(status_code=500, detail="Failed to compute session diff")
|
|
3992
|
+
|
|
3993
|
+
|
|
3994
|
+
@app.post("/api/activity", dependencies=[Depends(auth.require_scope("control"))])
|
|
3995
|
+
async def log_activity(entry: dict):
|
|
3996
|
+
"""Log an activity entry (for internal use by agents)."""
|
|
3997
|
+
if not _control_limiter.check("activity-write"):
|
|
3998
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3999
|
+
|
|
4000
|
+
# Validate required fields
|
|
4001
|
+
required = {"entity_type", "entity_id", "action"}
|
|
4002
|
+
missing = required - set(entry.keys())
|
|
4003
|
+
if missing:
|
|
4004
|
+
raise HTTPException(status_code=400, detail=f"Missing required fields: {', '.join(sorted(missing))}")
|
|
4005
|
+
|
|
4006
|
+
activity_logger = get_activity_logger()
|
|
4007
|
+
result = activity_logger.log(
|
|
4008
|
+
entity_type=entry["entity_type"],
|
|
4009
|
+
entity_id=entry["entity_id"],
|
|
4010
|
+
action=entry["action"],
|
|
4011
|
+
old_value=entry.get("old_value"),
|
|
4012
|
+
new_value=entry.get("new_value"),
|
|
4013
|
+
session_id=entry.get("session_id"),
|
|
4014
|
+
)
|
|
4015
|
+
return result
|
|
4016
|
+
|
|
4017
|
+
|
|
3891
4018
|
# Serve favicon.svg from static directory
|
|
3892
4019
|
@app.get("/favicon.svg", include_in_schema=False)
|
|
3893
4020
|
async def serve_favicon():
|
|
@@ -3940,6 +4067,307 @@ async def serve_index():
|
|
|
3940
4067
|
)
|
|
3941
4068
|
|
|
3942
4069
|
|
|
4070
|
+
# =============================================================================
|
|
4071
|
+
# Rigour Quality Gate Endpoints
|
|
4072
|
+
# =============================================================================
|
|
4073
|
+
|
|
4074
|
+
_rigour: Optional["RigourIntegration"] = None
|
|
4075
|
+
|
|
4076
|
+
|
|
4077
|
+
def _get_rigour() -> "RigourIntegration":
|
|
4078
|
+
"""Lazy-initialise the shared RigourIntegration instance."""
|
|
4079
|
+
global _rigour
|
|
4080
|
+
if _rigour is None:
|
|
4081
|
+
from .rigour_integration import RigourIntegration
|
|
4082
|
+
|
|
4083
|
+
data_dir = os.environ.get("LOKI_DATA_DIR", os.path.expanduser("~/.loki"))
|
|
4084
|
+
_rigour = RigourIntegration(data_dir=data_dir)
|
|
4085
|
+
return _rigour
|
|
4086
|
+
|
|
4087
|
+
|
|
4088
|
+
@app.get("/api/quality-score")
|
|
4089
|
+
def get_quality_score():
|
|
4090
|
+
"""Get current quality score from the most recent Rigour scan."""
|
|
4091
|
+
if not _read_limiter.check("quality-score"):
|
|
4092
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4093
|
+
try:
|
|
4094
|
+
rigour = _get_rigour()
|
|
4095
|
+
return rigour.get_score()
|
|
4096
|
+
except Exception as exc:
|
|
4097
|
+
logger.error("Quality score read error: %s", exc)
|
|
4098
|
+
raise HTTPException(status_code=500, detail="Failed to read quality score")
|
|
4099
|
+
|
|
4100
|
+
|
|
4101
|
+
@app.get("/api/quality-score/history")
|
|
4102
|
+
def get_quality_score_history(limit: int = Query(50, ge=1, le=500)):
|
|
4103
|
+
"""Get quality score trend over time."""
|
|
4104
|
+
if not _read_limiter.check("quality-history"):
|
|
4105
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4106
|
+
try:
|
|
4107
|
+
rigour = _get_rigour()
|
|
4108
|
+
return rigour.get_score_history(limit=limit)
|
|
4109
|
+
except Exception as exc:
|
|
4110
|
+
logger.error("Quality history read error: %s", exc)
|
|
4111
|
+
raise HTTPException(status_code=500, detail="Failed to read quality history")
|
|
4112
|
+
|
|
4113
|
+
|
|
4114
|
+
@app.post("/api/quality-scan", dependencies=[Depends(auth.require_scope("control"))])
|
|
4115
|
+
async def run_quality_scan(preset: str = Query("default")):
|
|
4116
|
+
"""Run a Rigour quality scan.
|
|
4117
|
+
|
|
4118
|
+
Preset must be one of: default, healthcare, fintech, government.
|
|
4119
|
+
"""
|
|
4120
|
+
_valid_presets = ("default", "healthcare", "fintech", "government")
|
|
4121
|
+
if preset not in _valid_presets:
|
|
4122
|
+
raise HTTPException(
|
|
4123
|
+
status_code=400,
|
|
4124
|
+
detail=f"Invalid preset: {preset}. Must be one of {_valid_presets}",
|
|
4125
|
+
)
|
|
4126
|
+
if not _control_limiter.check("quality-scan"):
|
|
4127
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4128
|
+
rigour = _get_rigour()
|
|
4129
|
+
loop = asyncio.get_running_loop()
|
|
4130
|
+
result = await loop.run_in_executor(None, rigour.scan, ".", preset)
|
|
4131
|
+
return result
|
|
4132
|
+
|
|
4133
|
+
|
|
4134
|
+
@app.get("/api/quality-report")
|
|
4135
|
+
def get_quality_report(fmt: str = Query("json", alias="format")):
|
|
4136
|
+
"""Get an exportable quality audit report."""
|
|
4137
|
+
if not _read_limiter.check("quality-report"):
|
|
4138
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4139
|
+
try:
|
|
4140
|
+
rigour = _get_rigour()
|
|
4141
|
+
report_str = rigour.export_report(fmt=fmt)
|
|
4142
|
+
if fmt == "json":
|
|
4143
|
+
try:
|
|
4144
|
+
return json.loads(report_str)
|
|
4145
|
+
except json.JSONDecodeError:
|
|
4146
|
+
return PlainTextResponse(report_str)
|
|
4147
|
+
return PlainTextResponse(report_str)
|
|
4148
|
+
except Exception as exc:
|
|
4149
|
+
logger.error("Quality report error: %s", exc)
|
|
4150
|
+
raise HTTPException(status_code=500, detail="Failed to generate quality report")
|
|
4151
|
+
|
|
4152
|
+
|
|
4153
|
+
# =============================================================================
|
|
4154
|
+
# Migration Engine Endpoints
|
|
4155
|
+
# =============================================================================
|
|
4156
|
+
|
|
4157
|
+
_migration_imports = None
|
|
4158
|
+
|
|
4159
|
+
_MIGRATION_ID_RE = re.compile(r'^mig_\d{8}_\d{6}_[a-zA-Z0-9_-]+$')
|
|
4160
|
+
|
|
4161
|
+
|
|
4162
|
+
def _validate_migration_id(migration_id: str):
|
|
4163
|
+
"""Validate migration_id format to prevent path traversal."""
|
|
4164
|
+
if not _MIGRATION_ID_RE.match(migration_id):
|
|
4165
|
+
raise HTTPException(status_code=400, detail="Invalid migration ID format")
|
|
4166
|
+
|
|
4167
|
+
|
|
4168
|
+
def _get_migration_imports():
|
|
4169
|
+
"""Lazy-import migration_engine module. Returns (MigrationPipeline, list_migrations) or False."""
|
|
4170
|
+
global _migration_imports
|
|
4171
|
+
if _migration_imports is None:
|
|
4172
|
+
try:
|
|
4173
|
+
from dashboard.migration_engine import MigrationPipeline, list_migrations
|
|
4174
|
+
_migration_imports = (MigrationPipeline, list_migrations)
|
|
4175
|
+
except ImportError:
|
|
4176
|
+
_migration_imports = False
|
|
4177
|
+
return _migration_imports
|
|
4178
|
+
|
|
4179
|
+
|
|
4180
|
+
@app.get("/api/migration/list", dependencies=[Depends(auth.require_scope("read"))])
|
|
4181
|
+
def list_migrations_endpoint():
|
|
4182
|
+
"""List all migrations."""
|
|
4183
|
+
if not _read_limiter.check("migration-list"):
|
|
4184
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4185
|
+
imports = _get_migration_imports()
|
|
4186
|
+
if not imports:
|
|
4187
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4188
|
+
MigrationPipeline, list_migrations = imports
|
|
4189
|
+
try:
|
|
4190
|
+
return list_migrations()
|
|
4191
|
+
except Exception as exc:
|
|
4192
|
+
logger.error("Migration list error: %s", exc)
|
|
4193
|
+
raise HTTPException(status_code=500, detail="Failed to list migrations")
|
|
4194
|
+
|
|
4195
|
+
|
|
4196
|
+
@app.post("/api/migration/start", dependencies=[Depends(auth.require_scope("control"))])
|
|
4197
|
+
def start_migration(request_body: dict):
|
|
4198
|
+
"""Start a new migration.
|
|
4199
|
+
|
|
4200
|
+
Body: {"codebase_path": str, "target": str, "options": dict}
|
|
4201
|
+
"""
|
|
4202
|
+
if not _control_limiter.check("migration-start"):
|
|
4203
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4204
|
+
imports = _get_migration_imports()
|
|
4205
|
+
if not imports:
|
|
4206
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4207
|
+
MigrationPipeline, list_migrations = imports
|
|
4208
|
+
codebase_path = request_body.get("codebase_path")
|
|
4209
|
+
target = request_body.get("target")
|
|
4210
|
+
options = request_body.get("options", {})
|
|
4211
|
+
if not codebase_path or not target:
|
|
4212
|
+
raise HTTPException(status_code=400, detail="codebase_path and target are required")
|
|
4213
|
+
# Check raw input for traversal BEFORE resolving
|
|
4214
|
+
if '..' in codebase_path:
|
|
4215
|
+
raise HTTPException(status_code=400, detail="Path traversal not allowed")
|
|
4216
|
+
codebase_path = os.path.realpath(codebase_path)
|
|
4217
|
+
if not os.path.isdir(codebase_path):
|
|
4218
|
+
raise HTTPException(status_code=400, detail=f"codebase_path does not exist: {codebase_path}")
|
|
4219
|
+
try:
|
|
4220
|
+
pipeline = MigrationPipeline(codebase_path=codebase_path, target=target, options=options)
|
|
4221
|
+
manifest = pipeline.create_manifest()
|
|
4222
|
+
return asdict(manifest)
|
|
4223
|
+
except Exception as exc:
|
|
4224
|
+
logger.error("Migration start error: %s", exc)
|
|
4225
|
+
raise HTTPException(status_code=500, detail="Failed to start migration")
|
|
4226
|
+
|
|
4227
|
+
|
|
4228
|
+
@app.get("/api/migration/{migration_id}/status", dependencies=[Depends(auth.require_scope("read"))])
|
|
4229
|
+
def get_migration_status(migration_id: str):
|
|
4230
|
+
"""Get migration status and progress."""
|
|
4231
|
+
_validate_migration_id(migration_id)
|
|
4232
|
+
if not _read_limiter.check("migration-status"):
|
|
4233
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4234
|
+
imports = _get_migration_imports()
|
|
4235
|
+
if not imports:
|
|
4236
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4237
|
+
MigrationPipeline, list_migrations = imports
|
|
4238
|
+
try:
|
|
4239
|
+
pipeline = MigrationPipeline.load(migration_id)
|
|
4240
|
+
return pipeline.get_progress()
|
|
4241
|
+
except FileNotFoundError:
|
|
4242
|
+
raise HTTPException(status_code=404, detail=f"Migration not found: {migration_id}")
|
|
4243
|
+
except Exception as exc:
|
|
4244
|
+
logger.error("Migration status error: %s", exc)
|
|
4245
|
+
raise HTTPException(status_code=500, detail="Failed to get migration status")
|
|
4246
|
+
|
|
4247
|
+
|
|
4248
|
+
@app.get("/api/migration/{migration_id}/plan", dependencies=[Depends(auth.require_scope("read"))])
|
|
4249
|
+
def get_migration_plan(migration_id: str):
|
|
4250
|
+
"""Get migration plan content."""
|
|
4251
|
+
_validate_migration_id(migration_id)
|
|
4252
|
+
if not _read_limiter.check("migration-plan"):
|
|
4253
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4254
|
+
imports = _get_migration_imports()
|
|
4255
|
+
if not imports:
|
|
4256
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4257
|
+
MigrationPipeline, list_migrations = imports
|
|
4258
|
+
try:
|
|
4259
|
+
pipeline = MigrationPipeline.load(migration_id)
|
|
4260
|
+
plan = pipeline.load_plan()
|
|
4261
|
+
return asdict(plan) if hasattr(plan, '__dataclass_fields__') else plan
|
|
4262
|
+
except FileNotFoundError:
|
|
4263
|
+
raise HTTPException(status_code=404, detail=f"Migration plan not found for: {migration_id}")
|
|
4264
|
+
except Exception as exc:
|
|
4265
|
+
logger.error("Migration plan error: %s", exc)
|
|
4266
|
+
raise HTTPException(status_code=500, detail="Failed to get migration plan")
|
|
4267
|
+
|
|
4268
|
+
|
|
4269
|
+
@app.get("/api/migration/{migration_id}/features", dependencies=[Depends(auth.require_scope("read"))])
|
|
4270
|
+
def get_migration_features(migration_id: str):
|
|
4271
|
+
"""Get migration feature list."""
|
|
4272
|
+
_validate_migration_id(migration_id)
|
|
4273
|
+
if not _read_limiter.check("migration-features"):
|
|
4274
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4275
|
+
imports = _get_migration_imports()
|
|
4276
|
+
if not imports:
|
|
4277
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4278
|
+
MigrationPipeline, list_migrations = imports
|
|
4279
|
+
try:
|
|
4280
|
+
pipeline = MigrationPipeline.load(migration_id)
|
|
4281
|
+
features = pipeline.load_features()
|
|
4282
|
+
return [asdict(f) for f in features]
|
|
4283
|
+
except FileNotFoundError:
|
|
4284
|
+
raise HTTPException(status_code=404, detail=f"Migration features not found for: {migration_id}")
|
|
4285
|
+
except Exception as exc:
|
|
4286
|
+
logger.error("Migration features error: %s", exc)
|
|
4287
|
+
raise HTTPException(status_code=500, detail="Failed to get migration features")
|
|
4288
|
+
|
|
4289
|
+
|
|
4290
|
+
@app.get("/api/migration/{migration_id}/seams", dependencies=[Depends(auth.require_scope("read"))])
|
|
4291
|
+
def get_migration_seams(migration_id: str):
|
|
4292
|
+
"""Get detected seams for migration."""
|
|
4293
|
+
_validate_migration_id(migration_id)
|
|
4294
|
+
if not _read_limiter.check("migration-seams"):
|
|
4295
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4296
|
+
imports = _get_migration_imports()
|
|
4297
|
+
if not imports:
|
|
4298
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4299
|
+
MigrationPipeline, list_migrations = imports
|
|
4300
|
+
try:
|
|
4301
|
+
pipeline = MigrationPipeline.load(migration_id)
|
|
4302
|
+
seams = pipeline.load_seams()
|
|
4303
|
+
return [asdict(s) for s in seams]
|
|
4304
|
+
except FileNotFoundError:
|
|
4305
|
+
raise HTTPException(status_code=404, detail=f"Migration seams not found for: {migration_id}")
|
|
4306
|
+
except Exception as exc:
|
|
4307
|
+
logger.error("Migration seams error: %s", exc)
|
|
4308
|
+
raise HTTPException(status_code=500, detail="Failed to get migration seams")
|
|
4309
|
+
|
|
4310
|
+
|
|
4311
|
+
@app.post("/api/migration/{migration_id}/advance", dependencies=[Depends(auth.require_scope("control"))])
|
|
4312
|
+
def advance_migration(migration_id: str, request_body: dict):
|
|
4313
|
+
"""Advance migration to the next phase.
|
|
4314
|
+
|
|
4315
|
+
Body: {"from_phase": str, "to_phase": str}
|
|
4316
|
+
"""
|
|
4317
|
+
_validate_migration_id(migration_id)
|
|
4318
|
+
if not _control_limiter.check("migration-advance"):
|
|
4319
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4320
|
+
imports = _get_migration_imports()
|
|
4321
|
+
if not imports:
|
|
4322
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4323
|
+
MigrationPipeline, list_migrations = imports
|
|
4324
|
+
from_phase = request_body.get("from_phase")
|
|
4325
|
+
to_phase = request_body.get("to_phase")
|
|
4326
|
+
if not from_phase or not to_phase:
|
|
4327
|
+
raise HTTPException(status_code=400, detail="from_phase and to_phase are required")
|
|
4328
|
+
# Load pipeline and check phase gate before the try/except to let
|
|
4329
|
+
# HTTPException and FileNotFoundError propagate naturally.
|
|
4330
|
+
try:
|
|
4331
|
+
pipeline = MigrationPipeline.load(migration_id)
|
|
4332
|
+
except FileNotFoundError:
|
|
4333
|
+
raise HTTPException(status_code=404, detail=f"Migration not found: {migration_id}")
|
|
4334
|
+
passed, reason = pipeline.check_phase_gate(from_phase, to_phase)
|
|
4335
|
+
if not passed:
|
|
4336
|
+
raise HTTPException(status_code=409, detail=reason)
|
|
4337
|
+
try:
|
|
4338
|
+
result = pipeline.advance_phase(from_phase)
|
|
4339
|
+
return asdict(result) if hasattr(result, '__dataclass_fields__') else result
|
|
4340
|
+
except Exception as exc:
|
|
4341
|
+
logger.error("Migration advance error: %s", exc)
|
|
4342
|
+
raise HTTPException(status_code=500, detail="Failed to advance migration")
|
|
4343
|
+
|
|
4344
|
+
|
|
4345
|
+
@app.post("/api/migration/{migration_id}/start-phase", dependencies=[Depends(auth.require_scope("control"))])
|
|
4346
|
+
def start_migration_phase(migration_id: str, request_body: dict):
|
|
4347
|
+
"""Start a migration phase (transition from pending to in_progress)."""
|
|
4348
|
+
_validate_migration_id(migration_id)
|
|
4349
|
+
if not _control_limiter.check("migration-start-phase"):
|
|
4350
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4351
|
+
imports = _get_migration_imports()
|
|
4352
|
+
if not imports:
|
|
4353
|
+
raise HTTPException(status_code=503, detail="Migration engine not available")
|
|
4354
|
+
MigrationPipeline, list_migrations = imports
|
|
4355
|
+
phase = request_body.get("phase")
|
|
4356
|
+
if not phase:
|
|
4357
|
+
raise HTTPException(status_code=400, detail="phase is required")
|
|
4358
|
+
try:
|
|
4359
|
+
pipeline = MigrationPipeline.load(migration_id)
|
|
4360
|
+
pipeline.start_phase(phase)
|
|
4361
|
+
return {"status": "started", "phase": phase}
|
|
4362
|
+
except FileNotFoundError:
|
|
4363
|
+
raise HTTPException(status_code=404, detail=f"Migration not found: {migration_id}")
|
|
4364
|
+
except (ValueError, RuntimeError) as exc:
|
|
4365
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
4366
|
+
except Exception as exc:
|
|
4367
|
+
logger.error("Start phase error: %s", exc)
|
|
4368
|
+
raise HTTPException(status_code=500, detail="Failed to start phase")
|
|
4369
|
+
|
|
4370
|
+
|
|
3943
4371
|
def run_server(host: str = None, port: int = None) -> None:
|
|
3944
4372
|
"""Run the dashboard server."""
|
|
3945
4373
|
import uvicorn
|