loki-mode 7.129.5 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -5
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +282 -95
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +151 -102
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +124 -0
- package/autonomy/loki +189 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4417 -676
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- package/skills/quality-gates.md +22 -0
package/dashboard/server.py
CHANGED
|
@@ -10,7 +10,9 @@ import asyncio
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
+
import platform
|
|
13
14
|
import subprocess
|
|
15
|
+
import sys
|
|
14
16
|
import threading
|
|
15
17
|
import time
|
|
16
18
|
from collections import defaultdict
|
|
@@ -31,7 +33,7 @@ from fastapi import (
|
|
|
31
33
|
WebSocketDisconnect,
|
|
32
34
|
)
|
|
33
35
|
from fastapi.middleware.cors import CORSMiddleware
|
|
34
|
-
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
36
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, Response
|
|
35
37
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
36
38
|
from sqlalchemy import select, update, delete
|
|
37
39
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -54,6 +56,7 @@ from . import auth
|
|
|
54
56
|
from . import audit
|
|
55
57
|
from . import app_secrets as secrets_mod
|
|
56
58
|
from . import telemetry as _telemetry
|
|
59
|
+
from . import build_supervisor as _build_execution
|
|
57
60
|
from .control import atomic_write_json, find_skill_dir, is_process_running
|
|
58
61
|
from .activity_logger import get_activity_logger
|
|
59
62
|
from .api_v2 import (
|
|
@@ -404,6 +407,29 @@ class SessionInfo(BaseModel):
|
|
|
404
407
|
log_file: str = ""
|
|
405
408
|
|
|
406
409
|
|
|
410
|
+
def _npm_execution_target() -> tuple[str, str, str]:
|
|
411
|
+
"""Return the npm-native target used by provider and verification commands."""
|
|
412
|
+
os_name = {
|
|
413
|
+
"darwin": "darwin",
|
|
414
|
+
"linux": "linux",
|
|
415
|
+
"win32": "win32",
|
|
416
|
+
}.get(sys.platform, "")
|
|
417
|
+
cpu = {
|
|
418
|
+
"aarch64": "arm64",
|
|
419
|
+
"arm64": "arm64",
|
|
420
|
+
"amd64": "x64",
|
|
421
|
+
"x86_64": "x64",
|
|
422
|
+
}.get(platform.machine().lower(), "")
|
|
423
|
+
libc = ""
|
|
424
|
+
if os_name == "linux":
|
|
425
|
+
detected_libc = platform.libc_ver()[0].lower()
|
|
426
|
+
libc = {"glibc": "glibc", "musl": "musl"}.get(detected_libc, "")
|
|
427
|
+
return os_name, cpu, libc
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
_EXECUTION_OS, _EXECUTION_CPU, _EXECUTION_LIBC = _npm_execution_target()
|
|
431
|
+
|
|
432
|
+
|
|
407
433
|
class StatusResponse(BaseModel):
|
|
408
434
|
"""Schema for system status response."""
|
|
409
435
|
status: str
|
|
@@ -413,6 +439,11 @@ class StatusResponse(BaseModel):
|
|
|
413
439
|
running_agents: int = 0
|
|
414
440
|
pending_tasks: int = 0
|
|
415
441
|
database_connected: bool = True
|
|
442
|
+
# Native dependency target for commands executed by this engine. Callers
|
|
443
|
+
# must treat blanks as unknown and skip cross-environment preparation.
|
|
444
|
+
execution_os: str = _EXECUTION_OS
|
|
445
|
+
execution_cpu: str = _EXECUTION_CPU
|
|
446
|
+
execution_libc: str = _EXECUTION_LIBC
|
|
416
447
|
# File-based session fields
|
|
417
448
|
phase: str = ""
|
|
418
449
|
iteration: int = 0
|
|
@@ -3059,7 +3090,8 @@ async def get_session_model():
|
|
|
3059
3090
|
default = _normalize_session_pin(os.environ.get("LOKI_SESSION_MODEL")) or "sonnet"
|
|
3060
3091
|
# Resolve on the route the runner will actually take: override-path clamp when
|
|
3061
3092
|
# an override file is present, session-pin tier route otherwise. This closes
|
|
3062
|
-
# the task-568 stock-path gap (a "sonnet" pin dispatches
|
|
3093
|
+
# the task-568 stock-path gap (a "sonnet" pin dispatches sonnet post-v7.104.0;
|
|
3094
|
+
# the gap it originally fixed was the pre-flip opus default).
|
|
3063
3095
|
if override is not None:
|
|
3064
3096
|
effective = _clamp_to_max_tier(override)
|
|
3065
3097
|
else:
|
|
@@ -3274,6 +3306,9 @@ class StartBuildRequest(BaseModel):
|
|
|
3274
3306
|
# The path is path-guarded against ALLOWED_WORKSPACE_ROOTS (see
|
|
3275
3307
|
# _validate_workspace) -- it is NOT a free-form filesystem write target.
|
|
3276
3308
|
workspace: Optional[str] = None
|
|
3309
|
+
# Stable SaaS build UUID. Required for workspace starts so a retried POST
|
|
3310
|
+
# returns the same durable execution instead of launching a second runner.
|
|
3311
|
+
request_id: Optional[str] = None
|
|
3277
3312
|
# Start-time execution model (haiku|sonnet|opus; fable NOT accepted). When a
|
|
3278
3313
|
# valid alias is supplied, the run is pinned to EXACTLY that model for every
|
|
3279
3314
|
# iteration via the LOKI_CLAUDE_MODEL_{PLANNING,DEVELOPMENT,FAST} env triple
|
|
@@ -3292,6 +3327,21 @@ class StartBuildRequest(BaseModel):
|
|
|
3292
3327
|
# execution stays on the chosen/default execution model. Absent/invalid -> no
|
|
3293
3328
|
# advisor pin (reviewers use the account default).
|
|
3294
3329
|
advisor_model: Optional[str] = None
|
|
3330
|
+
# Build profile (e.g. "simple-web"): exported as LOKI_BUILD_PROFILE into the
|
|
3331
|
+
# run env, where run.sh's loki_apply_build_profile helper (run.sh:562) maps it
|
|
3332
|
+
# to a set of LOKI_PHASE_* gate defaults (a simple landing page skips the
|
|
3333
|
+
# gates irrelevant to a static frontend while KEEPING E2E, code review, the
|
|
3334
|
+
# completion council, security, and accessibility). Absent -> unset -> the
|
|
3335
|
+
# full gate suite runs (byte-identical to before this field existed). Never
|
|
3336
|
+
# weakens the moat: the council + evidence/proof gate are not disable-able by
|
|
3337
|
+
# a profile.
|
|
3338
|
+
build_profile: Optional[str] = None
|
|
3339
|
+
# Optional runtime complexity tier. Unknown values are ignored so they can
|
|
3340
|
+
# never widen policy or become arbitrary environment input.
|
|
3341
|
+
complexity: Optional[str] = None
|
|
3342
|
+
# The provider's first-pass excellence prompt is default-on. A caller may
|
|
3343
|
+
# explicitly disable it for non-UI work such as CLI and library builds.
|
|
3344
|
+
first_pass_directive: Optional[bool] = None
|
|
3295
3345
|
|
|
3296
3346
|
def validate_provider(self) -> None:
|
|
3297
3347
|
"""Validate provider is from the supported list.
|
|
@@ -3478,6 +3528,274 @@ def _project_run_active(loki_dir: _Path) -> Optional[int]:
|
|
|
3478
3528
|
return None
|
|
3479
3529
|
|
|
3480
3530
|
|
|
3531
|
+
def _supervised_start_response(state: dict[str, Any], replay: bool = False) -> dict[str, Any]:
|
|
3532
|
+
"""Build the additive start response shared by new and replayed starts."""
|
|
3533
|
+
execution_id = str(state.get("execution_id") or "")
|
|
3534
|
+
provider = str(state.get("provider") or "")
|
|
3535
|
+
return {
|
|
3536
|
+
"success": True,
|
|
3537
|
+
"message": (
|
|
3538
|
+
"Existing build execution returned"
|
|
3539
|
+
if replay
|
|
3540
|
+
else f"Build accepted with provider {provider}"
|
|
3541
|
+
),
|
|
3542
|
+
"execution_id": execution_id,
|
|
3543
|
+
"state": str(state.get("state") or "accepted"),
|
|
3544
|
+
"status_url": f"/api/control/builds/{execution_id}",
|
|
3545
|
+
# Deprecated compatibility handle. Callers must use execution_id.
|
|
3546
|
+
"pid": int(state.get("supervisor_pid") or 0),
|
|
3547
|
+
"spec": str(state.get("spec") or ""),
|
|
3548
|
+
"provider": provider,
|
|
3549
|
+
"workspace": str(state.get("workspace") or ""),
|
|
3550
|
+
"run_id": str(state.get("run_id") or ""),
|
|
3551
|
+
"model": str(state.get("model") or ""),
|
|
3552
|
+
"advisor_model": str(state.get("advisor_model") or ""),
|
|
3553
|
+
"build_profile": str(state.get("build_profile") or ""),
|
|
3554
|
+
"complexity": str(state.get("complexity") or "auto"),
|
|
3555
|
+
"first_pass_directive": bool(state.get("first_pass_directive", True)),
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
|
|
3559
|
+
def _reap_supervisor_process(process: subprocess.Popen[Any]) -> None:
|
|
3560
|
+
"""Wait for a dashboard child so an exited supervisor cannot stay zombie."""
|
|
3561
|
+
try:
|
|
3562
|
+
process.wait()
|
|
3563
|
+
except Exception:
|
|
3564
|
+
logging.getLogger(__name__).debug(
|
|
3565
|
+
"Build supervisor reaper could not wait for child", exc_info=True
|
|
3566
|
+
)
|
|
3567
|
+
|
|
3568
|
+
|
|
3569
|
+
def _start_supervised_workspace_build(
|
|
3570
|
+
request: Request,
|
|
3571
|
+
body: StartBuildRequest,
|
|
3572
|
+
execution_id: str,
|
|
3573
|
+
workspace_dir: _Path,
|
|
3574
|
+
loki_dir: _Path,
|
|
3575
|
+
spec_content: bytes,
|
|
3576
|
+
run_sh: _Path,
|
|
3577
|
+
skill_dir: _Path,
|
|
3578
|
+
popen_env: dict[str, str],
|
|
3579
|
+
start_model: str,
|
|
3580
|
+
advisor_model: str,
|
|
3581
|
+
build_profile: Optional[str],
|
|
3582
|
+
complexity: str,
|
|
3583
|
+
first_pass_directive: bool,
|
|
3584
|
+
) -> JSONResponse:
|
|
3585
|
+
"""Start one durable workspace execution without the run.sh --bg wrapper."""
|
|
3586
|
+
spec_sha256 = _build_execution.sha256_bytes(spec_content)
|
|
3587
|
+
fingerprint_payload = json.dumps(
|
|
3588
|
+
{
|
|
3589
|
+
"workspace": str(workspace_dir),
|
|
3590
|
+
"spec_sha256": spec_sha256,
|
|
3591
|
+
"provider": body.provider,
|
|
3592
|
+
"parallel": body.parallel,
|
|
3593
|
+
"model": start_model,
|
|
3594
|
+
"advisor_model": advisor_model,
|
|
3595
|
+
"build_profile": build_profile or "",
|
|
3596
|
+
"complexity": complexity,
|
|
3597
|
+
"first_pass_directive": first_pass_directive,
|
|
3598
|
+
},
|
|
3599
|
+
sort_keys=True,
|
|
3600
|
+
separators=(",", ":"),
|
|
3601
|
+
).encode("utf-8")
|
|
3602
|
+
request_fingerprint = _build_execution.sha256_bytes(fingerprint_payload)
|
|
3603
|
+
|
|
3604
|
+
created = False
|
|
3605
|
+
with _build_execution.execution_lock(execution_id):
|
|
3606
|
+
existing = _build_execution.read_state_unlocked(execution_id)
|
|
3607
|
+
if existing is not None:
|
|
3608
|
+
if existing.get("request_fingerprint") != request_fingerprint:
|
|
3609
|
+
raise HTTPException(
|
|
3610
|
+
status_code=409,
|
|
3611
|
+
detail="request_id is already bound to different build inputs",
|
|
3612
|
+
)
|
|
3613
|
+
return JSONResponse(
|
|
3614
|
+
status_code=200,
|
|
3615
|
+
content=_supervised_start_response(existing, replay=True),
|
|
3616
|
+
)
|
|
3617
|
+
if _build_execution.state_path(execution_id).exists():
|
|
3618
|
+
raise HTTPException(
|
|
3619
|
+
status_code=409,
|
|
3620
|
+
detail="Execution state exists but cannot be read safely",
|
|
3621
|
+
)
|
|
3622
|
+
|
|
3623
|
+
active_pid = _project_run_active(loki_dir)
|
|
3624
|
+
if active_pid is not None:
|
|
3625
|
+
detail = "A build is already running in this project"
|
|
3626
|
+
if active_pid > 0:
|
|
3627
|
+
detail += f" (PID {active_pid})"
|
|
3628
|
+
raise HTTPException(status_code=409, detail=detail)
|
|
3629
|
+
|
|
3630
|
+
try:
|
|
3631
|
+
spec_file = _build_execution.write_spec_snapshot_unlocked(
|
|
3632
|
+
execution_id, spec_content
|
|
3633
|
+
)
|
|
3634
|
+
except OSError as exc:
|
|
3635
|
+
raise HTTPException(
|
|
3636
|
+
status_code=500, detail=f"Could not persist spec snapshot: {exc}"
|
|
3637
|
+
) from exc
|
|
3638
|
+
|
|
3639
|
+
if os.environ.get("LOKI_HOST_SEATBELT", "").strip().lower() in {
|
|
3640
|
+
"1",
|
|
3641
|
+
"true",
|
|
3642
|
+
"yes",
|
|
3643
|
+
"on",
|
|
3644
|
+
}:
|
|
3645
|
+
try:
|
|
3646
|
+
auth_status = _build_execution.confined_provider_auth_status(
|
|
3647
|
+
body.provider,
|
|
3648
|
+
workspace_dir,
|
|
3649
|
+
run_sh,
|
|
3650
|
+
spec_file,
|
|
3651
|
+
)
|
|
3652
|
+
except (OSError, RuntimeError, ValueError):
|
|
3653
|
+
auth_status = {
|
|
3654
|
+
"available": False,
|
|
3655
|
+
"classification": "provider_auth_probe_failed",
|
|
3656
|
+
"action": (
|
|
3657
|
+
"Check the controlled-local provider auth boundary, "
|
|
3658
|
+
"then retry."
|
|
3659
|
+
),
|
|
3660
|
+
}
|
|
3661
|
+
if auth_status.get("available") is not True:
|
|
3662
|
+
raise HTTPException(
|
|
3663
|
+
status_code=503,
|
|
3664
|
+
detail={
|
|
3665
|
+
"code": "provider_auth_unavailable_in_confinement",
|
|
3666
|
+
"provider": body.provider,
|
|
3667
|
+
"reason": str(
|
|
3668
|
+
auth_status.get("classification") or "unknown"
|
|
3669
|
+
),
|
|
3670
|
+
"action": str(
|
|
3671
|
+
auth_status.get("action")
|
|
3672
|
+
or "Authenticate the provider, then retry."
|
|
3673
|
+
),
|
|
3674
|
+
},
|
|
3675
|
+
)
|
|
3676
|
+
|
|
3677
|
+
accepted_at = _build_execution.utc_now()
|
|
3678
|
+
state: dict[str, Any] = {
|
|
3679
|
+
"schema_version": _build_execution.SCHEMA_VERSION,
|
|
3680
|
+
"execution_id": execution_id,
|
|
3681
|
+
"request_fingerprint": request_fingerprint,
|
|
3682
|
+
"workspace": str(workspace_dir),
|
|
3683
|
+
"spec": str(spec_file),
|
|
3684
|
+
"spec_sha256": spec_sha256,
|
|
3685
|
+
"provider": body.provider,
|
|
3686
|
+
"parallel": body.parallel,
|
|
3687
|
+
"model": start_model,
|
|
3688
|
+
"advisor_model": advisor_model,
|
|
3689
|
+
"build_profile": build_profile or "",
|
|
3690
|
+
"complexity": complexity,
|
|
3691
|
+
"first_pass_directive": first_pass_directive,
|
|
3692
|
+
"state": "accepted",
|
|
3693
|
+
"accepted_at": accepted_at,
|
|
3694
|
+
"started_at": None,
|
|
3695
|
+
"exited_at": None,
|
|
3696
|
+
"finished_at": None,
|
|
3697
|
+
"supervisor_pid": None,
|
|
3698
|
+
"supervisor_birth_token": None,
|
|
3699
|
+
"runner_pid": None,
|
|
3700
|
+
"runner_pgid": None,
|
|
3701
|
+
"runner_sid": None,
|
|
3702
|
+
"runner_birth_token": None,
|
|
3703
|
+
"returncode": None,
|
|
3704
|
+
"exit_code": None,
|
|
3705
|
+
"signal": None,
|
|
3706
|
+
"termination_reason": None,
|
|
3707
|
+
"descendants": _build_execution.empty_descendant_outcome(),
|
|
3708
|
+
"run_id": "",
|
|
3709
|
+
"final_tree_sha256": "",
|
|
3710
|
+
"proof": _build_execution.empty_proof_binding(execution_id),
|
|
3711
|
+
}
|
|
3712
|
+
_build_execution.write_state_unlocked(execution_id, state)
|
|
3713
|
+
|
|
3714
|
+
supervisor_args = [
|
|
3715
|
+
sys.executable,
|
|
3716
|
+
"-m",
|
|
3717
|
+
"dashboard.build_supervisor",
|
|
3718
|
+
"--execution-id",
|
|
3719
|
+
execution_id,
|
|
3720
|
+
"--run-sh",
|
|
3721
|
+
str(run_sh),
|
|
3722
|
+
"--workspace",
|
|
3723
|
+
str(workspace_dir),
|
|
3724
|
+
"--loki-dir",
|
|
3725
|
+
str(loki_dir),
|
|
3726
|
+
"--spec",
|
|
3727
|
+
str(spec_file),
|
|
3728
|
+
"--provider",
|
|
3729
|
+
body.provider,
|
|
3730
|
+
]
|
|
3731
|
+
if body.parallel:
|
|
3732
|
+
supervisor_args.append("--parallel")
|
|
3733
|
+
|
|
3734
|
+
popen_env["LOKI_OWN_SESSION"] = "1"
|
|
3735
|
+
popen_env["LOKI_SPEC_SHA256"] = spec_sha256
|
|
3736
|
+
supervisor_log = _build_execution.execution_dir(execution_id) / "supervisor.log"
|
|
3737
|
+
try:
|
|
3738
|
+
with _build_execution.open_private_log(supervisor_log) as log_handle:
|
|
3739
|
+
process = subprocess.Popen(
|
|
3740
|
+
supervisor_args,
|
|
3741
|
+
stdout=log_handle,
|
|
3742
|
+
stderr=subprocess.STDOUT,
|
|
3743
|
+
start_new_session=True,
|
|
3744
|
+
cwd=str(skill_dir),
|
|
3745
|
+
env=popen_env,
|
|
3746
|
+
)
|
|
3747
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
3748
|
+
finished_at = _build_execution.utc_now()
|
|
3749
|
+
state.update(
|
|
3750
|
+
state="exited",
|
|
3751
|
+
exited_at=finished_at,
|
|
3752
|
+
finished_at=finished_at,
|
|
3753
|
+
exit_code=None,
|
|
3754
|
+
signal=None,
|
|
3755
|
+
termination_reason="launch_failed",
|
|
3756
|
+
launch_error=str(exc),
|
|
3757
|
+
)
|
|
3758
|
+
_build_execution.write_state_unlocked(execution_id, state)
|
|
3759
|
+
raise HTTPException(
|
|
3760
|
+
status_code=500, detail=f"Failed to start build supervisor: {exc}"
|
|
3761
|
+
) from exc
|
|
3762
|
+
|
|
3763
|
+
state.update(
|
|
3764
|
+
supervisor_pid=process.pid,
|
|
3765
|
+
supervisor_birth_token=_build_execution.process_birth_token(process.pid),
|
|
3766
|
+
supervisor_log=str(supervisor_log),
|
|
3767
|
+
)
|
|
3768
|
+
_build_execution.write_state_unlocked(execution_id, state)
|
|
3769
|
+
threading.Thread(
|
|
3770
|
+
target=_reap_supervisor_process,
|
|
3771
|
+
args=(process,),
|
|
3772
|
+
name=f"build-supervisor-reaper-{execution_id[:8]}",
|
|
3773
|
+
daemon=True,
|
|
3774
|
+
).start()
|
|
3775
|
+
created = True
|
|
3776
|
+
|
|
3777
|
+
if created:
|
|
3778
|
+
audit.log_event(
|
|
3779
|
+
action="start",
|
|
3780
|
+
resource_type="session",
|
|
3781
|
+
details={
|
|
3782
|
+
"source": "dashboard",
|
|
3783
|
+
"provider": body.provider,
|
|
3784
|
+
"spec": state["spec"],
|
|
3785
|
+
"execution_id": execution_id,
|
|
3786
|
+
"supervisor_pid": state["supervisor_pid"],
|
|
3787
|
+
"workspace": str(workspace_dir),
|
|
3788
|
+
"model": start_model,
|
|
3789
|
+
"advisor_model": advisor_model,
|
|
3790
|
+
},
|
|
3791
|
+
ip_address=request.client.host if request.client else None,
|
|
3792
|
+
)
|
|
3793
|
+
return JSONResponse(
|
|
3794
|
+
status_code=202,
|
|
3795
|
+
content=_supervised_start_response(state),
|
|
3796
|
+
)
|
|
3797
|
+
|
|
3798
|
+
|
|
3481
3799
|
@app.post("/api/control/start", dependencies=[Depends(auth.require_scope("control"))])
|
|
3482
3800
|
async def start_build(request: Request, body: StartBuildRequest):
|
|
3483
3801
|
"""Start a Loki Mode build from a spec, kicked off from the browser.
|
|
@@ -3515,11 +3833,21 @@ async def start_build(request: Request, body: StartBuildRequest):
|
|
|
3515
3833
|
# When omitted, behavior is byte-identical to before: the active dashboard
|
|
3516
3834
|
# project (the engine's own _get_loki_dir).
|
|
3517
3835
|
workspace_dir: Optional[_Path] = None
|
|
3836
|
+
execution_id = ""
|
|
3518
3837
|
if body.workspace and body.workspace.strip():
|
|
3519
3838
|
try:
|
|
3520
3839
|
workspace_dir = _validate_workspace(body.workspace)
|
|
3521
3840
|
except ValueError as e:
|
|
3522
3841
|
raise HTTPException(status_code=400, detail=str(e))
|
|
3842
|
+
if not body.request_id:
|
|
3843
|
+
raise HTTPException(
|
|
3844
|
+
status_code=400,
|
|
3845
|
+
detail="request_id is required for workspace builds",
|
|
3846
|
+
)
|
|
3847
|
+
try:
|
|
3848
|
+
execution_id = _build_execution.validate_execution_id(body.request_id)
|
|
3849
|
+
except ValueError as e:
|
|
3850
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
3523
3851
|
|
|
3524
3852
|
if workspace_dir is not None:
|
|
3525
3853
|
# The build's project dir IS the workspace; its .loki lives inside it.
|
|
@@ -3538,17 +3866,28 @@ async def start_build(request: Request, body: StartBuildRequest):
|
|
|
3538
3866
|
project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
|
|
3539
3867
|
project_dir = project_dir.resolve()
|
|
3540
3868
|
|
|
3541
|
-
#
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3869
|
+
# Legacy no-workspace starts keep their existing single-flight behavior.
|
|
3870
|
+
# Workspace starts check under their execution lock below so a retried POST
|
|
3871
|
+
# can return its existing execution before the active-run guard fires.
|
|
3872
|
+
if workspace_dir is None:
|
|
3873
|
+
active_pid = _project_run_active(loki_dir)
|
|
3874
|
+
if active_pid is not None:
|
|
3875
|
+
detail = "A build is already running in this project"
|
|
3876
|
+
if active_pid > 0:
|
|
3877
|
+
detail += f" (PID {active_pid})"
|
|
3878
|
+
raise HTTPException(status_code=409, detail=detail)
|
|
3548
3879
|
|
|
3549
3880
|
# Resolve the spec to a concrete, path-guarded file.
|
|
3881
|
+
spec_content: Optional[bytes] = None
|
|
3550
3882
|
try:
|
|
3551
|
-
if
|
|
3883
|
+
if workspace_dir is not None:
|
|
3884
|
+
if has_path:
|
|
3885
|
+
source_spec = _validate_prd_path(body.prd_path.strip(), project_dir)
|
|
3886
|
+
spec_content = source_spec.read_bytes()
|
|
3887
|
+
else:
|
|
3888
|
+
spec_content = body.prd_text.encode("utf-8")
|
|
3889
|
+
spec_file = None
|
|
3890
|
+
elif has_path:
|
|
3552
3891
|
spec_file = _validate_prd_path(body.prd_path.strip(), project_dir)
|
|
3553
3892
|
else:
|
|
3554
3893
|
spec_file = _write_spec_text(body.prd_text, project_dir)
|
|
@@ -3563,14 +3902,6 @@ async def start_build(request: Request, body: StartBuildRequest):
|
|
|
3563
3902
|
if not run_sh.exists():
|
|
3564
3903
|
raise HTTPException(status_code=500, detail=f"run.sh not found at {run_sh}")
|
|
3565
3904
|
|
|
3566
|
-
# Build args: mirror control.py:start_session (provider, optional parallel,
|
|
3567
|
-
# background, then the spec path).
|
|
3568
|
-
args = [str(run_sh), "--provider", body.provider]
|
|
3569
|
-
if body.parallel:
|
|
3570
|
-
args.append("--parallel")
|
|
3571
|
-
args.append("--bg")
|
|
3572
|
-
args.append(str(spec_file))
|
|
3573
|
-
|
|
3574
3905
|
# When a workspace is given, pass an explicit env that PINS run.sh to the
|
|
3575
3906
|
# workspace. cwd alone is not enough: `loki` exports LOKI_DIR (default
|
|
3576
3907
|
# .loki) into the dashboard process, and run.sh resolves its workspace as
|
|
@@ -3584,12 +3915,28 @@ async def start_build(request: Request, body: StartBuildRequest):
|
|
|
3584
3915
|
# haiku|sonnet|opus (no fable); invalid/absent -> "" -> no pin.
|
|
3585
3916
|
start_model = _normalize_start_model(body.model)
|
|
3586
3917
|
advisor_model = _normalize_start_model(body.advisor_model)
|
|
3918
|
+
# Build profile: a light allow-list (only known profiles are forwarded, so a
|
|
3919
|
+
# stray value can never disable a gate we did not intend). Absent -> no profile.
|
|
3920
|
+
build_profile = body.build_profile if body.build_profile in ("simple-web",) else None
|
|
3921
|
+
complexity = (
|
|
3922
|
+
body.complexity
|
|
3923
|
+
if body.complexity in ("auto", "simple", "standard", "complex")
|
|
3924
|
+
else "auto"
|
|
3925
|
+
)
|
|
3926
|
+
first_pass_directive = body.first_pass_directive is not False
|
|
3587
3927
|
|
|
3588
3928
|
# Build a custom env only when we actually need to change something
|
|
3589
3929
|
# (workspace pin, start-time model pin, or advisor pin). When nothing is set,
|
|
3590
3930
|
# env stays None (inherit) so behavior is byte-identical to before.
|
|
3591
3931
|
popen_env = None
|
|
3592
|
-
if
|
|
3932
|
+
if (
|
|
3933
|
+
workspace_dir is not None
|
|
3934
|
+
or start_model
|
|
3935
|
+
or advisor_model
|
|
3936
|
+
or build_profile
|
|
3937
|
+
or complexity != "auto"
|
|
3938
|
+
or not first_pass_directive
|
|
3939
|
+
):
|
|
3593
3940
|
popen_env = dict(os.environ)
|
|
3594
3941
|
if workspace_dir is not None:
|
|
3595
3942
|
popen_env["LOKI_TARGET_DIR"] = str(workspace_dir)
|
|
@@ -3611,6 +3958,40 @@ async def start_build(request: Request, body: StartBuildRequest):
|
|
|
3611
3958
|
if advisor_model:
|
|
3612
3959
|
# Opt-in Opus (or other) judge for code review; execution model unchanged.
|
|
3613
3960
|
popen_env["LOKI_ADVISOR_MODEL"] = advisor_model
|
|
3961
|
+
if build_profile:
|
|
3962
|
+
# run.sh's loki_apply_build_profile maps this to LOKI_PHASE_* gate defaults
|
|
3963
|
+
# (skips gates irrelevant to a static frontend; keeps E2E, code review,
|
|
3964
|
+
# council, security, accessibility). The moat gates cannot be disabled here.
|
|
3965
|
+
popen_env["LOKI_BUILD_PROFILE"] = build_profile
|
|
3966
|
+
if complexity != "auto":
|
|
3967
|
+
popen_env["LOKI_COMPLEXITY"] = complexity
|
|
3968
|
+
if not first_pass_directive:
|
|
3969
|
+
popen_env["LOKI_FIRST_PASS_EXCELLENCE"] = "0"
|
|
3970
|
+
|
|
3971
|
+
if workspace_dir is not None:
|
|
3972
|
+
return _start_supervised_workspace_build(
|
|
3973
|
+
request=request,
|
|
3974
|
+
body=body,
|
|
3975
|
+
execution_id=execution_id,
|
|
3976
|
+
workspace_dir=workspace_dir,
|
|
3977
|
+
loki_dir=loki_dir,
|
|
3978
|
+
spec_content=spec_content or b"",
|
|
3979
|
+
run_sh=run_sh,
|
|
3980
|
+
skill_dir=skill_dir,
|
|
3981
|
+
popen_env=popen_env,
|
|
3982
|
+
start_model=start_model,
|
|
3983
|
+
advisor_model=advisor_model,
|
|
3984
|
+
build_profile=build_profile,
|
|
3985
|
+
complexity=complexity,
|
|
3986
|
+
first_pass_directive=first_pass_directive,
|
|
3987
|
+
)
|
|
3988
|
+
|
|
3989
|
+
# Legacy behavior for local dashboard starts without a workspace.
|
|
3990
|
+
args = [str(run_sh), "--provider", body.provider]
|
|
3991
|
+
if body.parallel:
|
|
3992
|
+
args.append("--parallel")
|
|
3993
|
+
args.append("--bg")
|
|
3994
|
+
args.append(str(spec_file))
|
|
3614
3995
|
try:
|
|
3615
3996
|
process = subprocess.Popen(
|
|
3616
3997
|
args,
|
|
@@ -3698,9 +4079,177 @@ async def start_build(request: Request, body: StartBuildRequest):
|
|
|
3698
4079
|
# so the UI can confirm what the run was actually pinned to.
|
|
3699
4080
|
"model": start_model,
|
|
3700
4081
|
"advisor_model": advisor_model,
|
|
4082
|
+
"build_profile": build_profile or "",
|
|
4083
|
+
"complexity": complexity,
|
|
4084
|
+
"first_pass_directive": first_pass_directive,
|
|
3701
4085
|
}
|
|
3702
4086
|
|
|
3703
4087
|
|
|
4088
|
+
def _execution_status_response(state: dict[str, Any]) -> dict[str, Any]:
|
|
4089
|
+
"""Add fail-closed liveness facts to a durable execution snapshot."""
|
|
4090
|
+
result = dict(state)
|
|
4091
|
+
defaults: dict[str, Any] = {
|
|
4092
|
+
"schema_version": _build_execution.SCHEMA_VERSION,
|
|
4093
|
+
"state": "accepted",
|
|
4094
|
+
"accepted_at": None,
|
|
4095
|
+
"started_at": None,
|
|
4096
|
+
"exited_at": None,
|
|
4097
|
+
"finished_at": None,
|
|
4098
|
+
"supervisor_pid": None,
|
|
4099
|
+
"supervisor_birth_token": None,
|
|
4100
|
+
"runner_pid": None,
|
|
4101
|
+
"runner_pgid": None,
|
|
4102
|
+
"runner_sid": None,
|
|
4103
|
+
"runner_birth_token": None,
|
|
4104
|
+
"returncode": None,
|
|
4105
|
+
"exit_code": None,
|
|
4106
|
+
"signal": None,
|
|
4107
|
+
"termination_reason": None,
|
|
4108
|
+
"descendants": _build_execution.empty_descendant_outcome(),
|
|
4109
|
+
"run_id": "",
|
|
4110
|
+
"final_tree_sha256": "",
|
|
4111
|
+
"proof": _build_execution.empty_proof_binding(
|
|
4112
|
+
str(state.get("execution_id") or "")
|
|
4113
|
+
),
|
|
4114
|
+
}
|
|
4115
|
+
for key, value in defaults.items():
|
|
4116
|
+
result.setdefault(key, value)
|
|
4117
|
+
raw_proof = result.get("proof")
|
|
4118
|
+
proof = dict(raw_proof) if isinstance(raw_proof, dict) else {}
|
|
4119
|
+
proof.pop("proof_path", None)
|
|
4120
|
+
proof_defaults = dict(defaults["proof"])
|
|
4121
|
+
proof_defaults.pop("proof_path", None)
|
|
4122
|
+
for key, value in proof_defaults.items():
|
|
4123
|
+
proof.setdefault(key, value)
|
|
4124
|
+
result["proof"] = proof
|
|
4125
|
+
supervisor_alive = _build_execution.process_identity_matches(
|
|
4126
|
+
state.get("supervisor_pid"), state.get("supervisor_birth_token")
|
|
4127
|
+
)
|
|
4128
|
+
runner_alive = _build_execution.process_identity_matches(
|
|
4129
|
+
state.get("runner_pid"), state.get("runner_birth_token")
|
|
4130
|
+
)
|
|
4131
|
+
result["supervisor_alive"] = supervisor_alive
|
|
4132
|
+
result["runner_alive"] = runner_alive
|
|
4133
|
+
if result.get("state") == "exited":
|
|
4134
|
+
result["lifecycle_health"] = "exited"
|
|
4135
|
+
result["terminal"] = True
|
|
4136
|
+
result["reconciliation_reason"] = ""
|
|
4137
|
+
elif supervisor_alive:
|
|
4138
|
+
result["lifecycle_health"] = (
|
|
4139
|
+
"running" if result.get("state") == "running" else "starting"
|
|
4140
|
+
)
|
|
4141
|
+
result["terminal"] = False
|
|
4142
|
+
result["reconciliation_reason"] = ""
|
|
4143
|
+
elif runner_alive:
|
|
4144
|
+
result["lifecycle_health"] = "runner_orphaned"
|
|
4145
|
+
result["terminal"] = True
|
|
4146
|
+
result["reconciliation_reason"] = "supervisor_lost"
|
|
4147
|
+
else:
|
|
4148
|
+
result["lifecycle_health"] = "supervisor_lost"
|
|
4149
|
+
result["terminal"] = True
|
|
4150
|
+
result["reconciliation_reason"] = "supervisor_lost"
|
|
4151
|
+
return result
|
|
4152
|
+
|
|
4153
|
+
|
|
4154
|
+
@app.get(
|
|
4155
|
+
"/api/control/builds/{execution_id}",
|
|
4156
|
+
dependencies=[Depends(auth.require_scope("read"))],
|
|
4157
|
+
)
|
|
4158
|
+
async def get_build_execution(execution_id: str):
|
|
4159
|
+
"""Read server-owned build state by durable execution identity."""
|
|
4160
|
+
try:
|
|
4161
|
+
execution_id = _build_execution.validate_execution_id(execution_id)
|
|
4162
|
+
except ValueError as exc:
|
|
4163
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
4164
|
+
state = _build_execution.read_state(execution_id)
|
|
4165
|
+
if state is None:
|
|
4166
|
+
raise HTTPException(status_code=404, detail="Build execution not found")
|
|
4167
|
+
return _execution_status_response(state)
|
|
4168
|
+
|
|
4169
|
+
|
|
4170
|
+
@app.get(
|
|
4171
|
+
"/api/control/builds/{execution_id}/proof",
|
|
4172
|
+
dependencies=[Depends(auth.require_scope("read"))],
|
|
4173
|
+
)
|
|
4174
|
+
async def get_build_execution_proof(execution_id: str):
|
|
4175
|
+
"""Serve the immutable server-owned proof snapshot for one execution."""
|
|
4176
|
+
try:
|
|
4177
|
+
execution_id = _build_execution.validate_execution_id(execution_id)
|
|
4178
|
+
except ValueError as exc:
|
|
4179
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
4180
|
+
state = _build_execution.read_state(execution_id)
|
|
4181
|
+
if state is None:
|
|
4182
|
+
raise HTTPException(status_code=404, detail="Build execution not found")
|
|
4183
|
+
proof = state.get("proof") if isinstance(state.get("proof"), dict) else {}
|
|
4184
|
+
expected_sha = str(proof.get("document_sha256") or "")
|
|
4185
|
+
snapshot = _build_execution.execution_dir(execution_id) / "proof.json"
|
|
4186
|
+
try:
|
|
4187
|
+
raw = snapshot.read_bytes()
|
|
4188
|
+
except OSError as exc:
|
|
4189
|
+
raise HTTPException(status_code=404, detail="Proof snapshot not found") from exc
|
|
4190
|
+
if not expected_sha or _build_execution.sha256_bytes(raw) != expected_sha:
|
|
4191
|
+
raise HTTPException(
|
|
4192
|
+
status_code=409,
|
|
4193
|
+
detail="Proof snapshot integrity check failed",
|
|
4194
|
+
)
|
|
4195
|
+
return Response(
|
|
4196
|
+
content=raw,
|
|
4197
|
+
media_type="application/json",
|
|
4198
|
+
headers={"ETag": f'"sha256:{expected_sha}"'},
|
|
4199
|
+
)
|
|
4200
|
+
|
|
4201
|
+
|
|
4202
|
+
@app.post(
|
|
4203
|
+
"/api/control/builds/{execution_id}/stop",
|
|
4204
|
+
dependencies=[Depends(auth.require_scope("control"))],
|
|
4205
|
+
)
|
|
4206
|
+
async def stop_build_execution(request: Request, execution_id: str):
|
|
4207
|
+
"""Ask the exact long-lived supervisor to stop its owned runner group."""
|
|
4208
|
+
if not _control_limiter.check("control"):
|
|
4209
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
4210
|
+
try:
|
|
4211
|
+
execution_id = _build_execution.validate_execution_id(execution_id)
|
|
4212
|
+
except ValueError as exc:
|
|
4213
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
4214
|
+
|
|
4215
|
+
with _build_execution.execution_lock(execution_id):
|
|
4216
|
+
state = _build_execution.read_state_unlocked(execution_id)
|
|
4217
|
+
if state is None:
|
|
4218
|
+
raise HTTPException(status_code=404, detail="Build execution not found")
|
|
4219
|
+
if state.get("state") == "exited":
|
|
4220
|
+
return {
|
|
4221
|
+
"success": True,
|
|
4222
|
+
"message": "Build execution already exited",
|
|
4223
|
+
"execution_id": execution_id,
|
|
4224
|
+
"state": "exited",
|
|
4225
|
+
}
|
|
4226
|
+
if not _build_execution.process_identity_matches(
|
|
4227
|
+
state.get("supervisor_pid"), state.get("supervisor_birth_token")
|
|
4228
|
+
):
|
|
4229
|
+
raise HTTPException(
|
|
4230
|
+
status_code=409,
|
|
4231
|
+
detail="Build supervisor identity cannot be verified",
|
|
4232
|
+
)
|
|
4233
|
+
state.setdefault("stop_requested_at", _build_execution.utc_now())
|
|
4234
|
+
_build_execution.write_state_unlocked(execution_id, state)
|
|
4235
|
+
|
|
4236
|
+
audit.log_event(
|
|
4237
|
+
action="stop",
|
|
4238
|
+
resource_type="session",
|
|
4239
|
+
details={"source": "api", "execution_id": execution_id},
|
|
4240
|
+
ip_address=request.client.host if request.client else None,
|
|
4241
|
+
)
|
|
4242
|
+
return JSONResponse(
|
|
4243
|
+
status_code=202,
|
|
4244
|
+
content={
|
|
4245
|
+
"success": True,
|
|
4246
|
+
"message": "Stop requested",
|
|
4247
|
+
"execution_id": execution_id,
|
|
4248
|
+
"state": str(state.get("state") or "running"),
|
|
4249
|
+
},
|
|
4250
|
+
)
|
|
4251
|
+
|
|
4252
|
+
|
|
3704
4253
|
class RunningProjectStopRequest(BaseModel):
|
|
3705
4254
|
"""Schema for stopping a specific registered project from the switcher.
|
|
3706
4255
|
|
|
@@ -9942,7 +10491,10 @@ def get_prompt_versions():
|
|
|
9942
10491
|
if not _read_limiter.check("prompt_versions"):
|
|
9943
10492
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
9944
10493
|
try:
|
|
9945
|
-
|
|
10494
|
+
result = _get_prompt_optimizer().get_current_version()
|
|
10495
|
+
result["experimental"] = True
|
|
10496
|
+
result["note"] = "heuristic-only: returns content-hash version tracking, not an applied LLM optimization"
|
|
10497
|
+
return result
|
|
9946
10498
|
except Exception as exc:
|
|
9947
10499
|
logger.error("Prompt version read error: %s", exc)
|
|
9948
10500
|
raise HTTPException(status_code=500, detail="Failed to read prompt versions")
|
|
@@ -9956,7 +10508,10 @@ def optimize_prompts(sessions: int = 10, dry_run: bool = True):
|
|
|
9956
10508
|
if not _control_limiter.check("prompt_optimize"):
|
|
9957
10509
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
9958
10510
|
try:
|
|
9959
|
-
|
|
10511
|
+
result = _get_prompt_optimizer().optimize(sessions=sessions, dry_run=dry_run)
|
|
10512
|
+
result["experimental"] = True
|
|
10513
|
+
result["note"] = "heuristic-only: returns content-hash version tracking, not an applied LLM optimization"
|
|
10514
|
+
return result
|
|
9960
10515
|
except Exception as exc:
|
|
9961
10516
|
logger.error("Prompt optimization error: %s", exc)
|
|
9962
10517
|
raise HTTPException(status_code=500, detail="Failed to run prompt optimization")
|
|
@@ -9967,7 +10522,7 @@ def optimize_prompts(sessions: int = 10, dry_run: bool = True):
|
|
|
9967
10522
|
# =============================================================================
|
|
9968
10523
|
# Must be configured AFTER all API routes to avoid conflicts
|
|
9969
10524
|
|
|
9970
|
-
from fastapi.responses import FileResponse, HTMLResponse
|
|
10525
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
9971
10526
|
|
|
9972
10527
|
# Find static files in multiple possible locations
|
|
9973
10528
|
DASHBOARD_DIR = os.path.dirname(os.path.abspath(__file__))
|