diff --git a/Cortex_Preview.py b/Cortex_Preview.py index 80f1154..acfe55e 100644 --- a/Cortex_Preview.py +++ b/Cortex_Preview.py @@ -42,11 +42,16 @@ def build_preview_app( frontend_dist: Path | None = None, serve_frontend: bool = True, handoff_secret: str | None = None, - execution_profile: str | None = None, + execution_profile: str | None = "local", qualification: QualificationLifecycleConfig | None = None, execution_lifecycle: ExecutionLifecycle | None = None, ): - """Build the local web application without starting a server.""" + """Build the local web application without starting a server. + + Source and packaged launches select the checked-in ``local`` execution + profile. Pass ``execution_profile="disabled"`` for a chat-only preview or + inject a lifecycle explicitly for qualification tests. + """ paths = AppPaths.from_data_dir(data_dir) if data_dir else AppPaths.for_current_user() execution_repository = ExecutionRepository( paths.execution_database, diff --git a/README.md b/README.md index 0d3c6f0..75c61db 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ the user's installed browser. - Persistent SQLite conversations and atomic JSON permanent memory. - Validated settings, local model inventory and pull progress, and optional translation. +- Safe local computation for explicit arithmetic requests, with a user setting + to disable automatic verification. +- User-requested PNG, JPEG, and WebP transformations with visible progress, + Stop, and a local result download. - Loopback-only API authentication with one-time native-window bootstrap tokens. - Windows one-folder packaging with the frontend bundled and no Node.js or system Python required at runtime. @@ -40,6 +44,10 @@ packaging/ Windows PyInstaller build tests/ headless Python and browser-facing tests ``` +The current open-source execution scope and remaining product stages are tracked in +the [open-source execution plan](docs/OPEN_SOURCE_EXECUTION_PLAN.md). The larger +capability ADR is technical reference; it is not a list of additional TODO items. + The supported runtime is Windows. The embedded window uses the WebView2 Runtime, not Edge, Chrome, or their user profiles. User data remains in the existing location: `%APPDATA%\ChatLLM\ChatLLM-Assistant`. Legacy SQLite, JSON chat, permanent diff --git a/backend/cortex_backend/api/routes.py b/backend/cortex_backend/api/routes.py index 0ed9d26..90316f3 100644 --- a/backend/cortex_backend/api/routes.py +++ b/backend/cortex_backend/api/routes.py @@ -15,7 +15,7 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import StreamingResponse +from fastapi.responses import Response, StreamingResponse from cortex_backend.core.generation import ConnectionResult, GenerationSnapshot from cortex_backend.services.chat import ( @@ -35,13 +35,18 @@ AttachmentStagingError, AttachmentStagingService, ) +from cortex_backend.execution.artifact_boundary import ArtifactBoundary from cortex_backend.execution.models import ExecutionJob, ExecutionEvent, TerminalExecutionStatus from cortex_backend.execution.recipe_coordinator import ( RECIPE_IMAGE_PROFILE, - RecipeExecutionCoordinator, RecipeExecutionError, RecipeImageRequest, ) +from cortex_backend.execution.scratch_compute import ( + ScratchComputeError, + ScratchComputeRequest as ScratchExecutionRequest, + extract_automatic_expression, +) from cortex_backend.execution.repository import ( ApprovalPolicyError, ApprovalTransitionError, @@ -65,6 +70,8 @@ ExecutionPreviewRequest, RecipeImageTransformAccepted, RecipeImageTransformRequest, + ScratchComputeAccepted, + ScratchComputeRequest, ExecutionSSEEvent, ExecutionStatusResponse, ExecutionTaskListResponse, @@ -95,6 +102,9 @@ from .security import SessionPrincipal, SessionSecurityError +DEFAULT_AUTOMATIC_COMPUTE_WAIT_SECONDS = 1.5 + + def build_router() -> APIRouter: router = APIRouter() @@ -155,11 +165,29 @@ def dependencies(request: Request) -> BackendDependenciesProtocol: def system( request: Request, _: SessionPrincipal = Depends(require_session) ) -> SystemResponse: + coordinator = request.app.state.execution_coordinator return SystemResponse( preview=request.app.state.preview, execution_preview_available=( request.app.state.preview - and request.app.state.execution_coordinator is not None + and coordinator is not None + ), + scratch_compute_available=bool( + request.app.state.preview + and getattr( + coordinator, + "scratch_available", + False, + ) + ), + image_transform_available=bool( + request.app.state.preview + and coordinator is not None + and getattr( + coordinator, + "image_transform_available", + callable(getattr(coordinator, "start_image_transform", None)), + ) ), started_at=request.app.state.started_at, ollama_host=request.app.state.ollama_host, @@ -445,6 +473,42 @@ def start_fake_execution( sequence=job.sequence, ) + @router.post( + "/execution/scratch", + response_model=ScratchComputeAccepted, + status_code=status.HTTP_202_ACCEPTED, + ) + def start_scratch_compute( + request: Request, + payload: ScratchComputeRequest, + principal: SessionPrincipal = Depends(require_session), + ) -> ScratchComputeAccepted: + """Start one bounded calculation in the local worker profile.""" + + coordinator = _scratch_coordinator(request) + try: + job = coordinator.start_scratch( + ScratchExecutionRequest( + owner=_execution_owner(principal), + request_id=payload.request_id, + expression=payload.expression, + ) + ) + except ScratchComputeError as exc: + _raise_scratch_request_error(exc) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Safe computation request is invalid.", + ) from exc + return ScratchComputeAccepted( + job_id=job.job_id, + request_id=job.request_id, + profile="scratch.auto.v1", + status=job.status, + sequence=job.sequence, + ) + @router.post( "/execution/recipe/image", response_model=RecipeImageTransformAccepted, @@ -540,6 +604,44 @@ def stage_attachment( expires_at=datetime.fromisoformat(artifact.expires_at), ) + @router.get("/execution/artifacts/{artifact_id}") + def download_execution_artifact( + artifact_id: str, + request: Request, + principal: SessionPrincipal = Depends(require_session), + ) -> Response: + """Return one owner-scoped, integrity-checked result artifact. + + The UI receives bytes only after the repository re-checks retention, + location, size, and digest. Neither the artifact path nor the source + filename is exposed to the browser. + """ + + repository = _execution_repository(request) + artifact = repository.get_artifact( + artifact_id, + owner=_execution_owner(principal), + ) + if artifact is None: + raise HTTPException(status_code=404, detail="Execution artifact is unavailable.") + try: + content = repository.read_artifact(artifact.artifact_id) + except ExecutionRepositoryError: + raise HTTPException(status_code=404, detail="Execution artifact is unavailable.") from None + suffix = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + }.get(artifact.mime_type, "bin") + return Response( + content=content, + media_type=artifact.mime_type, + headers={ + "Cache-Control": "no-store", + "Content-Disposition": f'attachment; filename="cortex-result.{suffix}"', + }, + ) + @router.get("/execution/tasks", response_model=ExecutionTaskListResponse) def execution_tasks( request: Request, @@ -1136,11 +1238,19 @@ async def _start_generation_job( settings = _load_settings(deps) job_id = uuid4().hex generation_payload = payload.model_copy(update={"thread_id": thread_id}) + compute_observation = await _automatic_compute_observation( + request, + principal, + settings, + generation_payload, + job_id, + ) generation_snapshot = _generation_snapshot( job_id, generation_payload, settings, deps.models.list_installed(), + compute_observation=compute_observation, ) user_message_id: str | None = None @@ -1266,6 +1376,66 @@ def runner(sink, cancel_event): return snapshot, user_message_id +async def _automatic_compute_observation( + request: Request, + principal: SessionPrincipal, + settings: CortexSettings, + payload: GenerationRequest, + generation_job_id: str, +) -> str | None: + """Run an explicit arithmetic request before generation when it is useful. + + This is intentionally conservative: normal prose is never turned into a + program. If the local worker is unavailable, slow, cancelled, or rejects + the expression, ordinary chat proceeds without a hidden retry loop. + """ + + if not settings.execution.automatic_compute: + return None + expression = extract_automatic_expression(payload.user_input) + if expression is None: + return None + coordinator = getattr(request.app.state, "execution_coordinator", None) + if ( + coordinator is None + or not getattr(coordinator, "scratch_available", False) + or not callable(getattr(coordinator, "start_scratch", None)) + or not callable(getattr(coordinator, "wait", None)) + ): + return None + try: + job = await asyncio.to_thread( + coordinator.start_scratch, + ScratchExecutionRequest( + owner=_execution_owner(principal), + request_id=f"auto-{generation_job_id}", + expression=expression, + ), + ) + completed = await asyncio.to_thread( + coordinator.wait, + job.job_id, + timeout=DEFAULT_AUTOMATIC_COMPUTE_WAIT_SECONDS, + ) + except (ScratchComputeError, TimeoutError, TypeError, ValueError): + return None + except Exception as exc: + logging.getLogger("cortex.execution").warning( + "Automatic safe computation was unavailable (%s).", type(exc).__name__ + ) + return None + if completed.status != "succeeded" or not isinstance(completed.result, Mapping): + return None + value = completed.result.get("value") + if not isinstance(value, str) or not value or len(value) > 128: + return None + return ( + "A local safe-compute worker verified this exact arithmetic result: " + f"{expression} = {value}. Treat it as a reliable fact, explain it plainly, " + "and do not claim to have run any other code." + ) + + def _chunks(value: str, size: int = 80): for start in range(0, len(value), size): yield value[start : start + size] @@ -1378,6 +1548,8 @@ def _generation_snapshot( payload: GenerationRequest, settings: CortexSettings, installed_models: tuple[str, ...], + *, + compute_observation: str | None = None, ) -> GenerationSnapshot: chat_model = _selected_local_model(settings.models.chat, installed_models) if chat_model is None: @@ -1394,6 +1566,13 @@ def _generation_snapshot( raise ChatDomainError( "Choose or install a local translation model before enabling translation." ) + instructions = settings.generation.system_instructions or None + if compute_observation: + instructions = ( + f"{instructions}\n\n{compute_observation}" + if instructions + else compute_observation + ) return GenerationSnapshot( job_id=job_id, thread_id=payload.thread_id or "", @@ -1409,7 +1588,7 @@ def _generation_snapshot( memories_enabled=settings.memory.enabled, translation_enabled=settings.translation.enabled, target_language=settings.translation.target_language, - user_system_instructions=settings.generation.system_instructions or None, + user_system_instructions=instructions, ) @@ -1522,14 +1701,13 @@ def _fake_execution_coordinator(request: Request) -> DurableFakeCoordinator: return coordinator -def _recipe_coordinator(request: Request) -> RecipeExecutionCoordinator: - """Expose recipes only from a ready, explicit qualification lifecycle.""" +def _recipe_coordinator(request: Request): + """Expose a ready fixed-image profile without granting general execution.""" lifecycle = getattr(request.app.state, "execution_lifecycle", None) if ( not request.app.state.preview or lifecycle is None - or getattr(lifecycle, "profile", None) != "qualification" or not getattr(getattr(lifecycle, "snapshot", None), "available", False) ): raise HTTPException( @@ -1537,7 +1715,12 @@ def _recipe_coordinator(request: Request) -> RecipeExecutionCoordinator: detail="Recipe execution is unavailable.", ) coordinator = getattr(lifecycle, "coordinator", None) - if not isinstance(coordinator, RecipeExecutionCoordinator): + if ( + coordinator is None + or not callable(getattr(coordinator, "start_image_transform", None)) + or not isinstance(getattr(coordinator, "artifact_boundary", None), ArtifactBoundary) + or not getattr(coordinator, "image_transform_available", True) + ): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Recipe execution is unavailable.", @@ -1545,6 +1728,21 @@ def _recipe_coordinator(request: Request) -> RecipeExecutionCoordinator: return coordinator +def _scratch_coordinator(request: Request): + """Require the narrow local safe-compute capability explicitly.""" + + coordinator = _execution_runtime(request) + if ( + not getattr(coordinator, "scratch_available", False) + or not callable(getattr(coordinator, "start_scratch", None)) + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Safe computation is unavailable.", + ) + return coordinator + + def _raise_recipe_request_error(exc: RecipeExecutionError) -> None: """Map internal recipe categories to stable, non-sensitive HTTP responses.""" @@ -1564,6 +1762,20 @@ def _raise_recipe_request_error(exc: RecipeExecutionError) -> None: ) from exc +def _raise_scratch_request_error(exc: ScratchComputeError) -> None: + """Map scratch errors without exposing input or worker details.""" + + if exc.code == "request_conflict": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Safe computation request conflicts with an existing request.", + ) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Safe computation request could not be accepted safely.", + ) from exc + + def _raise_attachment_staging_error(exc: AttachmentStagingError) -> None: """Map attachment stage categories to stable, non-sensitive responses.""" diff --git a/backend/cortex_backend/api/schemas.py b/backend/cortex_backend/api/schemas.py index 353da1e..0d6c656 100644 --- a/backend/cortex_backend/api/schemas.py +++ b/backend/cortex_backend/api/schemas.py @@ -23,6 +23,11 @@ RecipeValidationError, parse_image_transform, ) +from cortex_backend.execution.scratch_compute import ( + SCRATCH_COMPUTE_PROFILE, + ScratchComputeError, + validate_scratch_expression, +) class APIModel(BaseModel): @@ -54,6 +59,8 @@ class SystemResponse(APIModel): preview: bool = True session_required: bool = True execution_preview_available: bool = False + scratch_compute_available: bool = False + image_transform_available: bool = False started_at: datetime ollama_host: str = "http://127.0.0.1:11434" ollama_setup_url: str = "https://ollama.com/download" @@ -210,6 +217,26 @@ class ExecutionPreviewRequest(APIModel): step_delay_seconds: float = Field(default=0.0, ge=0.0, le=1.0) +class ScratchComputeRequest(APIModel): + """One bounded expression, never Python source or a shell command.""" + + request_id: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + strict=True, + ) + expression: str = Field(min_length=1, max_length=512, strict=True) + + @field_validator("expression") + @classmethod + def _safe_expression(cls, value: str) -> str: + try: + return validate_scratch_expression(value) + except ScratchComputeError: + raise ValueError("safe computation expression is invalid") from None + + class RecipeImageTransformRequest(APIModel): """Explicit request for one qualified, fixed-function image transform. @@ -297,6 +324,14 @@ class ExecutionAccepted(APIModel): sequence: int +class ScratchComputeAccepted(APIModel): + job_id: str + request_id: str + profile: Literal[SCRATCH_COMPUTE_PROFILE] + status: ExecutionStatus + sequence: int + + class RecipeImageTransformAccepted(APIModel): job_id: str request_id: str diff --git a/backend/cortex_backend/core/__init__.py b/backend/cortex_backend/core/__init__.py index 99fb8b6..0e4ca03 100644 --- a/backend/cortex_backend/core/__init__.py +++ b/backend/cortex_backend/core/__init__.py @@ -13,6 +13,7 @@ from .settings import ( AppearanceSettings, CortexSettings, + ExecutionSettings, GenerationSettings, MemorySettings, ModelSettings, @@ -28,6 +29,7 @@ "ConnectionStatus", "AppearanceSettings", "CortexSettings", + "ExecutionSettings", "GenerationResult", "GenerationSnapshot", "GenerationSettings", diff --git a/backend/cortex_backend/core/settings.py b/backend/cortex_backend/core/settings.py index 130cd87..a41f00f 100644 --- a/backend/cortex_backend/core/settings.py +++ b/backend/cortex_backend/core/settings.py @@ -45,6 +45,12 @@ class GenerationSettings(_SettingsModel): system_instructions: str = Field(default="", max_length=1800) +class ExecutionSettings(_SettingsModel): + """Small, user-visible controls for the bounded local execution tools.""" + + automatic_compute: bool = True + + class MemorySettings(_SettingsModel): enabled: bool = True @@ -71,6 +77,7 @@ class CortexSettings(_SettingsModel): onboarding: OnboardingSettings = Field(default_factory=OnboardingSettings) models: ModelSettings = Field(default_factory=ModelSettings) generation: GenerationSettings = Field(default_factory=GenerationSettings) + execution: ExecutionSettings = Field(default_factory=ExecutionSettings) memory: MemorySettings = Field(default_factory=MemorySettings) translation: TranslationSettings = Field(default_factory=TranslationSettings) suggestions: SuggestionSettings = Field(default_factory=SuggestionSettings) diff --git a/backend/cortex_backend/execution/__init__.py b/backend/cortex_backend/execution/__init__.py index 8fb93d0..3451cf2 100644 --- a/backend/cortex_backend/execution/__init__.py +++ b/backend/cortex_backend/execution/__init__.py @@ -1,10 +1,10 @@ """Durable execution primitives and provider-independent safety contracts. -Only deterministic contracts, the storage-only signed bundle installer, the -reviewed broker transports, the explicit local qualification composition, and the -internal qualified recipe coordinator are exposed in this phase. Native transport -and bundle storage never load a provider; the normal application remains -default-off until a later application-integration decision. +The package exposes deterministic contracts, the storage-only signed bundle +installer, reviewed broker transports, an optional qualification composition, +and the normal process-backed local runtime. Native transport and bundle storage +never load a provider; the normal application uses only the checked-in bounded +local profiles unless a caller explicitly selects qualification wiring. """ from .broker import ( @@ -54,6 +54,7 @@ verify_keyring_update, ) from .lifecycle import ExecutionLifecycle, LifecycleSnapshot, RuntimeHealth +from .local_runtime import LocalExecutionCoordinator, LocalRecipeWorkerAttempt from .qualification import ( CoordinatorFactory, ExecutionProfile, @@ -161,6 +162,16 @@ RecipeWorkerConnection, RecipeWorkerOutput, ) +from .scratch_compute import ( + SCRATCH_COMPUTE_PROFILE, + SCRATCH_PAYLOAD_SCHEMA, + SCRATCH_RESULT_SCHEMA, + ScratchComputeError, + ScratchComputeRequest, + ScratchComputeResult, + evaluate_scratch_expression, + extract_automatic_expression, +) from .resource_accounting import ( MAX_CONSOLE_BYTES, MAX_COUNTER, @@ -241,6 +252,8 @@ "RecipeWorkerConnection", "RecipeWorkerOutput", "ExecutionLifecycle", + "LocalExecutionCoordinator", + "LocalRecipeWorkerAttempt", "ExecutionProfile", "CoordinatorFactory", "build_recipe_coordinator_factory", @@ -286,6 +299,14 @@ "PublishedArtifact", "RecipeValidationError", "RuntimeHealth", + "SCRATCH_COMPUTE_PROFILE", + "SCRATCH_PAYLOAD_SCHEMA", + "SCRATCH_RESULT_SCHEMA", + "ScratchComputeError", + "ScratchComputeRequest", + "ScratchComputeResult", + "evaluate_scratch_expression", + "extract_automatic_expression", "ProviderHealthProbe", "QualificationLifecycleConfig", "QualificationProfileError", diff --git a/backend/cortex_backend/execution/local_runtime.py b/backend/cortex_backend/execution/local_runtime.py new file mode 100644 index 0000000..b5204c8 --- /dev/null +++ b/backend/cortex_backend/execution/local_runtime.py @@ -0,0 +1,704 @@ +"""Practical local execution runtime for the open-source desktop app. + +It exposes two intentionally narrow background capabilities: + +* ``scratch.auto.v1`` evaluates a safe decimal expression in a short-lived + worker process; and +* ``recipe.image.v1`` runs the fixed image provider in a short-lived worker + process after attachment staging has copied bytes into the artifact store. + +Neither capability accepts a path, shell command, Python source, package name, +or network instruction. The parent owns durable state, cancellation, and +artifact publication; workers receive only immutable input and return compact +validated output. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import json +import multiprocessing +from threading import Event, Lock, Thread +import time +from typing import Any +from uuid import uuid4 + +from .artifact_boundary import ArtifactBoundary +from .lifecycle import RuntimeHealth +from .models import ExecutionJob, TerminalExecutionStatus +from .recipe_coordinator import ( + RECIPE_IMAGE_PROFILE, + RecipeExecutionCoordinator, + RecipeExecutionError, + RecipeImageRequest, + RecipeWorkerOutput, +) +from .recipe_provider import RecipeImageProvider, RecipeProviderError +from .recipes import RecipeValidationError, parse_image_transform +from .repository import ExecutionRepository, LeaseConflict +from .scratch_compute import ( + SCRATCH_COMPUTE_PROFILE, + SCRATCH_PAYLOAD_SCHEMA, + ScratchComputeError, + ScratchComputeRequest, + ScratchComputeResult, + scratch_result_payload, + scratch_worker_main, + validate_scratch_expression, +) + + +DEFAULT_SCRATCH_TIMEOUT_SECONDS = 3.0 +DEFAULT_IMAGE_TIMEOUT_SECONDS = 45.0 +DEFAULT_CANCEL_GRACE_SECONDS = 0.35 +_RECIPE_PROCESS_ERROR = "worker_provider_failed" + + +def _recipe_worker_main( + connection: Any, + cancel_event: Any, + plan_payload: Mapping[str, Any], + content: bytes, +) -> None: + """Run only the fixed provider in a child process and return bytes/metadata.""" + + try: + provider = RecipeImageProvider() + health = provider.start( + RuntimeHealth.ready("The local image worker dependency check passed.") + ) + if not health.available: + connection.send({"ok": False, "code": _RECIPE_PROCESS_ERROR}) + return + plan = parse_image_transform(plan_payload) + result = provider.transform( + plan, + content, + cancel_check=lambda: bool(cancel_event.is_set()), + ) + connection.send( + { + "ok": True, + "content": result.content, + "mime_type": result.mime_type, + "format": result.format, + "width": result.width, + "height": result.height, + "sha256": result.sha256, + } + ) + except (RecipeProviderError, RecipeValidationError): + try: + connection.send({"ok": False, "code": _RECIPE_PROCESS_ERROR}) + except Exception: + pass + except Exception: + try: + connection.send({"ok": False, "code": "worker_failed"}) + except Exception: + pass + finally: + try: + connection.close() + except Exception: + pass + + +def _stop_process(process: Any, *, grace_seconds: float = DEFAULT_CANCEL_GRACE_SECONDS) -> None: + """Bounded clean-up for a worker that may have stopped responding.""" + + try: + process.join(timeout=max(0.0, grace_seconds)) + except Exception: + return + try: + alive = bool(process.is_alive()) + except Exception: + return + if alive: + try: + process.terminate() + except Exception: + return + try: + process.join(timeout=max(0.0, grace_seconds)) + except Exception: + pass + + +class LocalRecipeWorkerAttempt: + """A cancellable, short-lived local process wrapper for the fixed recipe.""" + + def __init__( + self, + _job: ExecutionJob, + *, + timeout_seconds: float = DEFAULT_IMAGE_TIMEOUT_SECONDS, + cancel_grace_seconds: float = DEFAULT_CANCEL_GRACE_SECONDS, + ) -> None: + if timeout_seconds <= 0 or cancel_grace_seconds <= 0: + raise ValueError("worker timeouts must be positive") + self._context = multiprocessing.get_context("spawn") + self._cancel_event = self._context.Event() + self._timeout_seconds = float(timeout_seconds) + self._cancel_grace_seconds = float(cancel_grace_seconds) + self._lock = Lock() + self._process: Any | None = None + self._closed = False + + def transform( + self, + _request_id: str, + job_id: str, + plan: Any, + content: bytes, + cancel_event: Event, + ) -> RecipeWorkerOutput: + if self._closed: + raise RecipeExecutionError("worker_closed") + if cancel_event.is_set() or self._cancel_event.is_set(): + raise RecipeExecutionError("cancelled") + try: + plan_payload = plan.model_dump(mode="json") + except Exception: + raise RecipeExecutionError("worker_plan_invalid") from None + receiver = sender = process = None + try: + receiver, sender = self._context.Pipe(duplex=False) + process = self._context.Process( + target=_recipe_worker_main, + args=(sender, self._cancel_event, plan_payload, content), + name=f"cortex-image-{job_id}", + daemon=True, + ) + with self._lock: + if self._closed: + raise RecipeExecutionError("worker_closed") + self._process = process + process.start() + sender.close() + sender = None + deadline = time.monotonic() + self._timeout_seconds + cancelled_at: float | None = None + while True: + if cancel_event.is_set() or self._cancel_event.is_set(): + self._cancel_event.set() + cancelled_at = cancelled_at or time.monotonic() + if receiver.poll(0.025): + try: + message = receiver.recv() + except (EOFError, OSError): + raise RecipeExecutionError("worker_failed") from None + return self._output_from_message(message) + now = time.monotonic() + if cancelled_at is not None and now - cancelled_at >= self._cancel_grace_seconds: + raise RecipeExecutionError("cancelled") + if now >= deadline: + raise RecipeExecutionError("worker_timeout") + finally: + if sender is not None: + try: + sender.close() + except Exception: + pass + if receiver is not None: + try: + receiver.close() + except Exception: + pass + if process is not None: + _stop_process(process, grace_seconds=self._cancel_grace_seconds) + with self._lock: + if self._process is process: + self._process = None + + @staticmethod + def _output_from_message(message: object) -> RecipeWorkerOutput: + if not isinstance(message, Mapping): + raise RecipeExecutionError("worker_output_invalid") + if message.get("ok") is not True: + code = message.get("code") + if code == "cancelled": + raise RecipeExecutionError("cancelled") + raise RecipeExecutionError(_RECIPE_PROCESS_ERROR) + try: + return RecipeWorkerOutput( + content=message["content"], + mime_type=message["mime_type"], + format=message["format"], + width=message["width"], + height=message["height"], + sha256=message["sha256"], + ) + except (KeyError, TypeError, RecipeExecutionError): + raise RecipeExecutionError("worker_output_invalid") from None + + def cancel(self, _reason: str = "user") -> None: + self._cancel_event.set() + + def close(self) -> None: + with self._lock: + self._closed = True + process = self._process + self._cancel_event.set() + if process is not None: + _stop_process(process, grace_seconds=self._cancel_grace_seconds) + + +class _LocalScratchAttempt: + """Spawn the limited expression evaluator with a hard wall-clock deadline.""" + + def __init__( + self, + *, + timeout_seconds: float = DEFAULT_SCRATCH_TIMEOUT_SECONDS, + cancel_grace_seconds: float = DEFAULT_CANCEL_GRACE_SECONDS, + ) -> None: + if timeout_seconds <= 0 or cancel_grace_seconds <= 0: + raise ValueError("worker timeouts must be positive") + self._context = multiprocessing.get_context("spawn") + self._cancel_event = self._context.Event() + self._timeout_seconds = float(timeout_seconds) + self._cancel_grace_seconds = float(cancel_grace_seconds) + self._lock = Lock() + self._process: Any | None = None + self._closed = False + + def evaluate(self, expression: str, cancel_event: Event) -> ScratchComputeResult: + if self._closed: + raise ScratchComputeError("worker_closed") + receiver = sender = process = None + try: + receiver, sender = self._context.Pipe(duplex=False) + process = self._context.Process( + target=scratch_worker_main, + args=(sender, self._cancel_event, expression), + name="cortex-scratch", + daemon=True, + ) + with self._lock: + if self._closed: + raise ScratchComputeError("worker_closed") + self._process = process + process.start() + sender.close() + sender = None + deadline = time.monotonic() + self._timeout_seconds + cancelled_at: float | None = None + while True: + if cancel_event.is_set() or self._cancel_event.is_set(): + self._cancel_event.set() + cancelled_at = cancelled_at or time.monotonic() + if receiver.poll(0.025): + try: + message = receiver.recv() + except (EOFError, OSError): + raise ScratchComputeError("worker_failed") from None + if not isinstance(message, Mapping): + raise ScratchComputeError("worker_output_invalid") + if message.get("ok") is not True: + code = message.get("code") + raise ScratchComputeError( + "cancelled" if code == "cancelled" else "worker_failed" + ) + try: + return ScratchComputeResult(value=message["value"]) + except (KeyError, TypeError, ValueError): + raise ScratchComputeError("worker_output_invalid") from None + now = time.monotonic() + if cancelled_at is not None and now - cancelled_at >= self._cancel_grace_seconds: + raise ScratchComputeError("cancelled") + if now >= deadline: + raise ScratchComputeError("worker_timeout") + finally: + if sender is not None: + try: + sender.close() + except Exception: + pass + if receiver is not None: + try: + receiver.close() + except Exception: + pass + if process is not None: + _stop_process(process, grace_seconds=self._cancel_grace_seconds) + with self._lock: + if self._process is process: + self._process = None + + def cancel(self) -> None: + self._cancel_event.set() + + def close(self) -> None: + with self._lock: + self._closed = True + process = self._process + self._cancel_event.set() + if process is not None: + _stop_process(process, grace_seconds=self._cancel_grace_seconds) + + +class LocalExecutionCoordinator: + """One lifecycle owner for the normal local image and compute profiles.""" + + def __init__( + self, + repository: ExecutionRepository, + *, + lease_seconds: float = 60.0, + supervisor_lease_seconds: float = 60.0, + scratch_timeout_seconds: float = DEFAULT_SCRATCH_TIMEOUT_SECONDS, + image_timeout_seconds: float = DEFAULT_IMAGE_TIMEOUT_SECONDS, + ) -> None: + if not isinstance(repository, ExecutionRepository): + raise TypeError("repository must be an ExecutionRepository") + if lease_seconds <= 0 or supervisor_lease_seconds <= 0: + raise ValueError("lease durations must be positive") + self.repository = repository + self.artifact_boundary = ArtifactBoundary(repository) + self.lease_seconds = float(lease_seconds) + self.supervisor_lease_seconds = float(supervisor_lease_seconds) + self.scratch_timeout_seconds = float(scratch_timeout_seconds) + self.image_timeout_seconds = float(image_timeout_seconds) + self._supervisor_owner = f"local-supervisor-{uuid4().hex}" + self._supervisor_lease_active = False + self._scratch_lock = Lock() + self._scratch_threads: dict[str, Thread] = {} + self._scratch_cancel_events: dict[str, Event] = {} + self._scratch_attempts: dict[str, _LocalScratchAttempt] = {} + self._recipe = RecipeExecutionCoordinator( + repository, + lambda job: LocalRecipeWorkerAttempt( + job, + timeout_seconds=self.image_timeout_seconds, + ), + artifact_boundary=self.artifact_boundary, + lease_seconds=self.lease_seconds, + supervisor_lease_seconds=self.supervisor_lease_seconds, + auto_recover=False, + ) + self._image_health = self._probe_image_provider() + + @property + def scratch_available(self) -> bool: + return True + + @property + def image_transform_available(self) -> bool: + return self._image_health.available + + @property + def image_health(self) -> RuntimeHealth: + return self._image_health + + @staticmethod + def _probe_image_provider() -> RuntimeHealth: + provider = RecipeImageProvider() + try: + return provider.start( + RuntimeHealth.ready("The local image provider is being checked.") + ) + except Exception: + return RuntimeHealth.blocked( + "image_provider_unavailable", + "The fixed image transformation provider is unavailable.", + ) + finally: + try: + provider.stop() + except Exception: + pass + + def start_image_transform(self, request: RecipeImageRequest) -> ExecutionJob: + if not self.image_transform_available: + raise RecipeExecutionError("provider_unavailable") + return self._recipe.start_image_transform(request) + + def start_scratch(self, request: ScratchComputeRequest) -> ExecutionJob: + if not isinstance(request, ScratchComputeRequest): + raise TypeError("request must be a ScratchComputeRequest") + payload = self._scratch_payload(request) + job, created = self.repository.create_job( + job_id=uuid4().hex, + owner=request.owner, + request_id=request.request_id, + profile=SCRATCH_COMPUTE_PROFILE, + payload=payload, + ) + if not created: + if ( + job.profile != SCRATCH_COMPUTE_PROFILE + or self._canonical_payload(job.payload) != self._canonical_payload(payload) + ): + raise ScratchComputeError("request_conflict") + return job + self._launch_scratch(job.job_id, request) + return job + + def wait(self, job_id: str, *, timeout: float = 5.0) -> ExecutionJob: + if timeout < 0: + raise ValueError("timeout must be non-negative") + deadline = time.monotonic() + timeout + while True: + job = self.repository.get_job(job_id) + if job is None: + raise ValueError("execution job does not exist") + if job.status in TerminalExecutionStatus: + return job + if time.monotonic() >= deadline: + raise TimeoutError("execution job did not reach a terminal state") + time.sleep(0.005) + + def cancel(self, job_id: str, *, owner: str) -> ExecutionJob: + job = self.repository.get_job(job_id, owner=owner) + if job is None: + raise ValueError("execution job does not exist or is not owned by caller") + if job.profile == RECIPE_IMAGE_PROFILE: + return self._recipe.cancel(job_id, owner=owner) + if job.profile == SCRATCH_COMPUTE_PROFILE: + with self._scratch_lock: + event = self._scratch_cancel_events.get(job_id) + attempt = self._scratch_attempts.get(job_id) + if event is not None: + event.set() + if attempt is not None: + attempt.cancel() + return self.repository.request_cancel(job_id) + return self.repository.request_cancel(job_id) + + def startup_recover(self) -> list[str]: + if self._supervisor_lease_active: + return [] + self.repository.claim_supervisor_lease( + lease_owner=self._supervisor_owner, + ttl_seconds=self.supervisor_lease_seconds, + ) + self._supervisor_lease_active = True + recovered = self.repository.recover_expired_leases() + self.repository.expire_approvals() + self._recipe.recover_jobs(recovered) + for job_id in recovered: + job = self.repository.get_job(job_id) + if job is None or job.profile != SCRATCH_COMPUTE_PROFILE: + continue + self._recover_scratch(job) + return recovered + + def shutdown(self, *, timeout: float = 5.0) -> None: + if timeout < 0: + raise ValueError("timeout must be non-negative") + with self._scratch_lock: + events = list(self._scratch_cancel_events.values()) + attempts = list(self._scratch_attempts.values()) + threads = list(self._scratch_threads.values()) + for event in events: + event.set() + for attempt in attempts: + attempt.cancel() + deadline = time.monotonic() + timeout + for thread in threads: + thread.join(timeout=max(0.0, deadline - time.monotonic())) + self._recipe.shutdown(timeout=max(0.0, deadline - time.monotonic())) + if self._supervisor_lease_active: + self.repository.release_supervisor_lease(lease_owner=self._supervisor_owner) + self._supervisor_lease_active = False + + @staticmethod + def _canonical_payload(payload: Mapping[str, Any]) -> str: + return json.dumps(dict(payload), ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + @staticmethod + def _scratch_payload(request: ScratchComputeRequest) -> Mapping[str, str]: + return { + "schema_version": SCRATCH_PAYLOAD_SCHEMA, + "expression": request.expression.strip(), + } + + @staticmethod + def _scratch_request_from_job(job: ExecutionJob) -> ScratchComputeRequest: + payload = job.payload + if set(payload) != {"schema_version", "expression"} or payload.get( + "schema_version" + ) != SCRATCH_PAYLOAD_SCHEMA: + raise ScratchComputeError("recovery_invalid_payload") + expression = payload.get("expression") + if not isinstance(expression, str): + raise ScratchComputeError("recovery_invalid_payload") + try: + validate_scratch_expression(expression) + return ScratchComputeRequest( + owner=job.owner, + request_id=job.request_id, + expression=expression, + ) + except (ScratchComputeError, TypeError, ValueError): + raise ScratchComputeError("recovery_invalid_payload") from None + + def _recover_scratch(self, job: ExecutionJob) -> None: + if job.status in TerminalExecutionStatus: + return + if job.status == "cancelling": + try: + self.repository.transition( + job.job_id, + status="cancelled", + event="cancelled", + phase="recovery", + data={"message": "Safe computation cancellation was recovered."}, + error="cancelled", + ) + except Exception: + pass + return + try: + request = self._scratch_request_from_job(job) + except ScratchComputeError: + self._fail_scratch_recovery(job.job_id, "recovery_invalid_payload") + return + self._launch_scratch(job.job_id, request) + + def _launch_scratch(self, job_id: str, request: ScratchComputeRequest) -> None: + with self._scratch_lock: + existing = self._scratch_threads.get(job_id) + if existing is not None and existing.is_alive(): + return + event = self._scratch_cancel_events.setdefault(job_id, Event()) + thread = Thread( + target=self._run_scratch, + args=(job_id, request, event), + name=f"cortex-scratch-{job_id}", + daemon=True, + ) + self._scratch_threads[job_id] = thread + thread.start() + + def _run_scratch( + self, + job_id: str, + request: ScratchComputeRequest, + cancel_event: Event, + ) -> None: + lease_owner = f"scratch-coordinator-{uuid4().hex}" + attempt: _LocalScratchAttempt | None = None + with self._scratch_lock: + self._scratch_cancel_events[job_id] = cancel_event + try: + self.repository.claim_lease( + job_id, + lease_owner=lease_owner, + ttl_seconds=self.lease_seconds, + ) + current = self.repository.get_job(job_id) + if current is None: + return + if current.status == "cancelling" or cancel_event.is_set(): + self._cancel_scratch_before_start(job_id) + return + self.repository.transition( + job_id, + status="running", + event="started", + phase="prepare", + data={"message": "Safe computation started."}, + ) + self.repository.transition( + job_id, + status="running", + event="progress", + phase="worker", + data={"message": "Computing in an isolated local worker."}, + ) + attempt = _LocalScratchAttempt(timeout_seconds=self.scratch_timeout_seconds) + with self._scratch_lock: + self._scratch_attempts[job_id] = attempt + result = attempt.evaluate(request.expression, cancel_event) + current = self.repository.get_job(job_id) + if cancel_event.is_set() or (current is not None and current.status == "cancelling"): + raise ScratchComputeError("cancelled") + self.repository.transition( + job_id, + status="succeeded", + event="completed", + phase="completed", + data={"message": "Safe computation completed."}, + result=scratch_result_payload(result.value), + ) + except ScratchComputeError as exc: + self._finish_scratch_failure(job_id, cancel_event, exc.code) + except LeaseConflict: + self._fail_scratch_recovery(job_id, "lease_unavailable") + except Exception: + self._finish_scratch_failure(job_id, cancel_event, "coordinator_failed") + finally: + if attempt is not None: + attempt.close() + with self._scratch_lock: + self._scratch_attempts.pop(job_id, None) + self._scratch_cancel_events.pop(job_id, None) + self._scratch_threads.pop(job_id, None) + try: + self.repository.release_lease(job_id, lease_owner=lease_owner) + except Exception: + pass + + def _cancel_scratch_before_start(self, job_id: str) -> None: + self.repository.transition( + job_id, + status="cancelled", + event="cancelled", + phase="cancelled", + data={"message": "Safe computation cancelled before start."}, + error="cancelled", + ) + + def _finish_scratch_failure( + self, + job_id: str, + cancel_event: Event, + failure_code: str, + ) -> None: + current = self.repository.get_job(job_id) + cancelled = ( + failure_code == "cancelled" + or cancel_event.is_set() + or (current is not None and current.status == "cancelling") + ) + try: + self.repository.transition( + job_id, + status="cancelled" if cancelled else "failed", + event="cancelled" if cancelled else "failed", + phase="cancelled" if cancelled else "failed", + data={ + "message": ( + "Safe computation was cancelled." + if cancelled + else "Safe computation failed safely." + ) + }, + error="cancelled" if cancelled else failure_code, + ) + except Exception: + pass + + def _fail_scratch_recovery(self, job_id: str, code: str) -> None: + try: + self.repository.transition( + job_id, + status="failed", + event="failed", + phase="recovery", + data={"message": "Safe computation recovery metadata is invalid."}, + error=code, + ) + except Exception: + pass + + +__all__ = [ + "DEFAULT_IMAGE_TIMEOUT_SECONDS", + "DEFAULT_SCRATCH_TIMEOUT_SECONDS", + "LocalExecutionCoordinator", + "LocalRecipeWorkerAttempt", +] diff --git a/backend/cortex_backend/execution/qualification.py b/backend/cortex_backend/execution/qualification.py index e71eab4..dc9749c 100644 --- a/backend/cortex_backend/execution/qualification.py +++ b/backend/cortex_backend/execution/qualification.py @@ -1,20 +1,21 @@ -"""Explicit local qualification-profile lifecycle composition. +"""Explicit execution-profile lifecycle composition. -The normal Cortex application does not construct a runnable recipe lifecycle. -This module provides the deliberate development/CI seam for callers that have -already built the fixed worker, installed it with disposable qualification -trust, and supplied a coordinator that owns the worker boundary. +The normal application selects the checked-in ``local`` profile, which builds +the small process-backed scratch and fixed-image workers. ``qualification`` +remains a deliberate development/CI seam for callers that have already built +the signed native worker and supplied its release controls. -The profile is intentionally narrower than an official release profile: +The profiles are intentionally distinct: -* ``disabled`` is the default and never calls a health probe or coordinator; -* ``qualification`` requires the qualification release gate, an injected - coordinator factory, and a provider-health probe; and -* missing or malformed qualification wiring becomes a blocked lifecycle, not a - host-process fallback or an implicitly enabled provider. +* ``disabled`` never calls a health probe or coordinator; +* ``local`` has no external signer, reviewer, or broker requirement; and +* ``qualification`` requires its release gate, injected coordinator factory, + and provider-health probe. -This module does not create a process, bind a broker, load a provider, or read a -worker path. Those actions remain responsibilities of the injected controls. +Missing or malformed qualification wiring becomes a blocked lifecycle, not a +host-process fallback. The qualification helpers themselves do not create a +process, bind a broker, load a provider, or read a worker path; the local +profile owns its checked-in worker composition separately. """ from __future__ import annotations @@ -37,7 +38,7 @@ from .repository import ExecutionRepository -ExecutionProfile = Literal["disabled", "qualification"] +ExecutionProfile = Literal["disabled", "local", "qualification"] _SAFE_CODE = re.compile(r"^[a-z][a-z0-9._-]{0,63}$") @@ -162,6 +163,8 @@ def parse_execution_profile(value: str | None) -> ExecutionProfile: return "disabled" if value == "disabled": return "disabled" + if value == "local": + return "local" if value == "qualification": return "qualification" raise QualificationProfileError("execution_profile_invalid") @@ -185,6 +188,17 @@ def _qualification_configuration_health() -> RuntimeHealth: ) +def _local_health() -> RuntimeHealth: + """The local profile always retains its safe-compute fallback. + + Image support performs a second dependency probe in the local coordinator. + A missing optional image codec therefore disables only image transforms, + never normal chat or safe computation. + """ + + return RuntimeHealth.ready("Local safe-compute runtime is ready.") + + def _qualification_health(config: QualificationLifecycleConfig) -> RuntimeHealth: """Compose release and provider health without leaking internal details.""" @@ -228,9 +242,11 @@ def build_execution_lifecycle( """Build the only supported local profile boundary. ``profile=None`` and ``profile="disabled"`` construct the same inert - lifecycle. Selecting ``qualification`` is explicit and still fails closed - when its controls are absent or unhealthy. The returned lifecycle carries a - safe profile label for diagnostics. + lifecycle. ``local`` starts the checked-in bounded worker profiles with no + external signing or reviewer service. Selecting ``qualification`` remains + explicit and still fails closed when its additional controls are absent or + unhealthy. The returned lifecycle carries a safe profile label for + diagnostics. """ selected = parse_execution_profile(profile) @@ -245,6 +261,21 @@ def build_execution_lifecycle( profile="disabled", ) + if selected == "local": + if qualification is not None: + raise QualificationProfileError("qualification_config_with_local_profile") + # Import only when the normal local profile is requested. The disabled + # and qualification seams stay lightweight for documentation and CI. + from .local_runtime import LocalExecutionCoordinator + + return ExecutionLifecycle( + repository, + coordinator_factory=lambda target: LocalExecutionCoordinator(target), + health_check=_local_health, + enabled=True, + profile="local", + ) + if qualification is None: return ExecutionLifecycle( repository, diff --git a/backend/cortex_backend/execution/recipe_coordinator.py b/backend/cortex_backend/execution/recipe_coordinator.py index 7ebd052..235900a 100644 --- a/backend/cortex_backend/execution/recipe_coordinator.py +++ b/backend/cortex_backend/execution/recipe_coordinator.py @@ -10,7 +10,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping import base64 from dataclasses import dataclass from hashlib import sha256 @@ -656,7 +656,19 @@ def startup_recover(self) -> list[str]: self._supervisor_lease_active = True recovered = self.repository.recover_expired_leases() self.repository.expire_approvals() - for job_id in recovered: + self.recover_jobs(recovered) + return recovered + + def recover_jobs(self, recovered_job_ids: Iterable[str]) -> None: + """Resume only recipe jobs after a lifecycle owner reclaimed leases. + + The normal qualification lifecycle owns its supervisor lease itself. + A local composite runtime can instead claim that lease once and pass + the recovered IDs here, avoiding two independent supervisors racing + over the same durable store. + """ + + for job_id in recovered_job_ids: job = self.repository.get_job(job_id) if job is None or job.status in TerminalExecutionStatus: continue @@ -683,7 +695,6 @@ def startup_recover(self) -> list[str]: self._fail_recovery(job_id, "recovery_invalid_payload") continue self._launch(job_id, request) - return recovered def _fail_recovery(self, job_id: str, code: str) -> None: try: diff --git a/backend/cortex_backend/execution/recipe_provider.py b/backend/cortex_backend/execution/recipe_provider.py index 9b065a6..6d4aea4 100644 --- a/backend/cortex_backend/execution/recipe_provider.py +++ b/backend/cortex_backend/execution/recipe_provider.py @@ -1,10 +1,11 @@ -"""Qualification-only fixed-function image recipe provider. +"""Fixed-function image recipe provider. This module is a bounded transform core, not an execution route. It accepts validated ``ImageTransformPlan`` objects and immutable bytes, never paths or model source, and returns a new encoded image only after decoding and -re-validating the result. The provider starts only after an external sandbox -health result is available; no application lifecycle imports this module yet. +re-validating the result. The provider starts only after a caller supplies a +health result. The normal local runtime invokes it inside a short-lived worker +process; the qualification profile may supply a stricter external health gate. """ from __future__ import annotations @@ -368,7 +369,7 @@ def _encode_verified( class RecipeImageProvider: - """Fixed-function image provider that remains disabled until sandbox health passes.""" + """Fixed-function image provider that remains disabled until health passes.""" def __init__(self, limits: RecipeProviderLimits | None = None) -> None: self.limits = limits or RecipeProviderLimits() diff --git a/backend/cortex_backend/execution/scratch_compute.py b/backend/cortex_backend/execution/scratch_compute.py new file mode 100644 index 0000000..fe4318b --- /dev/null +++ b/backend/cortex_backend/execution/scratch_compute.py @@ -0,0 +1,369 @@ +"""A deliberately small, deterministic computation language for Cortex. + +This is not a Python, shell, or general-purpose code runner. It accepts one +math expression from a tightly bounded grammar, evaluates it with decimal +arithmetic, and exposes only a short rendered result. The worker-facing +function in this module has no filesystem, network, subprocess, package, or +environment API. +""" + +from __future__ import annotations + +import ast +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from decimal import Context, Decimal, DivisionByZero, InvalidOperation, Overflow, ROUND_HALF_EVEN, localcontext +import re +from typing import Any + + +SCRATCH_COMPUTE_PROFILE = "scratch.auto.v1" +SCRATCH_PAYLOAD_SCHEMA = "scratch.compute.v1" +SCRATCH_RESULT_SCHEMA = "scratch.result.v1" +MAX_EXPRESSION_CHARS = 512 +MAX_AST_NODES = 96 +MAX_AST_DEPTH = 16 +MAX_OPERATIONS = 64 +MAX_RESULT_ABS = Decimal("1e18") +MAX_DECIMAL_SCALE = 18 +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_AUTO_PREFIX = re.compile( + r"^\s*(?:calculate|compute|evaluate|solve|what\s+is|what['’]s|how\s+much\s+is)\s+(.+?)\s*[?.!]*\s*$", + re.IGNORECASE, +) + + +class ScratchComputeError(ValueError): + """A stable, non-sensitive safe-compute failure category.""" + + def __init__(self, code: str) -> None: + if _SAFE_CODE.fullmatch(code) is None: + raise ValueError("invalid scratch compute error code") + self.code = code + super().__init__("The safe computation could not be completed.") + + +@dataclass(frozen=True, slots=True) +class ScratchComputeRequest: + """One owner-scoped request; it carries no path or executable authority.""" + + owner: str + request_id: str + expression: str + + def __post_init__(self) -> None: + if not isinstance(self.owner, str) or _SAFE_ID.fullmatch(self.owner) is None: + raise ValueError("owner must be a bounded opaque identifier") + if not isinstance(self.request_id, str) or _SAFE_ID.fullmatch(self.request_id) is None: + raise ValueError("request_id must be a bounded opaque identifier") + validate_scratch_expression(self.expression) + + +@dataclass(frozen=True, slots=True) +class ScratchComputeResult: + """The bounded observation that can be shown to a person or a model.""" + + value: str + + def __post_init__(self) -> None: + if not isinstance(self.value, str) or not self.value or len(self.value) > 128: + raise ValueError("scratch result must be a bounded string") + + +CancellationCheck = Callable[[], bool] + + +def _check_cancel(cancel_check: CancellationCheck | None) -> None: + if cancel_check is None: + return + try: + if bool(cancel_check()): + raise ScratchComputeError("cancelled") + except ScratchComputeError: + raise + except Exception: + raise ScratchComputeError("cancellation_check_failed") from None + + +def _parse(expression: str) -> ast.Expression: + if ( + not isinstance(expression, str) + or not expression.strip() + or len(expression) > MAX_EXPRESSION_CHARS + or "\x00" in expression + ): + raise ScratchComputeError("expression_invalid") + try: + tree = ast.parse(expression, mode="eval") + except (SyntaxError, MemoryError, RecursionError): + raise ScratchComputeError("expression_invalid") from None + if not isinstance(tree, ast.Expression): # defensive for alternate AST producers + raise ScratchComputeError("expression_invalid") + nodes = list(ast.walk(tree)) + if len(nodes) > MAX_AST_NODES or _ast_depth(tree) > MAX_AST_DEPTH: + raise ScratchComputeError("expression_too_complex") + return tree + + +def _ast_depth(node: ast.AST) -> int: + children = list(ast.iter_child_nodes(node)) + return 1 + max((_ast_depth(child) for child in children), default=0) + + +def validate_scratch_expression(expression: str) -> str: + """Parse and structurally bound an expression without evaluating it.""" + + tree = _parse(expression) + _validate_structure(tree.body) + return expression.strip() + + +def _validate_structure(node: ast.AST) -> None: + """Reject every AST shape outside the small arithmetic language.""" + + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + raise ScratchComputeError("expression_not_allowed") + return + if isinstance(node, ast.UnaryOp): + if not isinstance(node.op, (ast.UAdd, ast.USub)): + raise ScratchComputeError("expression_not_allowed") + _validate_structure(node.operand) + return + if isinstance(node, ast.BinOp): + if not isinstance( + node.op, + (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow), + ): + raise ScratchComputeError("expression_not_allowed") + _validate_structure(node.left) + _validate_structure(node.right) + return + if isinstance(node, ast.Call): + if ( + not isinstance(node.func, ast.Name) + or node.keywords + or node.func.id not in {"abs", "sqrt", "min", "max", "round"} + or not node.args + or len(node.args) > 16 + ): + raise ScratchComputeError("expression_not_allowed") + _validate_structure(node.func) + for argument in node.args: + _validate_structure(argument) + return + if isinstance(node, ast.Name) and node.id in {"abs", "sqrt", "min", "max", "round"}: + return + raise ScratchComputeError("expression_not_allowed") + + +class _Evaluator: + def __init__(self, cancel_check: CancellationCheck | None) -> None: + self._cancel_check = cancel_check + self._operations = 0 + + def _operation(self) -> None: + _check_cancel(self._cancel_check) + self._operations += 1 + if self._operations > MAX_OPERATIONS: + raise ScratchComputeError("operation_limit") + + def evaluate(self, node: ast.AST) -> Decimal: + self._operation() + if isinstance(node, ast.Constant): + return self._constant(node.value) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = self.evaluate(node.operand) + return _bounded(value if isinstance(node.op, ast.UAdd) else -value) + if isinstance(node, ast.BinOp): + return self._binary(node) + if isinstance(node, ast.Call): + return self._call(node) + raise ScratchComputeError("expression_not_allowed") + + @staticmethod + def _constant(value: object) -> Decimal: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ScratchComputeError("expression_not_allowed") + try: + converted = Decimal(str(value)) + except (InvalidOperation, ValueError): + raise ScratchComputeError("number_invalid") from None + return _bounded(converted) + + def _binary(self, node: ast.BinOp) -> Decimal: + left = self.evaluate(node.left) + right = self.evaluate(node.right) + try: + with localcontext(_decimal_context()): + if isinstance(node.op, ast.Add): + value = left + right + elif isinstance(node.op, ast.Sub): + value = left - right + elif isinstance(node.op, ast.Mult): + value = left * right + elif isinstance(node.op, ast.Div): + value = left / right + elif isinstance(node.op, ast.FloorDiv): + value = left // right + elif isinstance(node.op, ast.Mod): + value = left % right + elif isinstance(node.op, ast.Pow): + if right != right.to_integral_value() or not -12 <= int(right) <= 12: + raise ScratchComputeError("exponent_out_of_bounds") + value = left ** int(right) + else: + raise ScratchComputeError("expression_not_allowed") + except ScratchComputeError: + raise + except (DivisionByZero, InvalidOperation, Overflow, ValueError, ZeroDivisionError): + raise ScratchComputeError("arithmetic_failed") from None + return _bounded(+value) + + def _call(self, node: ast.Call) -> Decimal: + if not isinstance(node.func, ast.Name) or node.keywords: + raise ScratchComputeError("expression_not_allowed") + name = node.func.id + values = tuple(self.evaluate(argument) for argument in node.args) + try: + with localcontext(_decimal_context()): + if name == "abs" and len(values) == 1: + value = abs(values[0]) + elif name == "sqrt" and len(values) == 1 and values[0] >= 0: + value = values[0].sqrt() + elif name == "min" and 2 <= len(values) <= 16: + value = min(values) + elif name == "max" and 2 <= len(values) <= 16: + value = max(values) + elif name == "round" and len(values) in {1, 2}: + places = 0 + if len(values) == 2: + if values[1] != values[1].to_integral_value(): + raise ScratchComputeError("rounding_invalid") + places = int(values[1]) + if not 0 <= places <= 12: + raise ScratchComputeError("rounding_invalid") + value = values[0].quantize( + Decimal("1").scaleb(-places), rounding=ROUND_HALF_EVEN + ) + else: + raise ScratchComputeError("expression_not_allowed") + except ScratchComputeError: + raise + except (DivisionByZero, InvalidOperation, Overflow, ValueError): + raise ScratchComputeError("arithmetic_failed") from None + return _bounded(+value) + + +def _decimal_context() -> Context: + return Context(prec=34, Emax=24, Emin=-MAX_DECIMAL_SCALE) + + +def _bounded(value: Decimal) -> Decimal: + if not value.is_finite() or abs(value) > MAX_RESULT_ABS: + raise ScratchComputeError("result_out_of_bounds") + try: + if value.as_tuple().exponent < -MAX_DECIMAL_SCALE: + value = value.quantize( + Decimal("1").scaleb(-MAX_DECIMAL_SCALE), + rounding=ROUND_HALF_EVEN, + context=_decimal_context(), + ) + except (InvalidOperation, ValueError): + raise ScratchComputeError("result_precision_exceeded") from None + if len(value.as_tuple().digits) > 34: + raise ScratchComputeError("result_precision_exceeded") + return value + + +def _render(value: Decimal) -> str: + if value == 0: + return "0" + rendered = format(value.normalize(), "f") + if "." in rendered: + rendered = rendered.rstrip("0").rstrip(".") + if len(rendered) > 128: + raise ScratchComputeError("result_precision_exceeded") + return rendered + + +def evaluate_scratch_expression( + expression: str, + *, + cancel_check: CancellationCheck | None = None, +) -> ScratchComputeResult: + """Evaluate one expression with no host capabilities or dynamic execution.""" + + tree = _parse(expression) + evaluator = _Evaluator(cancel_check) + value = evaluator.evaluate(tree.body) + _check_cancel(cancel_check) + return ScratchComputeResult(value=_render(value)) + + +def extract_automatic_expression(prompt: str) -> str | None: + """Return an unambiguous user-requested computation, if one is present. + + Automatic use deliberately requires an explicit computation verb. Natural + language is not guessed into a program, and invalid expressions simply + fall back to ordinary chat. + """ + + if not isinstance(prompt, str) or len(prompt) > 100_000: + return None + match = _AUTO_PREFIX.match(prompt) + if match is None: + return None + candidate = match.group(1).strip() + try: + validate_scratch_expression(candidate) + except ScratchComputeError: + return None + return candidate + + +def scratch_worker_main( + connection: Any, + cancel_event: Any, + expression: str, +) -> None: + """Process entrypoint. It returns a compact, redacted message only.""" + + try: + result = evaluate_scratch_expression( + expression, + cancel_check=lambda: bool(cancel_event.is_set()), + ) + connection.send({"ok": True, "value": result.value}) + except ScratchComputeError as exc: + connection.send({"ok": False, "code": exc.code}) + except Exception: + connection.send({"ok": False, "code": "worker_failed"}) + finally: + try: + connection.close() + except Exception: + pass + + +def scratch_result_payload(value: str) -> Mapping[str, str]: + """Return the fixed durable result schema for a completed computation.""" + + return {"schema_version": SCRATCH_RESULT_SCHEMA, "value": value} + + +__all__ = [ + "MAX_EXPRESSION_CHARS", + "SCRATCH_COMPUTE_PROFILE", + "SCRATCH_PAYLOAD_SCHEMA", + "SCRATCH_RESULT_SCHEMA", + "ScratchComputeError", + "ScratchComputeRequest", + "ScratchComputeResult", + "evaluate_scratch_expression", + "extract_automatic_expression", + "scratch_result_payload", + "scratch_worker_main", + "validate_scratch_expression", +] diff --git a/contracts/cortex-api.ts b/contracts/cortex-api.ts index d5c192e..9f3f7fb 100644 --- a/contracts/cortex-api.ts +++ b/contracts/cortex-api.ts @@ -89,6 +89,7 @@ export interface CortexSettings { onboarding?: OnboardingSettings; models?: ModelSettings; generation?: GenerationSettings; + execution?: ExecutionSettings; memory?: MemorySettings; translation?: TranslationSettings; suggestions?: SuggestionSettings; @@ -138,6 +139,10 @@ export interface ExecutionPreviewRequest { step_delay_seconds?: number; } +export interface ExecutionSettings { + automatic_compute?: boolean; +} + export interface ExecutionStatusResponse { job_id: string; request_id: string; @@ -330,6 +335,19 @@ export interface SSEEvent { data?: Record; } +export interface ScratchComputeAccepted { + job_id: string; + request_id: string; + profile: "scratch.auto.v1"; + status: "queued" | "running" | "cancelling" | "succeeded" | "failed" | "cancelled"; + sequence: number; +} + +export interface ScratchComputeRequest { + request_id: string; + expression: string; +} + export interface SessionExchangeRequest { bootstrap_token: string; } @@ -377,6 +395,8 @@ export interface SystemResponse { preview?: boolean; session_required?: boolean; execution_preview_available?: boolean; + scratch_compute_available?: boolean; + image_transform_available?: boolean; started_at: string; ollama_host?: string; ollama_setup_url?: string; diff --git a/contracts/openapi.json b/contracts/openapi.json index 82f7378..243df2f 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -448,6 +448,9 @@ "appearance": { "$ref": "#/components/schemas/AppearanceSettings" }, + "execution": { + "$ref": "#/components/schemas/ExecutionSettings" + }, "generation": { "$ref": "#/components/schemas/GenerationSettings" }, @@ -790,6 +793,19 @@ "title": "ExecutionSSEEvent", "type": "object" }, + "ExecutionSettings": { + "additionalProperties": false, + "description": "Small, user-visible controls for the bounded local execution tools.", + "properties": { + "automatic_compute": { + "default": true, + "title": "Automatic Compute", + "type": "boolean" + } + }, + "title": "ExecutionSettings", + "type": "object" + }, "ExecutionStatusResponse": { "additionalProperties": false, "properties": { @@ -1948,6 +1964,74 @@ "title": "SSEEvent", "type": "object" }, + "ScratchComputeAccepted": { + "additionalProperties": false, + "properties": { + "job_id": { + "title": "Job Id", + "type": "string" + }, + "profile": { + "const": "scratch.auto.v1", + "title": "Profile", + "type": "string" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "sequence": { + "title": "Sequence", + "type": "integer" + }, + "status": { + "enum": [ + "queued", + "running", + "cancelling", + "succeeded", + "failed", + "cancelled" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "job_id", + "request_id", + "profile", + "status", + "sequence" + ], + "title": "ScratchComputeAccepted", + "type": "object" + }, + "ScratchComputeRequest": { + "additionalProperties": false, + "description": "One bounded expression, never Python source or a shell command.", + "properties": { + "expression": { + "maxLength": 512, + "minLength": 1, + "title": "Expression", + "type": "string" + }, + "request_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expression" + ], + "title": "ScratchComputeRequest", + "type": "object" + }, "SessionExchangeRequest": { "additionalProperties": false, "properties": { @@ -2174,6 +2258,11 @@ "title": "Execution Preview Available", "type": "boolean" }, + "image_transform_available": { + "default": false, + "title": "Image Transform Available", + "type": "boolean" + }, "ollama_host": { "default": "http://127.0.0.1:11434", "title": "Ollama Host", @@ -2189,6 +2278,11 @@ "title": "Preview", "type": "boolean" }, + "scratch_compute_available": { + "default": false, + "title": "Scratch Compute Available", + "type": "boolean" + }, "session_required": { "default": true, "title": "Session Required", @@ -2619,6 +2713,44 @@ "summary": "Diagnostics" } }, + "/api/v1/execution/artifacts/{artifact_id}": { + "get": { + "description": "Return one owner-scoped, integrity-checked result artifact.\n\nThe UI receives bytes only after the repository re-checks retention,\nlocation, size, and digest. Neither the artifact path nor the source\nfilename is exposed to the browser.", + "operationId": "download_execution_artifact_api_v1_execution_artifacts__artifact_id__get", + "parameters": [ + { + "in": "path", + "name": "artifact_id", + "required": true, + "schema": { + "title": "Artifact Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download Execution Artifact" + } + }, "/api/v1/execution/attachments": { "post": { "description": "Stage one bounded attachment for a qualified recipe request.\n\nThe route accepts only a bounded base64 envelope. Decoded bytes pass\nthrough the same owner-scoped artifact boundary used by recipe output;\nno caller path, filename, or executable instruction is accepted.", @@ -2735,6 +2867,45 @@ "summary": "Start Recipe Image Transform" } }, + "/api/v1/execution/scratch": { + "post": { + "description": "Start one bounded calculation in the local worker profile.", + "operationId": "start_scratch_compute_api_v1_execution_scratch_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScratchComputeRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScratchComputeAccepted" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Start Scratch Compute" + } + }, "/api/v1/execution/tasks": { "get": { "operationId": "execution_tasks_api_v1_execution_tasks_get", diff --git a/docs/OPEN_SOURCE_EXECUTION_PLAN.md b/docs/OPEN_SOURCE_EXECUTION_PLAN.md new file mode 100644 index 0000000..20cef15 --- /dev/null +++ b/docs/OPEN_SOURCE_EXECUTION_PLAN.md @@ -0,0 +1,123 @@ +# Cortex open-source execution plan + +- **Status:** Complete for the source-checkout scope; this remains the authoritative delivery plan for the open-source desktop app. +- **Scope:** Local model assistance, bounded computation, and user-requested image transformations. +- **Non-goal:** Turning Cortex into a terminal, IDE, autonomous coding agent, or hosted service. + +## Plain-English decision + +Cortex is a local Windows desktop app. The product should do three useful things: + +1. keep ordinary chat fast and reliable; +2. verify a small, safe computation when it materially improves an answer; and +3. let a user make a common change to an uploaded image. + +The execution feature must be safe enough for untrusted model-generated input, but it +does not need bank-style release bureaucracy. A contributor can clone the repository, +run the normal local profile, and improve the app without an external reviewer, +production signing key, attestation service, or paid infrastructure. + +When the runtime reports `blocked`, a specific optional capability is unavailable and +the app has failed closed. Ordinary chat remains available; this is not an unfinished +roadmap phase. + +## Delivery status + +| Workstream | Status | Meaning | +| --- | --- | --- | +| Chat and model-management UX | Complete | Model-pull progress and status are implemented and tested. | +| Durable execution foundation | Complete | Jobs, events, artifacts, cancellation, recovery, and task-tray status are implemented and tested. | +| Fixed image-recipe safety boundary | Complete | Attachment staging, typed plans, output validation, cancellation, retention, ownership, and cleanup are in place. | +| Automatic scratch computation | Complete | `scratch.auto.v1` evaluates a small decimal-expression language in a short-lived local worker. Explicit requests such as "calculate 9 * 9" are verified before generation, and a setting can disable this behavior. | +| User-facing attachment transformations | Complete | The normal app now stages a user-selected PNG/JPEG/WebP, runs a fixed transform in a local worker, shows progress/Stop/error state, and provides a result download. | +| Workspace mutation and network access | Deferred | Not needed for the product goal; each would require a separate design and explicit user permission. | + +## Delivery record and remaining product work + +### 1. Narrow scratch-compute MVP — implemented + +The shipped tool is a small expression language, not arbitrary Python, shell, or +WebAssembly source. It runs in a short-lived local worker process and supports bounded +decimal arithmetic plus `abs`, `sqrt`, `min`, `max`, and `round`. The app recognizes +unambiguous requests such as "calculate 12 * (3 + 4)", waits a short bounded time for a +verified result, and gives that observation to the local model. The General settings +toggle can disable automatic computation. + +Implemented protections: + +- no filesystem, secrets, network, shell, subprocess, package-install, or persistence API; +- bounded expression size, AST depth, operation count, precision, input, output, and wall time; +- durable progress, Stop/cancel, timeout, and clean worker shutdown; +- typed requests and bounded observations, never raw paths or tracebacks; and +- ordinary text chat when the tool is unavailable. + +The scratch tool cannot read attachments or modify files. It is intentionally not a +general coding agent. + +### 2. Normal image transformation flow — implemented + +The composer now has an Image transformation control. A user chooses a PNG, JPEG, or +WebP file and a fixed operation such as grayscale, contrast, or brightness. The UI +shows an active spinner and a human-readable phase, offers Stop while work is active, +surfaces a safe failure message, previews the result, and downloads an owner-scoped +artifact. Known image operations remain fixed recipes rather than generated code. + +### 3. Final product hardening — source checkout complete + +The source checkout passes the full Python and frontend suites, lint, typecheck, and +production frontend build. Before distributing a Windows binary, run the existing +package smoke check and a brief manual Windows verification for the two shipped +profiles. Keep this as a short release checklist; it is not a separate security-review, +signing, or service-integration project. + +## Current implementation evidence + +- `python -m pytest -q`: **345 passed, 1 skipped**. +- `npm.cmd run lint --prefix frontend`: passed. +- `npm.cmd run typecheck --prefix frontend`: passed. +- `npm.cmd test --prefix frontend -- --run`: **43 passed**. +- `npm.cmd run build --prefix frontend`: passed. + +## Safety that is mandatory + +These controls directly protect users from malformed files and untrusted model output: + +- short-lived execution outside the chat/backend process; +- no filesystem, network, shell, package, or user-path authority exposed through the worker contract; +- bounded resources and reliable cancellation; +- byte-derived input/output validation and atomic publication; and +- visible status, errors, and recovery behavior. + +The local worker is a practical containment boundary, not an operating-system security +sandbox or a claim of arbitrary-code isolation. The existing Phase 0/Phase 2 +qualification code supplies additional implementation evidence, not a list of separate +product launches. + +## Explicitly optional or out of scope + +The following are **not** blockers for the open-source checkout or normal local profile: + +- outside security review or an attestation authority; +- a production signing key, pinned public release root, or signed prebuilt worker; +- updater/rollback drills for an official installer; +- external red-team certification; +- Hyper-V, remote attestation, cloud workers, a workflow service, or a broker service; +- workspace write/commit support; and +- network egress. + +Maintainers may add release hardening before distributing a trusted prebuilt binary, +but those controls must not be mixed into the contributor/product roadmap. + +## Definition of done for the current goal - complete + +The current goal is complete when: + +1. ordinary chat remains usable if execution is disabled or fails; +2. the model can safely use scratch computation for explicit math requests when it improves an answer; +3. a user can transform an uploaded image through the normal UI; +4. progress, cancellation, errors, and results are understandable; and +5. focused safety and reliability tests pass on the supported Windows matrix. + +The detailed capability ADRs remain useful technical reference. If they conflict with +this delivery scope, this document controls sequencing and what is considered a project +blocker. diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index f2d7b01..0d6ff09 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -1,11 +1,40 @@ # ADR-0001: Capability-tiered agentic execution harness -- **Status:** Proposed +- **Status:** Active — open-source product plan; Phases 0–2 qualification complete, Phases 3–4 pending - **Date:** 2026-07-21 - **Decision owners:** Cortex maintainers - **Scope:** Local model tool use, background computation, artifact transformation, and execution-job reliability - **Target platform:** Windows 10 and later, preserving Cortex's local-first one-folder distribution +> **Project-plan correction (2026-07-29):** The concise [open-source execution plan](../OPEN_SOURCE_EXECUTION_PLAN.md) +> is the authoritative delivery sequence. This ADR remains the detailed technical +> reference for the safety boundaries and qualification evidence. External review, +> production signing, release attestation, updater drills, and similar official-release +> controls are optional hardening; they are not contributor or open-source product gates. + +## Complexity audit (2026-07-29) + +The original design mixed three different concerns: user-facing product work, +runtime safety, and official binary-release trust. That made every small feature +look like a release-gate project. The split is now explicit: + +- **Keep in the product:** an out-of-process worker, no ambient host authority, + hard resource limits, cancellation, typed input/output validation, atomic + artifacts, and visible progress/errors. These directly protect users from + untrusted model output and hostile files. +- **Keep as qualification evidence, not separate roadmap stages:** the existing + broker, AppContainer/Job Object, worker, artifact, and deterministic corpus + checks. They prove the safety boundary; they do not each require a new product + launch or outside organization. +- **Remove from the open-source critical path:** external review, production + signing, pinned release roots, release attestation, updater drills, remote + attestation, cloud/workflow infrastructure, and network/workspace support. + These are optional official-release hardening or explicitly deferred capabilities. + +The concise open-source plan contains the only remaining product stages. This ADR's +long technical sections and historical qualification records should not be read as +additional TODO items. + ## Decision summary Cortex will add a durable, policy-mediated execution harness that lets a model use @@ -920,7 +949,13 @@ add external security review, production signing, pinned trust roots, updater dr and release-specific red-team evidence; it must never be presented as a prerequisite for ordinary open-source development. -## Rollout plan +## Technical qualification history and capability rollout + +The phases below preserve the architecture's traceability. Phase 0 and Phase 1 are +closed, and the Phase 2 qualification boundary is closed through the durable +coordinator. Do not count each sub-control or supporting ADR as another product +phase; use [the open-source execution plan](../OPEN_SOURCE_EXECUTION_PLAN.md) for +remaining work. ### Phase 0 — threat model and executable spikes @@ -1046,10 +1081,10 @@ is implied by acceptance of this ADR. ## Success criteria -- At least 99.5% of qualified scratch attempts reach a terminal state without orphaned - process/staging residue in soak tests. -- Cancellation reaches a terminal state and kills the process tree within a defined - percentile target established in Phase 0. +- Qualified attempts reach a terminal state without known orphaned process or staging + residue in the focused reliability suite. +- Cancellation reaches a terminal state and reaps the worker within the configured + bounded cancellation window. - Crash-recovery tests produce no duplicate assistant message or artifact publication. - Sandbox tests show zero unauthorized host-file, registry, handle, process, or network access across the supported Windows matrix. diff --git a/docs/adr/0001-phase1-api-contract.md b/docs/adr/0001-phase1-api-contract.md index 7050da7..7507e72 100644 --- a/docs/adr/0001-phase1-api-contract.md +++ b/docs/adr/0001-phase1-api-contract.md @@ -163,8 +163,8 @@ connection as reconnectable rather than as success or failure. allow, sensitive scope/payload data is redacted, and the actionable card is keyboard-native without taking focus. -This contract does not close Phase 1. Fake preview, replay, task tray, recovery, -approval-decision transport, and installation-principal wiring are implemented. -The production app's control-plane lifecycle and recovery gate are now wired, but -provider lifecycle integration remains separately reviewed; real code execution is -still unavailable. +This contract closes the Phase 1 control-plane slice. Fake preview, replay, task +tray, recovery, approval-decision transport, installation-principal wiring, and +the production app's lifecycle/recovery gate are implemented. Real provider +execution remains a later, separately qualified capability and is intentionally +unavailable through this fake-only contract. diff --git a/docs/adr/0001-phase1-evidence.md b/docs/adr/0001-phase1-evidence.md index e390cb9..53b382a 100644 --- a/docs/adr/0001-phase1-evidence.md +++ b/docs/adr/0001-phase1-evidence.md @@ -1,7 +1,7 @@ # ADR-0001 Phase 1 evidence log - **Phase:** 1 — durable jobs, artifacts, and UI with a fake executor -- **Status:** Production lifecycle control-plane slice complete; overall Phase 1 remains in progress +- **Status:** Complete; the Phase 1 durable control-plane exit gate is met - **Scope:** Durable execution workflow only; no model-generated code, guest runtime, recipe provider, or host subprocess is enabled. - **Source decision:** [ADR-0001](0001-capability-tiered-agentic-execution-harness.md) @@ -24,7 +24,7 @@ failures can be exhausted without executing code. | Task tray/accessibility/approvals | **Complete (Phase 1 slice)** | Global tray has an owner-scoped task list, polite live region, visible phase/status text, keyboard Stop action, active-work spinner, and a non-modal pending-approval card with safe reason/profile/expiry plus Allow once/Deny controls. Decisions are exact-job, owner-scoped, expiry-gated, and never launch a provider. | | Cancellation and recovery | **Complete (backend + startup supervisor)** | Cooperative fake cancellation, terminal cancellation, per-job lease recovery, single-instance supervisor exclusion/reclaim, startup rehydration, approval expiry, and malformed-payload fail-closed behavior are covered; process/runtime cancellation is deferred to later phases. | | Deterministic fake provider | **Complete (backend slice)** | `fake.v1` emits fixed prepare/progress/completion/failure/cancellation outcomes and never accepts source, paths, network, or host-process controls. | -| Phase 1 exit gate | **Blocked** | Durable backend, authenticated fake-only preview API, SSE replay, task tray, approval persistence/enforcement/UI/API, startup supervisor, and installation-principal wiring are green. Production lifecycle/recovery integration remains. | +| Phase 1 exit gate | **Complete** | Durable backend, authenticated fake-only preview API, SSE replay, task tray, approval persistence/enforcement/UI/API, startup supervisor, installation-principal wiring, and lifecycle/recovery integration are green. Real providers remain intentionally outside the fake-executor phase. | ## Cross-check findings @@ -123,6 +123,7 @@ npm.cmd test --prefix frontend -- --run 123 tests with one pre-existing `pytest-asyncio` deprecation warning. Frontend lint, typecheck, production build (`tsc -b` + Vite), and all 39 component tests passed. Generated OpenAPI/TypeScript contracts are current and `git diff --check` passed. -Phase 1 control-plane lifecycle/recovery is complete; provider implementation, -broker ACL/framing, external review, and release enablement remain separate gates. -This stage does not enable production code execution. +Phase 1 is closed. Provider implementation and broker/worker execution belong to +the later fixed-recipe and scratch-compute workstreams; external review, production +signing, and official release enablement are optional hardening. This stage does not +enable arbitrary code execution. diff --git a/docs/adr/0001-phase1-production-lifecycle.md b/docs/adr/0001-phase1-production-lifecycle.md index 233079d..603b5c0 100644 --- a/docs/adr/0001-phase1-production-lifecycle.md +++ b/docs/adr/0001-phase1-production-lifecycle.md @@ -68,9 +68,8 @@ execution unavailable. Stop is idempotent for disabled or already-stopped lifecy ## Verification `tests/test_phase1_execution_lifecycle.py` covers disabled, health-blocked, healthy -startup/recovery/shutdown, and redacted factory-failure paths. Existing repository, -API, restart, approval, frontend, and Phase 0 contract tests remain required before -this stage can be merged. +startup/recovery/shutdown, and redacted factory-failure paths. The repository, API, +restart, approval, frontend, and Phase 0 contract suites passed for this stage. This ADR does not authorize a production execution provider. Provider implementation, broker ACL/framing, staging/publish validation, external review, and release enablement diff --git a/docs/adr/0001-phase1-recovery-approval.md b/docs/adr/0001-phase1-recovery-approval.md index fc21554..2d75f13 100644 --- a/docs/adr/0001-phase1-recovery-approval.md +++ b/docs/adr/0001-phase1-recovery-approval.md @@ -86,6 +86,6 @@ Phase 0 Job Object evidence. 7. Owner-scoped API responses expose approval state but never lease-owner, supervisor-token, payload, or path fields. -This contract keeps the Phase 1 gate closed until these tests pass and the evidence -log is updated. It does not authorize Wasmtime, host subprocesses, workspace, or -network execution. +These tests and the evidence log close the Phase 1 control-plane gate. This contract +does not authorize Wasmtime, host subprocesses, workspace, or network execution; +those are later capabilities with their own focused qualification work. diff --git a/docs/adr/0001-phase2-broker-contract.md b/docs/adr/0001-phase2-broker-contract.md index 04a39d7..13f93f0 100644 --- a/docs/adr/0001-phase2-broker-contract.md +++ b/docs/adr/0001-phase2-broker-contract.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 authenticated broker contract -- **Status:** Contract implemented and verified; native transport/provider enablement remains blocked +- **Status:** Contract implemented and verified; the qualification profile consumes it and the normal app remains default-off - **Parent:** [Phase 2 signed-manifest gate](0001-phase2-signed-manifest.md) - **Scope:** Bounded authenticated frames, canonical broker messages, direction keys, peer ACL/identity policy, and installation/job ownership authorization diff --git a/docs/adr/0001-phase2-bundle-installation.md b/docs/adr/0001-phase2-bundle-installation.md index f5bbc83..4f1b460 100644 --- a/docs/adr/0001-phase2-bundle-installation.md +++ b/docs/adr/0001-phase2-bundle-installation.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 signed bundle installation and recovery -- **Status:** Implemented and verified; provider, codec, and execution enablement remain blocked +- **Status:** Implemented and verified as a qualification-only installation boundary; normal app enablement remains a product-integration decision - **Parent:** [Phase 2 signed recipe manifest and rollback gate](0001-phase2-signed-manifest.md) - **Scope:** Verified bundle staging, atomic activation, durable keyring rotation, and explicit recovery diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index bfcd83f..3a68000 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -10,6 +10,11 @@ - **Qualification ADR:** [Phase 2 packaged worker release qualification](0001-phase2-worker-release-qualification.md) - **Review ADR:** [Phase 2 external release-review attestation](0001-phase2-external-review-attestation.md) +> This file is a historical qualification record, not a list of open tasks. The +> current product sequence is maintained in [the open-source execution plan](../OPEN_SOURCE_EXECUTION_PLAN.md). +> Older paragraphs may use `blocked` for a fail-closed result observed at that time; +> the current status is the header and stage checklist above. + ## Stage checklist | Deliverable | Status | Evidence | @@ -38,7 +43,7 @@ | OS sandbox provider and provider-produced image outputs | **Complete (qualification; default-off in the app)** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, `input_chunk`, `input_complete`, `collect_output`, hostile decoder rejection, in-flight cancellation acknowledgement, artifact security review, resource accounting, watchdog tree reaping, and the durable packaged-worker coordinator run are qualified. Official-release review/signing remains optional hardening. | | Explicit qualification-profile lifecycle wiring | **Complete (local/CI composition; default-off in the app)** | `build_execution_lifecycle()` accepts only exact `disabled`/`qualification` selection, requires a qualification release gate, coordinator factory, and provider-health probe, and composes them in a fail-closed order. `Cortex_Preview.build_preview_app` exposes this only as an explicit injection; the normal app supplies no profile or controls. | | Durable recipe coordinator/request, attachment staging, and artifact publication | **Complete (qualification-only API; default-off in the app)** | `RecipeExecutionCoordinator` persists only opaque artifact IDs and canonical plan digests, enforces owner-scoped input reads and idempotency conflicts, leases/recoveries/cancels attempts, validates worker envelopes/chunks, and publishes exactly one output through `ArtifactBoundary.collect_outputs`. `POST /api/v1/execution/attachments` creates an idempotent owner-scoped `attachment.stage.v1` record through `ArtifactBoundary.stage_bytes`; `POST /api/v1/execution/recipe/image` consumes its opaque artifact ID after a ready `qualification` lifecycle. `build_native_recipe_coordinator_factory` binds a fresh signed/native launcher/broker/client attempt per job without host fallback. `tests/test_phase2_recipe_coordinator.py`, `tests/test_phase2_recipe_api.py`, `tests/test_phase2_attachment_staging.py`, `tests/test_phase2_native_recipe_attempt.py`, and `tests/test_phase2_qualification_lifecycle.py` cover the hostile, cancellation, API, native-composition, and lifecycle paths. | -| Durable packaged-worker coordinator qualification | **Complete (local + hosted CI; default-off)** | `recipe_coordinator_e2e_qualification.py --json --strict --timeout-seconds 300` passed on Windows using an in-memory ephemeral signing key and a disposable immutable installer generation. The single coordinator corpus staged a PNG attachment, completed a real signed/native transform, verified digest/MIME/size and atomic publication, rejected a foreign owner, expired and purged a one-second artifact, cancelled an in-flight transform with no result artifact, and verified native worker/process and temporary-root cleanup. Final Quality run [30466976650](https://github.com/dovvnloading/Cortex/actions/runs/30466976650), job [90627154917](https://github.com/dovvnloading/Cortex/actions/runs/30466976650/job/90627154917), passed on commit `0c7b350` in 4m15s after the packaged worker release qualification. | +| Durable packaged-worker coordinator qualification | **Complete (local + hosted CI; default-off)** | `recipe_coordinator_e2e_qualification.py --json --strict --timeout-seconds 300` passed on Windows using an in-memory ephemeral signing key and a disposable immutable installer generation. The single coordinator corpus staged a PNG attachment, completed a real signed/native transform, verified digest/MIME/size and atomic publication, rejected a foreign owner, expired and purged a one-second artifact, cancelled an in-flight transform with no result artifact, and verified native worker/process and temporary-root cleanup. Final Quality run [30467455657](https://github.com/dovvnloading/Cortex/actions/runs/30467455657), job [90628811567](https://github.com/dovvnloading/Cortex/actions/runs/30467455657/job/90628811567), passed on commit `c022f51` in 4m12s after the packaged worker release qualification. | ## Security invariants @@ -346,6 +351,7 @@ publication, rejected a foreign-owner request before worker launch, expired and purged a one-second attachment, cancelled an in-flight resize transform with no published result, and verified native process plus temporary workspace cleanup. The probe remains qualification-only and does not enable the normal application; -hosted Quality CI run [30466976650](https://github.com/dovvnloading/Cortex/actions/runs/30466976650) -and job [90627154917](https://github.com/dovvnloading/Cortex/actions/runs/30466976650/job/90627154917) -passed the same gate on commit `0c7b350` in 4m15s. +hosted Quality CI run [30467455657](https://github.com/dovvnloading/Cortex/actions/runs/30467455657) +and job [90628811567](https://github.com/dovvnloading/Cortex/actions/runs/30467455657/job/90628811567) +passed the same gate on commit `c022f51` in 4m12s. PR #64 containing this +qualification slice is now merged. diff --git a/docs/adr/0001-phase2-native-broker.md b/docs/adr/0001-phase2-native-broker.md index 6c5ddc9..3467f27 100644 --- a/docs/adr/0001-phase2-native-broker.md +++ b/docs/adr/0001-phase2-native-broker.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 native broker adapter -- **Status:** Transport, launcher identity binder, and worker-side client loop implemented and verified; signed-worker end-to-end enablement remains blocked +- **Status:** Transport, launcher identity binder, worker-side client loop, and signed-worker end-to-end qualification implemented and verified; normal app remains default-off - **Parent:** [Phase 2 authenticated broker contract](0001-phase2-broker-contract.md) - **Scope:** Windows named-pipe transport, protected DACL, OS peer identity, and authenticated session-key establishment diff --git a/docs/adr/0001-phase2-native-launcher.md b/docs/adr/0001-phase2-native-launcher.md index a003669..b5f05df 100644 --- a/docs/adr/0001-phase2-native-launcher.md +++ b/docs/adr/0001-phase2-native-launcher.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 disposable native launcher qualification -- **Status:** Win32 suspended factory and broker identity binder qualified; signed-worker end-to-end execution remains blocked +- **Status:** Win32 suspended factory, broker identity binder, and signed-worker end-to-end qualification complete; official release trust is optional hardening - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Windows sandbox qualification](0001-phase2-sandbox-qualification.md) and [signed worker provenance](0001-phase2-worker-provenance.md) diff --git a/docs/adr/0001-phase2-qualification-lifecycle.md b/docs/adr/0001-phase2-qualification-lifecycle.md index d3a7738..1c7f78d 100644 --- a/docs/adr/0001-phase2-qualification-lifecycle.md +++ b/docs/adr/0001-phase2-qualification-lifecycle.md @@ -91,8 +91,9 @@ packaged worker release qualification. ## Next stage -Hosted Quality CI run [30466976650](https://github.com/dovvnloading/Cortex/actions/runs/30466976650) -and job [90627154917](https://github.com/dovvnloading/Cortex/actions/runs/30466976650/job/90627154917) -passed the coordinator gate on commit `0c7b350`. The immediate remaining action -is review/merge of PR #64; the application remains disabled unless an explicit -caller injects a ready qualification profile. +Hosted Quality CI run [30467455657](https://github.com/dovvnloading/Cortex/actions/runs/30467455657) +and job [90628811567](https://github.com/dovvnloading/Cortex/actions/runs/30467455657/job/90628811567) +passed the coordinator gate on merged PR #64. This stage is complete. The +application remains disabled unless an explicit caller injects a ready +qualification profile; that default-off behavior is intentional until the normal +user-facing recipe flow is wired. diff --git a/docs/adr/0001-phase2-recipe-contract.md b/docs/adr/0001-phase2-recipe-contract.md index df57c4e..84e6cc8 100644 --- a/docs/adr/0001-phase2-recipe-contract.md +++ b/docs/adr/0001-phase2-recipe-contract.md @@ -73,16 +73,16 @@ hardening, not open-source prerequisites. The trusted attachment boundary, signed/native attempt factory, and durable packaged-worker coordinator qualification are implemented behind the passing -lifecycle health check. Hosted Quality run [30466976650](https://github.com/dovvnloading/Cortex/actions/runs/30466976650) -passed `recipe_coordinator_e2e_qualification.py` on commit `0c7b350`; the -immediate remaining action is review and merge. The lifecycle builder still -intentionally does not create processes or discover artifacts. Official -prebuilt releases may add external review and production trust as optional -hardening. +lifecycle health check. Merged PR #64 passed hosted Quality run +[30467455657](https://github.com/dovvnloading/Cortex/actions/runs/30467455657), +including `recipe_coordinator_e2e_qualification.py`. The lifecycle builder still +intentionally does not create processes or discover artifacts. The next product +work is wiring a normal user-facing recipe flow; official prebuilt releases may +add external review and production trust as optional hardening. ## Verification `tests/test_phase2_recipe_contract.py` covers canonical identity, malformed and oversized plans, path/operation rejection, decimal determinism, division and result limits, explicit comparisons, and redacted failures. The full repository and -frontend matrix remains required before this stage is merged. +frontend matrix passed for this stage. diff --git a/docs/adr/0001-phase2-recipe-coordinator.md b/docs/adr/0001-phase2-recipe-coordinator.md index ac889c2..a5274dc 100644 --- a/docs/adr/0001-phase2-recipe-coordinator.md +++ b/docs/adr/0001-phase2-recipe-coordinator.md @@ -150,9 +150,9 @@ probe is Windows-only, bounded, redacted, and has no host-process fallback. ## Next stage -The coordinator implementation and hosted Quality CI gates are complete. The -immediate remaining action is review/merge of PR #64. Any follow-on Phase 3 -arbitrary-code profile requires its own ADR and qualification gates; this -API/coordinator contract must remain default-off. No external reviewer or -production key is required for the open-source qualification path; production -trust remains optional release hardening. +The coordinator implementation and hosted Quality CI gates are complete, and PR +#64 is merged. The next product stage is a normal user-facing recipe flow; this +API/coordinator contract remains qualification-only until that flow is reviewed. +Any follow-on scratch-compute profile needs focused capability and safety tests, +but it does not need an external reviewer or production key for the open-source +qualification path. Production trust remains optional release hardening. diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md index 76e150a..f5d3fe9 100644 --- a/docs/adr/0001-phase2-recipe-provider.md +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -126,8 +126,8 @@ Windows corpus launches the provider out of process, completes a staged transform, rejects a foreign owner, expires and purges a short-retention attachment, cancels in flight, verifies atomic publication, and proves native cleanup. It never falls back to a host process or in-process execution. Hosted -Quality run [30466976650](https://github.com/dovvnloading/Cortex/actions/runs/30466976650) -passed this gate on commit `0c7b350`; the immediate remaining action is review -and merge. +Merged PR #64 passed hosted Quality run +[30467455657](https://github.com/dovvnloading/Cortex/actions/runs/30467455657), +including this gate. External review and production signing remain optional official-release hardening. diff --git a/docs/adr/0001-phase2-release-lifecycle-gate.md b/docs/adr/0001-phase2-release-lifecycle-gate.md index 195c3a2..0c21202 100644 --- a/docs/adr/0001-phase2-release-lifecycle-gate.md +++ b/docs/adr/0001-phase2-release-lifecycle-gate.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 release and lifecycle health preflight -- **Status:** Optional official-release preflight and explicit local qualification composition implemented and verified; recipe request execution remains separate +- **Status:** Optional official-release preflight and explicit local qualification composition implemented and verified; recipe request execution is a separate product-integration surface - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [signed worker provenance](0001-phase2-worker-provenance.md), [native launcher](0001-phase2-native-launcher.md), [native broker](0001-phase2-native-broker.md), and [Phase 1 lifecycle](0001-phase1-production-lifecycle.md) diff --git a/docs/adr/0001-phase2-signed-manifest.md b/docs/adr/0001-phase2-signed-manifest.md index f561258..77fd73d 100644 --- a/docs/adr/0001-phase2-signed-manifest.md +++ b/docs/adr/0001-phase2-signed-manifest.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 signed recipe manifest and rollback gate -- **Status:** Implemented and verified; the storage installer is complete and provider enablement remains blocked +- **Status:** Implemented and verified as a storage/qualification boundary; provider exposure remains an explicit product-integration decision - **Parent:** [Phase 2 typed recipe contract](0001-phase2-recipe-contract.md) - **Scope:** Canonical Ed25519 manifest signatures, pinned bundle bytes, monotonic update policy, and explicit rollback authorization diff --git a/docs/adr/0001-phase2-worker-protocol.md b/docs/adr/0001-phase2-worker-protocol.md index 81afa34..a1e3e56 100644 --- a/docs/adr/0001-phase2-worker-protocol.md +++ b/docs/adr/0001-phase2-worker-protocol.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 fixed recipe worker protocol and package boundary -- **Status:** Protocol, authenticated worker loop, and packaging qualification complete; signed end-to-end execution remains blocked +- **Status:** Protocol, authenticated worker loop, packaging, and signed-worker end-to-end qualification complete; normal app remains default-off - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Signed worker provenance](0001-phase2-worker-provenance.md), [native broker adapter](0001-phase2-native-broker.md), and [native launcher](0001-phase2-native-launcher.md) diff --git a/docs/adr/0001-phase2-worker-provenance.md b/docs/adr/0001-phase2-worker-provenance.md index 07a2d59..401c18f 100644 --- a/docs/adr/0001-phase2-worker-provenance.md +++ b/docs/adr/0001-phase2-worker-provenance.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 signed worker provenance binding -- **Status:** Storage-only worker-role verifier complete; native launch remains blocked +- **Status:** Worker-role verifier and native-launch qualification complete; production trust material is optional release hardening - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [signed bundle installation](0001-phase2-bundle-installation.md), [signed recipe manifest](0001-phase2-signed-manifest.md), and [Windows sandbox qualification](0001-phase2-sandbox-qualification.md) diff --git a/docs/adr/0001-phase2-worker-release-qualification.md b/docs/adr/0001-phase2-worker-release-qualification.md index a7bd3bb..1abdc98 100644 --- a/docs/adr/0001-phase2-worker-release-qualification.md +++ b/docs/adr/0001-phase2-worker-release-qualification.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 packaged worker release qualification -- **Status:** CI qualification implemented; production signed release remains blocked +- **Status:** CI qualification complete; production-signed packaging is optional official-release hardening - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [signed worker provenance](0001-phase2-worker-provenance.md), [native launcher](0001-phase2-native-launcher.md), [native broker](0001-phase2-native-broker.md), and [release/lifecycle preflight](0001-phase2-release-lifecycle-gate.md) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 269c6bc..12dd27f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,6 +12,8 @@ import type { ExecutionTaskListResponse, RecipeImageTransformAccepted, RecipeImageTransformRequest, + ScratchComputeAccepted, + ScratchComputeRequest, ForkRequest, GenerationEvent, GenerationRequest, @@ -248,6 +250,17 @@ export class CortexApi { return this.request(`/execution/tasks${query ? `?${query}` : ""}`); } + executionStatus(jobId: string): Promise { + return this.request(`/execution/${encodeURIComponent(jobId)}`); + } + + startScratchCompute(payload: ScratchComputeRequest): Promise { + return this.request("/execution/scratch", { + method: "POST", + body: JSON.stringify(payload), + }); + } + startRecipeImageTransform( payload: RecipeImageTransformRequest, ): Promise { @@ -264,6 +277,16 @@ export class CortexApi { }); } + async downloadExecutionArtifact(artifactId: string): Promise { + const response = await this.fetcher( + `${this.baseUrl}/execution/artifacts/${encodeURIComponent(artifactId)}`, + { headers: this.authHeaders() }, + ); + if (response.status === 401) this.clearSession(); + if (!response.ok) throw new ApiError(response.status, await this.errorDetail(response)); + return response; + } + cancelExecution(jobId: string): Promise { return this.request( `/execution/${encodeURIComponent(jobId)}/cancel`, diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 93754f1..02b99f1 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -292,8 +292,8 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS } /> - { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} /> - { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} /> + { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} /> + { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} onForked={(chat) => { setActiveChatId(chat.id); updateChatSummary(setChats, chat); }} />} /> } /> @@ -313,10 +313,10 @@ function updateModelProgress( setProgress({ model, status, percent }); } -function ChatRoute({ api, runtimeReady, runtimeMessage, localModels, selectedModel, modelBusy, onSelectModel, onRescanModels, onChatChanged, onForked }: { api: CortexApi; runtimeReady: boolean; runtimeMessage: string | null; localModels: readonly string[]; selectedModel: string | null; modelBusy: boolean; onSelectModel: (model: string) => Promise; onRescanModels: () => Promise; onChatChanged: (chat: ChatResponse) => void; onForked: (chat: ChatResponse) => void }) { +function ChatRoute({ api, runtimeReady, runtimeMessage, localModels, selectedModel, modelBusy, imageTransformAvailable, onSessionExpired, onSelectModel, onRescanModels, onChatChanged, onForked }: { api: CortexApi; runtimeReady: boolean; runtimeMessage: string | null; localModels: readonly string[]; selectedModel: string | null; modelBusy: boolean; imageTransformAvailable: boolean; onSessionExpired: () => void; onSelectModel: (model: string) => Promise; onRescanModels: () => Promise; onChatChanged: (chat: ChatResponse) => void; onForked: (chat: ChatResponse) => void }) { const { threadId } = useParams(); const navigate = useNavigate(); - return navigate(`/chat/${id}`, { replace: true })} onChatChanged={onChatChanged} onForked={(chat) => { onForked(chat); navigate(`/chat/${chat.id}`); }} />; + return navigate(`/chat/${id}`, { replace: true })} onChatChanged={onChatChanged} onForked={(chat) => { onForked(chat); navigate(`/chat/${chat.id}`); }} />; } function SettingsRoute({ activeChatId, ...props }: Omit & { activeChatId: string | null }) { diff --git a/frontend/src/components/ChatPage.tsx b/frontend/src/components/ChatPage.tsx index 685bcc3..f374dbf 100644 --- a/frontend/src/components/ChatPage.tsx +++ b/frontend/src/components/ChatPage.tsx @@ -6,6 +6,7 @@ import { displayChatTitle } from "../lib/chatTitle"; import { composerDraftKey, readComposerDraft, writeComposerDraft } from "../lib/composerDraft"; import { humanizeGenerationStatus } from "../lib/generationStatus"; import { MessageComposer, type ComposerPhase } from "./MessageComposer"; +import { ImageTransformPanel } from "./ImageTransformPanel"; import { SafeMarkdown } from "./SafeMarkdown"; type Props = { @@ -16,6 +17,8 @@ type Props = { localModels: readonly string[]; selectedModel: string | null; modelBusy: boolean; + imageTransformAvailable?: boolean; + onSessionExpired?: () => void; onSelectModel: (model: string) => Promise; onRescanModels: () => Promise; onThreadCreated: (threadId: string) => void; @@ -44,6 +47,8 @@ export function ChatPage({ localModels, selectedModel, modelBusy, + imageTransformAvailable = false, + onSessionExpired, onSelectModel, onRescanModels, onThreadCreated, @@ -409,6 +414,7 @@ export function ChatPage({
{showJumpToLatest && } + { + afterEach(() => vi.unstubAllGlobals()); + + it("shows active work and returns a downloadable fixed-recipe result", async () => { + const user = userEvent.setup(); + const createObjectURL = vi.fn().mockReturnValueOnce("blob:source").mockReturnValueOnce("blob:result"); + vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL: vi.fn() }); + let finishStage: ((value: { artifact_id: string }) => void) | undefined; + const api = { + stageAttachment: vi.fn(() => new Promise<{ artifact_id: string }>((resolve) => { finishStage = resolve; })), + startRecipeImageTransform: vi.fn().mockResolvedValue({ job_id: "image-job" }), + executionStatus: vi.fn().mockResolvedValue({ + job_id: "image-job", + status: "succeeded", + result: { artifact_id: "result-artifact", mime_type: "image/png" }, + }), + downloadExecutionArtifact: vi.fn().mockResolvedValue({ + blob: vi.fn().mockResolvedValue(new Blob(["image"], { type: "image/png" })), + } as unknown as Response), + } as unknown as CortexApi; + + render(); + await user.click(screen.getByRole("button", { name: "Transform image" })); + const image = new File([new Uint8Array([137, 80, 78, 71])], "photo.png", { type: "image/png" }); + await user.upload(screen.getByLabelText("Image file"), image); + await user.click(screen.getAllByRole("button", { name: "Transform image" })[1]); + + expect(await screen.findByRole("status")).toHaveTextContent("Preparing image"); + finishStage?.({ artifact_id: "source-artifact" }); + await waitFor(() => expect(screen.getByRole("link", { name: "Download result" })).toBeVisible()); + expect(api.startRecipeImageTransform).toHaveBeenCalledWith(expect.objectContaining({ + source_artifact_id: "source-artifact", + plan: expect.objectContaining({ steps: [{ op: "grayscale" }] }), + })); + expect(createObjectURL).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/components/ImageTransformPanel.tsx b/frontend/src/components/ImageTransformPanel.tsx new file mode 100644 index 0000000..2e75027 --- /dev/null +++ b/frontend/src/components/ImageTransformPanel.tsx @@ -0,0 +1,270 @@ +import { Download, ImagePlus, LoaderCircle, Square, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import type { ImageTransformPlan } from "../../../contracts/cortex-api"; +import { ApiError, CortexApi } from "../api/client"; + +type TransformKind = "grayscale" | "contrast" | "brightness"; + +type Props = { + api: CortexApi; + available: boolean; + onSessionExpired?: () => void; +}; + +type ActiveTransform = { + jobId: string; + message: string; +}; + +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const ACCEPTED_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); + +export function ImageTransformPanel({ api, available, onSessionExpired }: Props) { + const fileInputId = useId(); + const [expanded, setExpanded] = useState(false); + const [file, setFile] = useState(null); + const [sourceUrl, setSourceUrl] = useState(null); + const [resultUrl, setResultUrl] = useState(null); + const [resultMime, setResultMime] = useState("image/png"); + const [kind, setKind] = useState("grayscale"); + const [factor, setFactor] = useState("1.5"); + const [active, setActive] = useState(null); + const [error, setError] = useState(null); + const sourceUrlRef = useRef(null); + const resultUrlRef = useRef(null); + + useEffect(() => () => { + if (sourceUrlRef.current) URL.revokeObjectURL(sourceUrlRef.current); + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }, []); + + if (!available) return null; + + const replaceSourceUrl = (next: string | null) => { + if (sourceUrlRef.current) URL.revokeObjectURL(sourceUrlRef.current); + sourceUrlRef.current = next; + setSourceUrl(next); + }; + + const replaceResultUrl = (next: string | null) => { + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = next; + setResultUrl(next); + }; + + const selectFile = (candidate: File | null) => { + setError(null); + replaceResultUrl(null); + if (!candidate) { + setFile(null); + replaceSourceUrl(null); + return; + } + if (!ACCEPTED_TYPES.has(candidate.type)) { + setFile(null); + replaceSourceUrl(null); + setError("Choose a PNG, JPEG, or WebP image."); + return; + } + if (candidate.size <= 0 || candidate.size > MAX_FILE_BYTES) { + setFile(null); + replaceSourceUrl(null); + setError("Choose an image smaller than 10 MB."); + return; + } + setFile(candidate); + replaceSourceUrl(URL.createObjectURL(candidate)); + }; + + const start = async () => { + if (!file || active) return; + setError(null); + replaceResultUrl(null); + setActive({ jobId: "staging", message: "Preparing image…" }); + try { + const contentBase64 = await fileToBase64(file); + const stage = await api.stageAttachment({ + request_id: requestId("image-stage"), + content_base64: contentBase64, + }); + const plan = buildPlan(stage.artifact_id, kind, factor); + const accepted = await api.startRecipeImageTransform({ + request_id: requestId("image-transform"), + source_artifact_id: stage.artifact_id, + plan, + }); + setActive({ jobId: accepted.job_id, message: "Starting image transformation…" }); + await watchTransform(api, accepted.job_id, (message) => { + setActive({ jobId: accepted.job_id, message }); + }); + const completed = await api.executionStatus(accepted.job_id); + if (completed.status !== "succeeded" || !completed.result) { + throw new Error(completed.error ?? "The image transformation did not complete."); + } + const artifactId = completed.result.artifact_id; + const mimeType = completed.result.mime_type; + if (typeof artifactId !== "string" || typeof mimeType !== "string") { + throw new Error("Cortex returned an incomplete image result."); + } + const download = await api.downloadExecutionArtifact(artifactId); + replaceResultUrl(URL.createObjectURL(await download.blob())); + setResultMime(mimeType); + setActive(null); + } catch (requestError) { + if (requestError instanceof ApiError && requestError.status === 401) { + onSessionExpired?.(); + return; + } + setActive(null); + setError(requestError instanceof ApiError ? requestError.detail : messageForError(requestError)); + } + }; + + const stop = async () => { + if (!active || active.jobId === "staging") return; + try { + setActive((current) => current ? { ...current, message: "Stopping image transformation…" } : null); + await api.cancelExecution(active.jobId); + } catch (requestError) { + if (requestError instanceof ApiError && requestError.status === 401) onSessionExpired?.(); + else setError(requestError instanceof ApiError ? requestError.detail : "Could not stop the image transformation."); + } + }; + + return ( +
+ + {expanded && ( +
+
+
+ Image transformation + Runs locally in a fixed, safe image worker. +
+ +
+
+ + + {kind !== "grayscale" && ( + + )} +
+ {sourceUrl &&
Selected image preview
} + {active && ( +
+
+ )} + {error &&

{error}

} + {resultUrl && ( + + )} +
+ +
+
+ )} +
+ ); +} + +function buildPlan(artifactId: string, kind: TransformKind, factor: string): ImageTransformPlan { + const numericFactor = Number(factor); + const safeFactor = Number.isFinite(numericFactor) && numericFactor >= 0 && numericFactor <= 4 + ? numericFactor + : 1.5; + const steps = kind === "grayscale" + ? [{ op: "grayscale" as const }] + : [{ op: kind, factor: safeFactor }]; + return { + schema_version: "artifact.transform.v1", + input_artifact_id: artifactId, + steps, + output_format: "png", + strip_metadata: true, + }; +} + +async function watchTransform(api: CortexApi, jobId: string, onStatus: (message: string) => void): Promise { + for (let attempt = 0; attempt < 600; attempt += 1) { + const status = await api.executionStatus(jobId); + if (status.message) onStatus(status.message); + if (status.status === "succeeded") return; + if (status.status === "failed" || status.status === "cancelled") { + throw new Error(status.error ?? "The image transformation did not complete."); + } + await delay(300); + } + throw new Error("The image transformation took too long. Try a smaller image."); +} + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Cortex could not read this image.")); + reader.onload = () => { + const value = typeof reader.result === "string" ? reader.result : ""; + const encoded = value.split(",", 2)[1]; + if (!encoded) reject(new Error("Cortex could not read this image.")); + else resolve(encoded); + }; + reader.readAsDataURL(file); + }); +} + +function requestId(prefix: string): string { + const value = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}-${value}`.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 128); +} + +function extensionForMime(mime: string): string { + return mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png"; +} + +function messageForError(error: unknown): string { + return error instanceof Error ? error.message : "The image transformation could not be completed."; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} diff --git a/frontend/src/components/SettingsPanel.test.tsx b/frontend/src/components/SettingsPanel.test.tsx index fc80393..cf01b34 100644 --- a/frontend/src/components/SettingsPanel.test.tsx +++ b/frontend/src/components/SettingsPanel.test.tsx @@ -108,4 +108,47 @@ describe("SettingsPanel", () => { expect(screen.getByRole("status", { name: "Model operation progress" })).toHaveAttribute("aria-busy", "true"); expect(container.querySelector(".model-progress-spinner")).toBeInTheDocument(); }); + + it("lets a user turn automatic safe computation off", async () => { + const user = userEvent.setup(); + const onSave = vi.fn<(settings: CortexSettings) => Promise>().mockResolvedValue(); + const settings: CortexSettings = { + models: { chat: "local-chat:7b", title: null, translation: "translategemma:4b" }, + execution: { automatic_compute: true }, + }; + const models: ModelResponse = { + required_models: [], + optional_models: [], + installed_models: ["local-chat:7b"], + models: [{ name: "local-chat:7b" }], + connection: { success: true, status: "connected", message: "Connected." }, + }; + render( + Promise>().mockResolvedValue()} + onReplaceMemory={vi.fn<(memos: string[]) => Promise>().mockResolvedValue()} + onClearMemory={vi.fn<() => Promise>().mockResolvedValue()} + models={models} + modelBusy={false} + modelProgress={null} + setupUrl="https://ollama.com/download" + onCheckModels={vi.fn<() => Promise>().mockResolvedValue()} + onPullModel={vi.fn<(model: string) => Promise>().mockResolvedValue()} + onClose={vi.fn()} + />, + ); + + const toggle = screen.getByRole("checkbox", { name: "Use safe computation automatically" }); + expect(toggle).toBeChecked(); + await user.click(toggle); + await user.click(screen.getByRole("button", { name: "Save settings" })); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + execution: { automatic_compute: false }, + })); + }); }); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 115c0b9..2b1f09c 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -63,6 +63,7 @@ export function SettingsPanel({ const installedModels = localModelNames(models); const appearance = draft.appearance ?? {}; const generation = draft.generation ?? {}; + const execution = draft.execution ?? {}; const modelSettings = draft.models ?? {}; const memory = draft.memory ?? {}; const translation = draft.translation ?? {}; @@ -140,6 +141,10 @@ export function SettingsPanel({ onChange={(theme) => update({ appearance: { ...appearance, theme: theme as "light" | "dark" | "system" } })} />
+ )} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index e548a43..97d962b 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -291,6 +291,23 @@ a { color: inherit; text-decoration: none; } .chat-empty-state p { max-width: 420px; margin: 8px 0 0; font-size: 0.88rem; line-height: 1.55; } .input-container { position: relative; flex: 0 0 auto; width: 100%; padding: 16px max(20px, calc((100% - 900px) / 2)) max(18px, env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--input) 96%, var(--bg)); } .composer-area, .composer { width: 100%; } +.image-transform { width: 100%; margin: 0 0 8px; } +.image-transform-trigger { display: inline-flex; align-items: center; min-height: 30px; gap: 7px; border: 1px solid transparent; border-radius: 9px; padding: 0 8px; background: transparent; color: var(--text-muted); font-size: 0.74rem; font-weight: 650; } +.image-transform-trigger:hover { border-color: var(--line-strong); background: var(--surface-soft); color: var(--accent); } +.image-transform-card { display: grid; gap: 13px; margin-bottom: 9px; border: 1px solid var(--line-strong); border-radius: 14px; padding: 14px; background: var(--surface-raised); box-shadow: 0 8px 24px rgba(43, 35, 28, 0.08); } +.image-transform-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.image-transform-heading > div { display: grid; gap: 3px; } +.image-transform-heading strong { color: var(--text); font-size: 0.85rem; } +.image-transform-heading span { color: var(--text-muted); font-size: 0.74rem; line-height: 1.4; } +.image-transform-controls { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(145px, 0.8fr) minmax(105px, 0.45fr); gap: 10px; } +.image-transform-controls .field-label { gap: 6px; font-size: 0.73rem; } +.image-transform-controls input[type="file"] { max-width: 100%; padding: 7px; font-size: 0.72rem; } +.image-transform-preview, .image-transform-result { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); } +.image-transform-preview img, .image-transform-result img { display: block; max-width: min(100%, 250px); max-height: 170px; border-radius: 8px; object-fit: contain; background: var(--surface); } +.image-transform-status { display: flex; align-items: center; min-width: 0; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--accent) 6%, var(--surface-soft)); color: var(--accent); font-size: 0.77rem; } +.image-transform-status > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.image-transform-stop { min-height: 29px; padding: 0 8px; } +.image-transform-actions { display: flex; justify-content: flex-end; } .composer-surface { display: grid; grid-template-columns: minmax(0, 1fr) 42px; grid-template-rows: minmax(52px, auto) 30px; column-gap: 11px; width: 100%; padding: 7px 8px 7px 16px; border: 1px solid var(--line-strong); border-radius: 18px; background: var(--surface); box-shadow: 0 2px 10px rgba(43, 35, 28, 0.035); transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease; } .composer-surface:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft), 0 4px 14px rgba(43, 35, 28, 0.055); } .composer-surface textarea { grid-column: 1; grid-row: 1; display: block; width: 100%; min-height: 52px; max-height: 188px; resize: none; border: 0; outline: 0; padding: 10px 0 6px; background: transparent; color: var(--text); line-height: 1.5; scrollbar-gutter: stable; } @@ -475,6 +492,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .composer-surface { grid-template-columns: minmax(0, 1fr) 40px; grid-template-rows: minmax(48px, auto) 29px; border-radius: 16px; padding: 6px 7px 6px 13px; } .composer-surface textarea { min-height: 48px; padding-top: 8px; } .composer-primary-control { width: 40px; height: 40px; border-radius: 11px; } + .image-transform-controls { grid-template-columns: 1fr; } .composer-phase-ready .composer-status { display: none; } .composer-error { align-items: flex-start; flex-direction: column; gap: 6px; } .composer-error-actions { align-self: flex-end; } diff --git a/main.py b/main.py index fe371a1..ee627f0 100644 --- a/main.py +++ b/main.py @@ -400,4 +400,10 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + # The normal local image and compute profiles use short-lived, restricted + # worker processes. PyInstaller requires this hand-off before it enters + # the desktop application's main function. + import multiprocessing + + multiprocessing.freeze_support() raise SystemExit(main()) diff --git a/packaging/Cortex.spec b/packaging/Cortex.spec index fcdb79c..3902453 100644 --- a/packaging/Cortex.spec +++ b/packaging/Cortex.spec @@ -42,6 +42,9 @@ a = Analysis( "uvicorn.logging", "uvicorn.loops.auto", "uvicorn.protocols.http.auto", + "PIL.PngImagePlugin", + "PIL.JpegImagePlugin", + "PIL.WebPImagePlugin", ], hookspath=[], hooksconfig={}, diff --git a/tests/test_open_source_execution.py b/tests/test_open_source_execution.py new file mode 100644 index 0000000..959ba2b --- /dev/null +++ b/tests/test_open_source_execution.py @@ -0,0 +1,192 @@ +"""End-to-end coverage for the two normal-app execution capabilities.""" + +from __future__ import annotations + +import base64 +from io import BytesIO +import time + +from fastapi.testclient import TestClient +from PIL import Image + +from cortex_backend.api import build_demo_dependencies, create_app +from cortex_backend.execution import ExecutionRepository, build_execution_lifecycle +from cortex_backend.execution.scratch_compute import ( + ScratchComputeError, + evaluate_scratch_expression, +) +from cortex_backend.services.generation import GenerationService +from cortex_backend.testing.fake_ollama import FakeGenerationEngine, FakeOllamaState + + +ALLOWED_HOSTS = ("testserver", "127.0.0.1", "localhost", "::1") + + +def _session(client: TestClient, app) -> dict[str, str]: + response = client.post( + "/api/v1/session/exchange", + json={"bootstrap_token": app.state.session_manager.bootstrap_token}, + ) + assert response.status_code == 200 + return {"Authorization": f"Bearer {response.json()['session_token']}"} + + +def _image_bytes() -> bytes: + image = Image.new("RGB", (4, 3), (120, 80, 40)) + try: + with BytesIO() as stream: + image.save(stream, format="PNG") + return stream.getvalue() + finally: + image.close() + + +def _wait_for_terminal(client: TestClient, headers: dict[str, str], job_id: str) -> dict: + for _ in range(500): + response = client.get(f"/api/v1/execution/{job_id}", headers=headers) + assert response.status_code == 200 + body = response.json() + if body["status"] in {"succeeded", "failed", "cancelled"}: + return body + time.sleep(0.01) + raise AssertionError("execution job did not finish") + + +def _app(tmp_path): + repository = ExecutionRepository( + tmp_path / "execution.sqlite", + tmp_path / "artifacts", + ) + lifecycle = build_execution_lifecycle(repository, profile="local") + return create_app( + build_demo_dependencies(), + allowed_hosts=ALLOWED_HOSTS, + execution_lifecycle=lifecycle, + installation_principal_id=repository.installation_principal_id, + ) + + +def test_safe_expression_language_rejects_python_and_host_capabilities(): + assert evaluate_scratch_expression("round(sqrt(81) / 2, 2)").value == "4.5" + for expression in ( + "__import__('os').system('whoami')", + "open('secret.txt').read()", + "[value for value in range(10)]", + "(lambda: 1)()", + ): + try: + evaluate_scratch_expression(expression) + except ScratchComputeError: + continue + raise AssertionError(f"unsafe expression was accepted: {expression}") + + +def test_local_profile_runs_scratch_and_fixed_image_recipe_end_to_end(tmp_path): + app = _app(tmp_path) + with TestClient(app) as client: + headers = _session(client, app) + system = client.get("/api/v1/system", headers=headers) + assert system.status_code == 200 + assert system.json()["execution_preview_available"] is True + assert system.json()["scratch_compute_available"] is True + assert system.json()["image_transform_available"] is True + + scratch = client.post( + "/api/v1/execution/scratch", + headers=headers, + json={"request_id": "scratch-one", "expression": "12 * (3 + 4)"}, + ) + assert scratch.status_code == 202 + completed = _wait_for_terminal(client, headers, scratch.json()["job_id"]) + assert completed["status"] == "succeeded" + assert completed["result"] == { + "schema_version": "scratch.result.v1", + "value": "84", + } + + unsafe = client.post( + "/api/v1/execution/scratch", + headers=headers, + json={"request_id": "scratch-unsafe", "expression": "open('file')"}, + ) + assert unsafe.status_code == 422 + + staged = client.post( + "/api/v1/execution/attachments", + headers=headers, + json={ + "request_id": "image-stage", + "content_base64": base64.b64encode(_image_bytes()).decode("ascii"), + }, + ) + assert staged.status_code == 201 + artifact_id = staged.json()["artifact_id"] + transformed = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json={ + "request_id": "image-transform", + "source_artifact_id": artifact_id, + "plan": { + "schema_version": "artifact.transform.v1", + "input_artifact_id": artifact_id, + "steps": [{"op": "grayscale"}], + "output_format": "png", + }, + }, + ) + assert transformed.status_code == 202 + image_completed = _wait_for_terminal(client, headers, transformed.json()["job_id"]) + assert image_completed["status"] == "succeeded" + result_artifact = image_completed["result"]["artifact_id"] + download = client.get( + f"/api/v1/execution/artifacts/{result_artifact}", headers=headers + ) + assert download.status_code == 200 + assert download.headers["content-type"].startswith("image/png") + assert download.content.startswith(b"\x89PNG\r\n\x1a\n") + + +def test_explicit_math_request_adds_a_verified_local_observation_to_generation(tmp_path): + state = FakeOllamaState() + dependencies = build_demo_dependencies(ollama_state=state) + captured = [] + dependencies.generation = GenerationService( + history_loader=lambda thread_id: (dependencies.chats.get_chat(thread_id) or {}).get( + "messages", [] + ), + memory_loader=dependencies.memories.get_memos, + engine_factory=lambda snapshot: captured.append(snapshot) + or FakeGenerationEngine(state), + ) + repository = ExecutionRepository( + tmp_path / "execution.sqlite", + tmp_path / "artifacts", + ) + app = create_app( + dependencies, + allowed_hosts=ALLOWED_HOSTS, + execution_lifecycle=build_execution_lifecycle(repository, profile="local"), + installation_principal_id=repository.installation_principal_id, + ) + with TestClient(app) as client: + headers = _session(client, app) + accepted = client.post( + "/api/v1/generations", + headers=headers, + json={"request_id": "generation-math", "user_input": "calculate 9 * 9"}, + ) + assert accepted.status_code == 202 + for _ in range(500): + generation = client.get( + f"/api/v1/generations/{accepted.json()['job_id']}", headers=headers + ) + assert generation.status_code == 200 + if generation.json()["status"] in {"succeeded", "failed", "cancelled"}: + break + time.sleep(0.01) + else: + raise AssertionError("generation did not finish") + assert generation.json()["status"] == "succeeded" + assert captured + assert "9 * 9 = 81" in (captured[0].user_system_instructions or "") diff --git a/tests/test_phase2_qualification_lifecycle.py b/tests/test_phase2_qualification_lifecycle.py index 40a2be8..c05790e 100644 --- a/tests/test_phase2_qualification_lifecycle.py +++ b/tests/test_phase2_qualification_lifecycle.py @@ -37,6 +37,7 @@ def _gate(tmp_path, *, profile: str = "qualification") -> RecipeRuntimeReleaseGa def test_profile_parser_is_exact_and_defaults_to_disabled(): assert parse_execution_profile(None) == "disabled" assert parse_execution_profile("disabled") == "disabled" + assert parse_execution_profile("local") == "local" assert parse_execution_profile("qualification") == "qualification" with pytest.raises(QualificationProfileError) as error: parse_execution_profile(" Qualification ") @@ -56,13 +57,13 @@ def test_disabled_profile_never_calls_health_or_coordinator(tmp_path): assert calls == [] -def test_preview_builder_remains_disabled_without_explicit_profile(tmp_path): +def test_preview_builder_uses_the_checked_in_local_profile_by_default(tmp_path): from Cortex_Preview import build_preview_app app = build_preview_app(data_dir=tmp_path / "app-data", serve_frontend=False) - assert app.state.execution_lifecycle.profile == "disabled" - assert app.state.execution_lifecycle.snapshot.state == "disabled" + assert app.state.execution_lifecycle.profile == "local" + assert app.state.execution_lifecycle.snapshot.state == "stopped" def test_qualification_without_controls_is_blocked_without_factory_call(tmp_path): diff --git a/tests/test_settings_compatibility.py b/tests/test_settings_compatibility.py index 0ed3265..a9ad664 100644 --- a/tests/test_settings_compatibility.py +++ b/tests/test_settings_compatibility.py @@ -33,6 +33,7 @@ def test_defaults_match_the_web_runtime_contract(self): self.assertEqual(settings.generation.temperature, 0.7) self.assertEqual(settings.generation.num_ctx, 4096) self.assertEqual(settings.generation.seed, -1) + self.assertTrue(settings.execution.automatic_compute) self.assertTrue(settings.memory.enabled) self.assertFalse(settings.translation.enabled) self.assertEqual(settings.translation.target_language, "Spanish")