From 35e92695ef83b53d79f91b6196fd4273ece61b72 Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Wed, 29 Jul 2026 09:49:57 -0400 Subject: [PATCH] Expose qualification-only recipe API surface --- backend/cortex_backend/api/routes.py | 116 +++++- backend/cortex_backend/api/schemas.py | 68 +++- backend/cortex_backend/execution/__init__.py | 2 + .../cortex_backend/execution/qualification.py | 50 +++ contracts/cortex-api.ts | 56 +++ contracts/openapi.json | 365 ++++++++++++++++++ ...bility-tiered-agentic-execution-harness.md | 15 +- docs/adr/0001-phase2-evidence.md | 28 +- .../0001-phase2-qualification-lifecycle.md | 31 +- docs/adr/0001-phase2-recipe-contract.md | 13 +- docs/adr/0001-phase2-recipe-coordinator.md | 53 ++- docs/adr/0001-phase2-recipe-provider.md | 31 +- docs/adr/0001-phase2-sandbox-qualification.md | 14 +- frontend/src/api/client.test.ts | 33 ++ frontend/src/api/client.ts | 11 + tests/test_phase2_qualification_lifecycle.py | 20 + tests/test_phase2_recipe_api.py | 228 +++++++++++ 17 files changed, 1059 insertions(+), 75 deletions(-) create mode 100644 tests/test_phase2_recipe_api.py diff --git a/backend/cortex_backend/api/routes.py b/backend/cortex_backend/api/routes.py index daa6e9d..e035058 100644 --- a/backend/cortex_backend/api/routes.py +++ b/backend/cortex_backend/api/routes.py @@ -30,6 +30,12 @@ from cortex_backend.execution.coordinator import DurableFakeCoordinator from cortex_backend.execution.fake import FakeExecutionPlan 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.repository import ( ApprovalPolicyError, ApprovalTransitionError, @@ -49,6 +55,8 @@ ExecutionAccepted, ExecutionApprovalDecisionRequest, ExecutionPreviewRequest, + RecipeImageTransformAccepted, + RecipeImageTransformRequest, ExecutionSSEEvent, ExecutionStatusResponse, ExecutionTaskListResponse, @@ -408,7 +416,7 @@ def start_fake_execution( payload: ExecutionPreviewRequest, principal: SessionPrincipal = Depends(require_session), ) -> ExecutionAccepted: - coordinator = _execution_coordinator(request) + coordinator = _fake_execution_coordinator(request) try: job = coordinator.start( owner=_execution_owner(principal), @@ -429,6 +437,50 @@ def start_fake_execution( sequence=job.sequence, ) + @router.post( + "/execution/recipe/image", + response_model=RecipeImageTransformAccepted, + status_code=status.HTTP_202_ACCEPTED, + ) + def start_recipe_image_transform( + request: Request, + payload: RecipeImageTransformRequest, + principal: SessionPrincipal = Depends(require_session), + ) -> RecipeImageTransformAccepted: + """Start one explicitly qualified, owner-scoped image recipe. + + The route is intentionally unavailable unless the app was built with + the explicit qualification lifecycle and that lifecycle completed its + health-gated startup. Attachment staging is a separate trusted boundary; + callers provide only its opaque artifact identifier here. + """ + + coordinator = _recipe_coordinator(request) + try: + job = coordinator.start_image_transform( + RecipeImageRequest( + owner=_execution_owner(principal), + request_id=payload.request_id, + source_artifact_id=payload.source_artifact_id, + plan=payload.plan, + retention_seconds=payload.retention_seconds, + ) + ) + except RecipeExecutionError as exc: + _raise_recipe_request_error(exc) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Recipe request is invalid.", + ) from exc + return RecipeImageTransformAccepted( + job_id=job.job_id, + request_id=job.request_id, + profile=RECIPE_IMAGE_PROFILE, + status=job.status, + sequence=job.sequence, + ) + @router.get("/execution/tasks", response_model=ExecutionTaskListResponse) def execution_tasks( request: Request, @@ -500,7 +552,7 @@ def cancel_execution( request: Request, principal: SessionPrincipal = Depends(require_session), ) -> ExecutionStatusResponse: - coordinator = _execution_coordinator(request) + coordinator = _execution_runtime(request) try: coordinator.cancel(job_id, owner=_execution_owner(principal)) except ValueError as exc: @@ -1388,8 +1440,8 @@ def _execution_owner(principal: SessionPrincipal) -> str: return principal.installation_principal_id -def _execution_coordinator(request: Request) -> DurableFakeCoordinator: - """Require the explicitly injected fake-only preview coordinator.""" +def _execution_runtime(request: Request): + """Require an explicitly enabled execution runtime for shared job routes.""" coordinator = getattr(request.app.state, "execution_coordinator", None) if not request.app.state.preview or coordinator is None: raise HTTPException( @@ -1399,8 +1451,62 @@ def _execution_coordinator(request: Request) -> DurableFakeCoordinator: return coordinator +def _fake_execution_coordinator(request: Request) -> DurableFakeCoordinator: + """Keep the deterministic preview route separate from recipe execution.""" + + coordinator = _execution_runtime(request) + if not isinstance(coordinator, DurableFakeCoordinator): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Execution preview is unavailable.", + ) + return coordinator + + +def _recipe_coordinator(request: Request) -> RecipeExecutionCoordinator: + """Expose recipes only from a ready, explicit qualification lifecycle.""" + + 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( + status_code=status.HTTP_404_NOT_FOUND, + detail="Recipe execution is unavailable.", + ) + coordinator = getattr(lifecycle, "coordinator", None) + if not isinstance(coordinator, RecipeExecutionCoordinator): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Recipe execution is unavailable.", + ) + return coordinator + + +def _raise_recipe_request_error(exc: RecipeExecutionError) -> None: + """Map internal recipe categories to stable, non-sensitive HTTP responses.""" + + if exc.code == "request_conflict": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Recipe request conflicts with an existing request.", + ) from exc + if exc.code == "input_artifact_unavailable": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Source artifact is unavailable.", + ) from exc + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Recipe request could not be accepted safely.", + ) from exc + + def _execution_repository(request: Request): - return _execution_coordinator(request).repository + return _execution_runtime(request).repository def _execution_latest_event( diff --git a/backend/cortex_backend/api/schemas.py b/backend/cortex_backend/api/schemas.py index d6ed789..41b3715 100644 --- a/backend/cortex_backend/api/schemas.py +++ b/backend/cortex_backend/api/schemas.py @@ -2,13 +2,23 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from cortex_backend.core.generation import ConnectionResult from cortex_backend.core.settings import CortexSettings +from cortex_backend.execution.recipe_coordinator import ( + DEFAULT_RECIPE_RETENTION_SECONDS, + MAX_RECIPE_RETENTION_SECONDS, +) +from cortex_backend.execution.recipes import ( + ImageTransformPlan, + RecipeValidationError, + parse_image_transform, +) class APIModel(BaseModel): @@ -196,6 +206,54 @@ class ExecutionPreviewRequest(APIModel): step_delay_seconds: float = Field(default=0.0, ge=0.0, le=1.0) +class RecipeImageTransformRequest(APIModel): + """Explicit request for one qualified, fixed-function image transform. + + The source artifact must already have been copied into the owner-scoped + artifact store by a trusted attachment boundary. The API accepts only the + opaque artifact ID and the typed recipe plan; it never accepts a path or + executable instruction. + """ + + request_id: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + strict=True, + ) + source_artifact_id: str = Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + strict=True, + ) + plan: ImageTransformPlan + retention_seconds: int = Field( + default=DEFAULT_RECIPE_RETENTION_SECONDS, + ge=1, + le=MAX_RECIPE_RETENTION_SECONDS, + strict=True, + ) + + @field_validator("plan", mode="before") + @classmethod + def _parse_json_plan(cls, value: object) -> ImageTransformPlan: + if isinstance(value, ImageTransformPlan): + return value + if not isinstance(value, Mapping): + raise ValueError("typed image plan is invalid") + try: + return parse_image_transform(value) + except RecipeValidationError: + raise ValueError("typed image plan is invalid") from None + + @model_validator(mode="after") + def _bind_plan_to_source(self) -> "RecipeImageTransformRequest": + if self.plan.input_artifact_id != self.source_artifact_id: + raise ValueError("source artifact and plan input must match") + return self + + class ExecutionAccepted(APIModel): job_id: str request_id: str @@ -204,6 +262,14 @@ class ExecutionAccepted(APIModel): sequence: int +class RecipeImageTransformAccepted(APIModel): + job_id: str + request_id: str + profile: Literal["recipe.image.v1"] + status: ExecutionStatus + sequence: int + + class ExecutionApprovalDecisionRequest(APIModel): decision: Literal["approved", "denied"] diff --git a/backend/cortex_backend/execution/__init__.py b/backend/cortex_backend/execution/__init__.py index c97199d..7587c42 100644 --- a/backend/cortex_backend/execution/__init__.py +++ b/backend/cortex_backend/execution/__init__.py @@ -51,6 +51,7 @@ QualificationLifecycleConfig, QualificationProfileError, build_execution_lifecycle, + build_recipe_coordinator_factory, parse_execution_profile, ) from .manifest import ( @@ -218,6 +219,7 @@ "ExecutionLifecycle", "ExecutionProfile", "CoordinatorFactory", + "build_recipe_coordinator_factory", "CalculatorPlan", "CheckPlan", "FakeExecutionPlan", diff --git a/backend/cortex_backend/execution/qualification.py b/backend/cortex_backend/execution/qualification.py index fd35c2b..60c3027 100644 --- a/backend/cortex_backend/execution/qualification.py +++ b/backend/cortex_backend/execution/qualification.py @@ -24,7 +24,12 @@ from collections.abc import Callable from typing import Literal +from .artifact_boundary import ArtifactBoundary from .lifecycle import ExecutionLifecycle, LifecycleCoordinator, RuntimeHealth +from .recipe_coordinator import ( + RecipeExecutionCoordinator, + RecipeWorkerAttemptFactory, +) from .release_gate import RecipeRuntimeReleaseGate from .repository import ExecutionRepository @@ -47,6 +52,50 @@ def __init__(self, code: str) -> None: CoordinatorFactory = Callable[[ExecutionRepository], LifecycleCoordinator] +def build_recipe_coordinator_factory( + worker_attempt_factory: RecipeWorkerAttemptFactory, + *, + artifact_boundary_factory: Callable[[ExecutionRepository], ArtifactBoundary] | None = None, + lease_seconds: float = 30.0, + supervisor_lease_seconds: float = 30.0, +) -> CoordinatorFactory: + """Bind a qualified worker-attempt seam to the durable recipe coordinator. + + The caller must provide the already-qualified attempt factory. This helper + does not launch a process, bind a broker, load a provider, or fall back to + host execution. It only creates a coordinator for the repository supplied + by :class:`ExecutionLifecycle`, keeping lifecycle ownership explicit. + """ + + if not callable(worker_attempt_factory): + raise TypeError("worker_attempt_factory must be callable") + if artifact_boundary_factory is not None and not callable(artifact_boundary_factory): + raise TypeError("artifact_boundary_factory must be callable") + if lease_seconds <= 0 or supervisor_lease_seconds <= 0: + raise ValueError("lease durations must be positive") + + def factory(repository: ExecutionRepository) -> LifecycleCoordinator: + artifact_boundary = ( + artifact_boundary_factory(repository) + if artifact_boundary_factory is not None + else None + ) + if artifact_boundary is not None and not isinstance(artifact_boundary, ArtifactBoundary): + raise TypeError("artifact_boundary_factory returned an invalid boundary") + if artifact_boundary is not None and artifact_boundary.repository is not repository: + raise ValueError("artifact boundary repository mismatch") + return RecipeExecutionCoordinator( + repository, + worker_attempt_factory, + artifact_boundary=artifact_boundary, + lease_seconds=lease_seconds, + supervisor_lease_seconds=supervisor_lease_seconds, + auto_recover=False, + ) + + return factory + + @dataclass(frozen=True, slots=True) class QualificationLifecycleConfig: """Controls required before the local qualification lifecycle can start.""" @@ -180,5 +229,6 @@ def build_execution_lifecycle( "QualificationLifecycleConfig", "QualificationProfileError", "build_execution_lifecycle", + "build_recipe_coordinator_factory", "parse_execution_profile", ] diff --git a/contracts/cortex-api.ts b/contracts/cortex-api.ts index 5375cf2..970ce71 100644 --- a/contracts/cortex-api.ts +++ b/contracts/cortex-api.ts @@ -16,6 +16,11 @@ export interface AppearanceSettings { theme?: "light" | "dark" | "system"; } +export interface BrightnessStep { + op: "brightness"; + factor: number | string; +} + export interface ChatMessage { id?: string | null; role: "user" | "assistant" | "system"; @@ -53,6 +58,11 @@ export interface ConnectionResult { optional_missing_models?: Array; } +export interface ContrastStep { + op: "contrast"; + factor: number | string; +} + export interface CortexSettings { schema_version?: 1; revision?: number; @@ -69,6 +79,14 @@ export interface CreateChatRequest { title?: string; } +export interface CropStep { + op: "crop"; + x: number; + y: number; + width: number; + height: number; +} + export interface DiagnosticsResponse { api_version?: "v1"; settings_source: string; @@ -163,6 +181,10 @@ export interface GenerationSettings { system_instructions?: string; } +export interface GrayscaleStep { + op: "grayscale"; +} + export interface HTTPValidationError { detail?: Array; } @@ -176,6 +198,14 @@ export interface HealthResponse { status?: "ok"; } +export interface ImageTransformPlan { + schema_version: "artifact.transform.v1"; + input_artifact_id: string; + steps: Array; + output_format: "png" | "jpeg" | "webp"; + strip_metadata?: true; +} + export interface InstalledModel { name: string; size?: number | null; @@ -232,6 +262,21 @@ export interface OnboardingSettings { agreement_accepted?: boolean; } +export interface RecipeImageTransformAccepted { + job_id: string; + request_id: string; + profile: "recipe.image.v1"; + status: "queued" | "running" | "cancelling" | "succeeded" | "failed" | "cancelled"; + sequence: number; +} + +export interface RecipeImageTransformRequest { + request_id: string; + source_artifact_id: string; + plan: ImageTransformPlan; + retention_seconds?: number; +} + export interface RegenerationRequest { request_id?: string | null; message_id: string; @@ -246,6 +291,17 @@ export interface ReplaceMemoryRequest { memos: Array; } +export interface ResizeStep { + op: "resize"; + width: number; + height: number; +} + +export interface RotateStep { + op: "rotate"; + degrees: 90 | 180 | 270; +} + export interface SSEEvent { id: number; job_id: string; diff --git a/contracts/openapi.json b/contracts/openapi.json index b8033f2..a37024c 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -84,6 +84,34 @@ "title": "AppearanceSettings", "type": "object" }, + "BrightnessStep": { + "additionalProperties": false, + "properties": { + "factor": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Factor" + }, + "op": { + "const": "brightness", + "title": "Op", + "type": "string" + } + }, + "required": [ + "op", + "factor" + ], + "title": "BrightnessStep", + "type": "object" + }, "ChatMessage": { "additionalProperties": false, "properties": { @@ -291,6 +319,34 @@ "title": "ConnectionResult", "type": "object" }, + "ContrastStep": { + "additionalProperties": false, + "properties": { + "factor": { + "anyOf": [ + { + "type": "number" + }, + { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "type": "string" + } + ], + "title": "Factor" + }, + "op": { + "const": "contrast", + "title": "Op", + "type": "string" + } + }, + "required": [ + "op", + "factor" + ], + "title": "ContrastStep", + "type": "object" + }, "CortexSettings": { "additionalProperties": false, "description": "Complete validated settings snapshot with legacy-compatible defaults.", @@ -346,6 +402,49 @@ "title": "CreateChatRequest", "type": "object" }, + "CropStep": { + "additionalProperties": false, + "properties": { + "height": { + "maximum": 16384.0, + "minimum": 1.0, + "title": "Height", + "type": "integer" + }, + "op": { + "const": "crop", + "title": "Op", + "type": "string" + }, + "width": { + "maximum": 16384.0, + "minimum": 1.0, + "title": "Width", + "type": "integer" + }, + "x": { + "maximum": 16383.0, + "minimum": 0.0, + "title": "X", + "type": "integer" + }, + "y": { + "maximum": 16383.0, + "minimum": 0.0, + "title": "Y", + "type": "integer" + } + }, + "required": [ + "op", + "x", + "y", + "width", + "height" + ], + "title": "CropStep", + "type": "object" + }, "DiagnosticsResponse": { "additionalProperties": false, "properties": { @@ -1014,6 +1113,21 @@ "title": "GenerationSettings", "type": "object" }, + "GrayscaleStep": { + "additionalProperties": false, + "properties": { + "op": { + "const": "grayscale", + "title": "Op", + "type": "string" + } + }, + "required": [ + "op" + ], + "title": "GrayscaleStep", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -1060,6 +1174,84 @@ "title": "HealthResponse", "type": "object" }, + "ImageTransformPlan": { + "additionalProperties": false, + "properties": { + "input_artifact_id": { + "maxLength": 128, + "minLength": 1, + "title": "Input Artifact Id", + "type": "string" + }, + "output_format": { + "enum": [ + "png", + "jpeg", + "webp" + ], + "title": "Output Format", + "type": "string" + }, + "schema_version": { + "const": "artifact.transform.v1", + "title": "Schema Version", + "type": "string" + }, + "steps": { + "items": { + "discriminator": { + "mapping": { + "brightness": "#/components/schemas/BrightnessStep", + "contrast": "#/components/schemas/ContrastStep", + "crop": "#/components/schemas/CropStep", + "grayscale": "#/components/schemas/GrayscaleStep", + "resize": "#/components/schemas/ResizeStep", + "rotate": "#/components/schemas/RotateStep" + }, + "propertyName": "op" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/GrayscaleStep" + }, + { + "$ref": "#/components/schemas/ContrastStep" + }, + { + "$ref": "#/components/schemas/BrightnessStep" + }, + { + "$ref": "#/components/schemas/CropStep" + }, + { + "$ref": "#/components/schemas/ResizeStep" + }, + { + "$ref": "#/components/schemas/RotateStep" + } + ] + }, + "maxItems": 8, + "minItems": 1, + "title": "Steps", + "type": "array" + }, + "strip_metadata": { + "const": true, + "default": true, + "title": "Strip Metadata", + "type": "boolean" + } + }, + "required": [ + "schema_version", + "input_artifact_id", + "steps", + "output_format" + ], + "title": "ImageTransformPlan", + "type": "object" + }, "InstalledModel": { "additionalProperties": false, "properties": { @@ -1393,6 +1585,86 @@ "title": "OnboardingSettings", "type": "object" }, + "RecipeImageTransformAccepted": { + "additionalProperties": false, + "properties": { + "job_id": { + "title": "Job Id", + "type": "string" + }, + "profile": { + "const": "recipe.image.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": "RecipeImageTransformAccepted", + "type": "object" + }, + "RecipeImageTransformRequest": { + "additionalProperties": false, + "description": "Explicit request for one qualified, fixed-function image transform.\n\nThe source artifact must already have been copied into the owner-scoped\nartifact store by a trusted attachment boundary. The API accepts only the\nopaque artifact ID and the typed recipe plan; it never accepts a path or\nexecutable instruction.", + "properties": { + "plan": { + "$ref": "#/components/schemas/ImageTransformPlan" + }, + "request_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + "title": "Request Id", + "type": "string" + }, + "retention_seconds": { + "default": 86400, + "maximum": 2592000.0, + "minimum": 1.0, + "title": "Retention Seconds", + "type": "integer" + }, + "source_artifact_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$", + "title": "Source Artifact Id", + "type": "string" + } + }, + "required": [ + "request_id", + "source_artifact_id", + "plan" + ], + "title": "RecipeImageTransformRequest", + "type": "object" + }, "RegenerationRequest": { "additionalProperties": false, "properties": { @@ -1468,6 +1740,60 @@ "title": "ReplaceMemoryRequest", "type": "object" }, + "ResizeStep": { + "additionalProperties": false, + "properties": { + "height": { + "maximum": 16384.0, + "minimum": 1.0, + "title": "Height", + "type": "integer" + }, + "op": { + "const": "resize", + "title": "Op", + "type": "string" + }, + "width": { + "maximum": 16384.0, + "minimum": 1.0, + "title": "Width", + "type": "integer" + } + }, + "required": [ + "op", + "width", + "height" + ], + "title": "ResizeStep", + "type": "object" + }, + "RotateStep": { + "additionalProperties": false, + "properties": { + "degrees": { + "enum": [ + 90, + 180, + 270 + ], + "title": "Degrees", + "type": "integer" + }, + "op": { + "const": "rotate", + "title": "Op", + "type": "string" + } + }, + "required": [ + "op", + "degrees" + ], + "title": "RotateStep", + "type": "object" + }, "SSEEvent": { "additionalProperties": false, "description": "Schema for the JSON payload carried inside each SSE data field.", @@ -2237,6 +2563,45 @@ "summary": "Start Fake Execution" } }, + "/api/v1/execution/recipe/image": { + "post": { + "description": "Start one explicitly qualified, owner-scoped image recipe.\n\nThe route is intentionally unavailable unless the app was built with\nthe explicit qualification lifecycle and that lifecycle completed its\nhealth-gated startup. Attachment staging is a separate trusted boundary;\ncallers provide only its opaque artifact identifier here.", + "operationId": "start_recipe_image_transform_api_v1_execution_recipe_image_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipeImageTransformRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipeImageTransformAccepted" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Start Recipe Image Transform" + } + }, "/api/v1/execution/tasks": { "get": { "operationId": "execution_tasks_api_v1_execution_tasks_get", diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index 11e10e0..fc8be3c 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -999,12 +999,15 @@ signed/AppContainer/broker, hostile-decoder, and cancellation corpus with bounde timeouts; this is the open-source qualification evidence and does not need production trust material. Explicit lifecycle composition for `release_profile="qualification"` is now available through the documented -local/CI-only builder; the application remains default-off and no recipe request -route is enabled by this slice. The next core slice is the recipe-specific -coordinator/request and artifact-publication path behind that lifecycle. The -separate `recipe.release-review.v1` verifier is available as optional release -hardening; it remains observation-only and no approval record or production -trust material is committed. +local/CI-only builder; the application remains default-off. The durable recipe +coordinator, typed `POST /api/v1/execution/recipe/image` request surface, and +generated TypeScript client method are available only after that lifecycle is +ready and consume an opaque artifact ID staged by a separate trusted boundary. +The next core slice is trusted attachment staging and binding the real +signed/native broker worker into the injected attempt factory. The separate +`recipe.release-review.v1` verifier is available as optional release hardening; +it remains observation-only and no approval record or production trust material +is committed. ### Phase 3 — `scratch.auto.v1` arbitrary WebAssembly code diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 8bbbd5d..185df0d 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -32,12 +32,12 @@ | Release/lifecycle health preflight | **Complete (official + qualification profiles)** | `RecipeRuntimeReleaseGate` defaults to `release_profile="official"` and requires the reviewed native process factory, live broker binder, and external-review result. An explicit `release_profile="qualification"` records `qualification_profile` and omits only the optional outside-review requirement for local/CI development; it performs no launch, broker bind, provider load, or lifecycle mutation. | | Packaged worker release qualification | **Complete (CI qualification-only)** | Quality CI builds the unsigned fixed one-folder worker, signs it only with an in-memory ephemeral key, installs/reverifies the immutable generation, and runs the live AppContainer/Job Object/broker/hostile/cancellation corpus with bounded 15/20-minute steps. Production trust material and provider enablement remain excluded. | | External release-review attestation | **Complete (optional official-release hardening)** | `recipe.release-review.v1`, an independent pinned review key root, exact release/bundle/worker-key/launcher/threat-model binding, bounded freshness, and redacted `RuntimeHealth` adaptation are implemented and adversarially tested. It is not required to build, test, or use the open-source qualification profile. | -| Fixed-function image provider core | **Complete (qualification-only)** | `RecipeImageProvider` validates allowlisted PNG/JPEG/WebP bytes, verifies/loads one frame with Pillow bomb/resource limits, applies only parsed steps, strips metadata, revalidates encoded output, and checks cancellation. The provider request route remains separate and default-off. | +| Fixed-function image provider core | **Complete (qualification-only)** | `RecipeImageProvider` validates allowlisted PNG/JPEG/WebP bytes, verifies/loads one frame with Pillow bomb/resource limits, applies only parsed steps, strips metadata, revalidates encoded output, and checks cancellation. The provider remains separate from the API; the typed coordinator route is qualification-only and default-off. | | Windows recipe sandbox qualification harness | **Complete (signed launch/broker/hostile/cancellation/resource/watchdog)** | `recipe_worker_e2e_qualification.py` signs a disposable package with an in-memory key, installs/verifies one immutable generation, binds the live AppContainer identity to the broker, and exercises the fixed PNG transform, truncated-PNG decoder rejection, active-SVG decoder rejection, and in-flight cancellation corpus. `resource_watchdog_qualification.py` separately proves immutable budgets, actual Job Object accounting, and kill-on-close tree reaping. The native broker uses bounded availability polling before reads so the cancellation reader remains live while the packaged provider transforms. | | Suspended native launcher/resource policy | **Complete (factory + binder + ACL cleanup + qualification evidence)** | `NativeWin32ProcessFactory` grants only inherited read/execute access to the fresh AppContainer SID on the verified package root, applies and verifies Job Object policy before resume, and removes the per-launch ACE during cleanup. `NativeBrokerIdentityBinder` pins the live server to the worker PID/AppContainer SID and launcher cleanup closes it on failure. | -| 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, and watchdog tree reaping are qualified. The explicit lifecycle composition and internal recipe coordinator/publication path are now wired behind injection; UI/API exposure remains separate and default-off. Official-release review/signing remains optional hardening. | +| 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, and watchdog tree reaping are qualified. The explicit lifecycle composition, durable recipe coordinator/publication path, and typed qualification-only API are wired behind injection; attachment staging and the real attempt-factory binding remain the next gate. 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 and artifact publication | **Complete (internal qualification composition; 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`. `tests/test_phase2_recipe_coordinator.py` covers the hostile and cancellation paths. | +| Durable recipe coordinator/request 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/recipe/image` accepts only a typed plan plus an owner-scoped, already-staged artifact ID after a ready `qualification` lifecycle. `build_recipe_coordinator_factory` binds an injected worker-attempt factory without host fallback. `tests/test_phase2_recipe_coordinator.py`, `tests/test_phase2_recipe_api.py`, and `tests/test_phase2_qualification_lifecycle.py` cover the hostile, cancellation, API, and lifecycle paths. | ## Security invariants @@ -295,6 +295,22 @@ qualification. The release gate exposes an explicit profile only when a caller injects the qualification gate, coordinator, and provider-health probe. The normal application remains default-off. The durable recipe-specific coordinator/request and artifact-publication path is now -implemented as an internal qualification composition; no outside reviewer or -production key was required for it. The next core slice is the explicit UI/API -request surface and qualified worker-attempt factory wiring. +implemented as an explicit qualification-only API surface with a typed +TypeScript client method; no outside reviewer or production key was required for +it. The next core slice is trusted attachment staging and binding the real +signed/native broker worker into the injected attempt factory. + +**Qualification API stage verification (2026-07-29):** The explicit +`POST /api/v1/execution/recipe/image` surface is available only from a ready +`qualification` lifecycle and accepts a strict typed plan plus an opaque, +owner-scoped artifact ID. The API regression set passed 22 tests, including +default-off/blocked exposure, JSON-array plan parsing, owner isolation, +idempotency/conflict responses, redacted failures, and the shared status/task +surface. The full Python suite passed **329 tests with 1 expected Windows +skip**; the frontend suite passed **40 tests**, lint and typecheck passed, the +production build passed, generated OpenAPI/TypeScript contracts were refreshed, +`compileall` passed, and `git diff --check` passed. The new +`build_recipe_coordinator_factory` binds only an injected worker-attempt factory +to the lifecycle-owned repository; it creates no process, broker, provider, or +host fallback. Attachment staging and real signed/native attempt binding remain +the next planned slice. diff --git a/docs/adr/0001-phase2-qualification-lifecycle.md b/docs/adr/0001-phase2-qualification-lifecycle.md index 879184a..03294f3 100644 --- a/docs/adr/0001-phase2-qualification-lifecycle.md +++ b/docs/adr/0001-phase2-qualification-lifecycle.md @@ -1,6 +1,8 @@ # ADR-0001 Phase 2 explicit qualification-profile lifecycle -- **Status:** Implemented as a local/CI lifecycle composition boundary; the internal recipe coordinator is now available behind it and application exposure remains separate +- **Status:** Implemented as a local/CI lifecycle composition boundary; the + qualification-only recipe API is now available behind it and the application + remains default-off - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Phase 2 release/lifecycle preflight](0001-phase2-release-lifecycle-gate.md), [recipe provider](0001-phase2-recipe-provider.md), and [Phase 1 lifecycle](0001-phase1-production-lifecycle.md) @@ -28,10 +30,14 @@ safe profile label `qualification` for diagnostics. `Cortex_Preview.build_preview_app` accepts the explicit profile/configuration as an injection point. Its normal call site supplies neither, so the packaged and -source application remain `disabled` by default. This stage does not add a -recipe API route, automatic model tool selection, worker-package discovery, or -persistent signing/trust material. A caller must deliberately provide those -qualified controls in local/CI code. +source application remain `disabled` by default. The helper +`build_recipe_coordinator_factory` now binds an injected, already-qualified +worker-attempt factory to the lifecycle-owned repository; it does not discover a +package, launch a process, bind a broker, or provide a host fallback. The +qualification-only recipe API route consumes an opaque, already-staged artifact +ID and remains unavailable unless this lifecycle is ready. Attachment staging, +automatic model tool selection, worker-package discovery, and persistent +signing/trust material remain separate gates. ## Failure and recovery behavior @@ -53,15 +59,18 @@ qualification code without an outside reviewer, production signing key, or trusted release root. The normal application cannot be enabled accidentally by importing the provider or setting an implicit default. -The internal recipe-specific coordinator/request and artifact-publication path is -now implemented behind this lifecycle boundary. It preserves the same release -gate, native launcher, broker identity, resource/watchdog, and trusted artifact -controls. The next slice is the explicit UI/API request surface and qualified -worker-attempt factory wiring; it must remain default-off in the application. +The recipe-specific coordinator/request and artifact-publication path plus its +typed qualification-only API surface are now implemented behind this lifecycle +boundary. It preserves the same release gate, native launcher, broker identity, +resource/watchdog, and trusted artifact controls. The next slice is trusted +attachment staging and binding the real signed/native broker worker into the +attempt factory; it must remain default-off in the application. ## Verification `tests/test_phase2_qualification_lifecycle.py` covers exact profile parsing, default-off behavior, missing configuration, official-gate rejection, blocked health ordering, provider-health composition, coordinator startup, profile -diagnostics, and clean stop. +diagnostics, clean stop, and repository-bound worker-attempt factory wiring. +`tests/test_phase2_recipe_api.py` covers the route's ready-lifecycle gate and +owner/idempotency/error behavior. diff --git a/docs/adr/0001-phase2-recipe-contract.md b/docs/adr/0001-phase2-recipe-contract.md index b72382f..d65946f 100644 --- a/docs/adr/0001-phase2-recipe-contract.md +++ b/docs/adr/0001-phase2-recipe-contract.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 typed recipe and primitive contract -- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, trusted artifact boundary, qualification-only provider core, explicit qualification-profile lifecycle composition, and the internal durable recipe coordinator implemented and verified; application integration remains separate +- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, trusted artifact boundary, qualification-only provider core, explicit qualification-profile lifecycle composition, durable recipe coordination, and qualification-only API exposure implemented and verified; application remains default-off - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Phase 1 production lifecycle gate](0001-phase1-production-lifecycle.md) - **Scope:** Typed fixed-function image plans, calculator/check primitives, canonical @@ -71,12 +71,13 @@ hardening, not open-source prerequisites. ## Required next gates -1. Wire an explicit UI/API request surface and a qualified worker-attempt factory - behind the passing lifecycle health check. The implemented coordinator is +1. Add trusted attachment staging and bind the real signed/native broker worker + into the injected attempt factory behind the passing lifecycle health check. + The qualification-only request surface and implemented coordinator are documented in [the coordinator ADR](0001-phase2-recipe-coordinator.md); the - lifecycle builder intentionally does not create processes or expose a recipe - route by itself. Official prebuilt releases may add external review and - production trust as optional hardening. + lifecycle builder intentionally does not create processes or discover + artifacts. Official prebuilt releases may add external review and production + trust as optional hardening. ## Verification diff --git a/docs/adr/0001-phase2-recipe-coordinator.md b/docs/adr/0001-phase2-recipe-coordinator.md index 22aa9cb..77c85b2 100644 --- a/docs/adr/0001-phase2-recipe-coordinator.md +++ b/docs/adr/0001-phase2-recipe-coordinator.md @@ -1,7 +1,7 @@ # ADR-0001 Phase 2 durable recipe coordinator and artifact publication -- **Status:** Implemented and verified as an internal qualification composition - boundary; application/API exposure remains a separate default-off decision +- **Status:** Implemented and verified behind an explicit qualification-only + API boundary; the normal application remains default-off - **Phase:** 2 - fixed-function image recipe - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [typed recipe contract](0001-phase2-recipe-contract.md), @@ -15,9 +15,11 @@ The qualified image recipe is coordinated by `cortex_backend.execution.recipe_coordinator.RecipeExecutionCoordinator`. -The coordinator is an internal composition seam, not a new application route. -It can be injected by the explicit `release_profile="qualification"` -lifecycle, while the normal application remains disabled by default. +The coordinator is exposed through the explicit +`POST /api/v1/execution/recipe/image` route only when the app was built with a +ready `release_profile="qualification"` lifecycle. The normal application +remains disabled by default, and the deterministic `fake.v1` preview route +cannot reach the recipe coordinator. The request contract is `RecipeImageRequest`. It contains an owner, an idempotency request identifier, an opaque `source_artifact_id`, a validated @@ -81,31 +83,40 @@ The coordinator exposes stable categories such as `input_artifact_unavailable`, tokens, and stack traces do not enter job events or results. Terminal repository state is immutable, so late worker callbacks cannot overwrite a validated result. -The worker factory is deliberately injected. This stage does not pretend that an -unsigned package, missing broker identity binding, or absent production trust root -is a usable runtime. Those are release/qualification composition inputs owned by -the existing lifecycle and launcher gates. +The worker factory is deliberately injected. The qualification helper +`build_recipe_coordinator_factory` binds that already-qualified attempt factory +to the lifecycle-owned repository without creating a process, binding a broker, +loading a provider, or falling back to host execution. This stage does not +pretend that an unsigned package, missing broker identity binding, or absent +production trust root is a usable runtime; those remain release/qualification +composition inputs owned by the existing lifecycle and launcher gates. ## Explicit non-goals -This ADR does not add a public API route, automatic model tool selection, arbitrary -Python/WASI execution, source-path access, direct user-file mutation, network -access, application-exit persistence, production signing, or external-review -requirements. It also does not make the qualification profile the application -default. +This ADR does not add an attachment-upload/staging route, automatic model tool +selection, arbitrary Python/WASI execution, source-path access, direct user-file +mutation, network access, application-exit persistence, production signing, or +external-review requirements. It also does not make the qualification profile +the application default. ## Verification `tests/test_phase2_recipe_coordinator.py` covers opaque request binding, output digest/MIME revalidation, owner-scoped publication, idempotency conflicts, redacted worker failure, cancellation cleanup, and a real authenticated worker -runtime round trip including in-flight cancellation. The repository-wide matrix -passed with **324 passed, 1 skipped** on 2026-07-29. +runtime round trip including in-flight cancellation. `tests/test_phase2_recipe_api.py` +covers default-off/blocked exposure, typed JSON plan parsing, owner-scoped +artifacts, idempotency/conflict handling, and the shared status/task surface. +`tests/test_phase2_qualification_lifecycle.py` covers the explicit coordinator +factory seam. The generated OpenAPI and TypeScript contracts include the typed +request/acceptance envelope, and `frontend/src/api/client.ts` exposes the +qualification route without enabling it in the UI by default. ## Next stage -The next implementation decision is application integration: define the explicit -UI/API request surface and wire a qualified worker-attempt factory into the -qualification lifecycle. That stage must preserve this coordinator contract and -keep the normal application default-off. No external reviewer or production key -is required to continue the open-source qualification path. +The next implementation decision is trusted attachment staging plus binding the +real signed/native broker worker into the injected attempt factory. That stage +must preserve this API/coordinator contract, keep the normal application +default-off, and prove cancellation, ownership, retention, and publication with +the real qualified attempt. No external reviewer or production key is required +to continue the open-source qualification path. diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md index 1502283..39f96ae 100644 --- a/docs/adr/0001-phase2-recipe-provider.md +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -1,6 +1,8 @@ # ADR-0001 Phase 2 fixed-function recipe provider qualification -- **Status:** Qualification core, explicit qualification-profile lifecycle composition, and the internal recipe coordinator/publication path implemented and verified; application exposure remains separate +- **Status:** Qualification core, explicit qualification-profile lifecycle + composition, durable recipe coordination, and the qualification-only API + request surface implemented and verified; application remains default-off - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Phase 2 typed recipe contract](0001-phase2-recipe-contract.md) and [trusted artifact boundary](0001-phase2-artifact-boundary.md) @@ -24,12 +26,13 @@ Python dependency declaration. ## Decision -`RecipeImageProvider` is a qualification-only core. It is deliberately not imported -by the application lifecycle or exported as an execution API. A future `RecipeExecutor` -may call this core only after the Windows sandbox controls pass and the caller selects -the explicit `release_profile="qualification"`. An official prebuilt release may -add separate release-review/signing hardening, but that is not required for the -open-source source checkout. +`RecipeImageProvider` remains a qualification-only core. It is not imported by +the default application lifecycle or exposed as a provider API. The typed recipe +request route reaches the durable coordinator only after the caller selects the +explicit `release_profile="qualification"`; the coordinator receives a +pre-staged opaque artifact ID and an injected worker attempt. An official +prebuilt release may add separate release-review/signing hardening, but that is +not required for the open-source source checkout. ### Input and decoder boundary @@ -113,9 +116,11 @@ limits, crop bounds, cancellation, stop behavior, and non-raiseable configuratio The explicit qualification-profile lifecycle composition now consumes the signed worker provenance, disposable [Windows sandbox qualification harness](0001-phase2-sandbox-qualification.md), native broker identity, watchdog, and accounting controls through an injected -health-gated builder. The internal recipe coordinator now binds that worker -contract to owner-scoped staged artifacts and atomic publication. The next gate -is the explicit UI/API request surface and qualified worker-attempt factory; the -provider must still launch out of process under the bounded worker contract and -must never fall back to a host process or in-process execution. External review -and production signing remain optional official-release hardening. +health-gated builder. The durable recipe coordinator binds that worker contract +to owner-scoped staged artifacts and atomic publication, and the typed API route +is available only after the lifecycle is ready. The next gate is trusted +attachment staging plus binding the real native broker worker into the attempt +factory; the provider must still launch out of process under the bounded worker +contract and must never fall back to a host process or in-process execution. +External review and production signing remain optional official-release +hardening. diff --git a/docs/adr/0001-phase2-sandbox-qualification.md b/docs/adr/0001-phase2-sandbox-qualification.md index b8f4592..1b65101 100644 --- a/docs/adr/0001-phase2-sandbox-qualification.md +++ b/docs/adr/0001-phase2-sandbox-qualification.md @@ -1,6 +1,8 @@ # ADR-0001 Phase 2 Windows recipe sandbox qualification -- **Status:** Qualification harness, packaged worker/release preflight, explicit qualification-profile lifecycle composition, and internal recipe coordination implemented; application exposure remains separate +- **Status:** Qualification harness, packaged worker/release preflight, explicit + qualification-profile lifecycle composition, durable recipe coordination, and + qualification-only API exposure implemented; application remains default-off - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Phase 2 recipe provider core](0001-phase2-recipe-provider.md), [signed bundle installation](0001-phase2-bundle-installation.md), [native broker adapter](0001-phase2-native-broker.md), and [trusted artifact boundary](0001-phase2-artifact-boundary.md) @@ -51,7 +53,7 @@ fallback. | Decoder hostile corpus | Fixed one-pixel PNG, truncated PNG, and active SVG against the core | Qualification-only evidence; not OS-sandbox evidence | | Signed worker provenance | Storage-only `verify_active_worker()` role binding plus strict packaged-worker corpus | **Qualification complete** for the fixed worker role; production trust remains separate | | Broker identity and framed IPC | Native broker transport tests and strict packaged-worker corpus | **Qualification complete** when bound to the actual worker PID/token; no host fallback | -| Lifecycle enablement | `ExecutionLifecycle` remains disabled by default; `build_execution_lifecycle()` composes an explicit `release_profile="qualification"` only with an injected gate, coordinator, and provider-health probe | No provider can become reachable accidentally; the internal coordinator is now available and the next slice is explicit UI/API exposure | +| Lifecycle enablement | `ExecutionLifecycle` remains disabled by default; `build_execution_lifecycle()` composes an explicit `release_profile="qualification"` only with an injected gate, coordinator, and provider-health probe | No provider can become reachable accidentally; the typed recipe route is available only after a ready qualification lifecycle and consumes an opaque pre-staged artifact ID | No single green smoke result closes the gate. A missing, failed, or unverified control produces `blocked` or `fail`, and no weaker host-process path is attempted. @@ -106,7 +108,7 @@ prevents accidental false-green qualification. The source checkout remains default-off; the explicit qualification profile is now a deliberate, injected lifecycle composition rather than an application default. Official production readiness, signing, and external review are separate optional maintainer concerns -and are not prerequisites for open-source development. The internal recipe -coordinator/request path is now implemented behind this boundary; the next -implementation slice is explicit UI/API exposure and qualified attempt-factory -wiring. +and are not prerequisites for open-source development. The recipe +coordinator/request path and typed qualification-only API are implemented behind +this boundary; the next implementation slice is trusted attachment staging and +binding the real qualified attempt factory. diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 296fd70..940f684 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -64,4 +64,37 @@ describe("CortexApi", () => { const request = fetcher.mock.calls[0]?.[1] as RequestInit; expect(new Headers(request.headers).get("Authorization")).toBe("Bearer session-1"); }); + + it("starts a typed recipe request on the explicit qualification route", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + job_id: "recipe-job", + request_id: "recipe-request", + profile: "recipe.image.v1", + status: "queued", + sequence: 1, + }), { status: 202, headers: { "Content-Type": "application/json" } })); + const api = new CortexApi("/api/v1", fetcher); + window.sessionStorage.setItem("cortex.session.token", "session-1"); + + await api.startRecipeImageTransform({ + request_id: "recipe-request", + source_artifact_id: "artifact-1", + plan: { + schema_version: "artifact.transform.v1", + input_artifact_id: "artifact-1", + steps: [{ op: "grayscale" }], + output_format: "png", + }, + }); + + expect(fetcher).toHaveBeenCalledWith( + "/api/v1/execution/recipe/image", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"source_artifact_id":"artifact-1"'), + }), + ); + const request = fetcher.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(request.headers).get("Authorization")).toBe("Bearer session-1"); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2263177..055af51 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,6 +8,8 @@ import type { ExecutionApprovalDecisionRequest, ExecutionStatusResponse, ExecutionTaskListResponse, + RecipeImageTransformAccepted, + RecipeImageTransformRequest, ForkRequest, GenerationEvent, GenerationRequest, @@ -244,6 +246,15 @@ export class CortexApi { return this.request(`/execution/tasks${query ? `?${query}` : ""}`); } + startRecipeImageTransform( + payload: RecipeImageTransformRequest, + ): Promise { + return this.request("/execution/recipe/image", { + method: "POST", + body: JSON.stringify(payload), + }); + } + cancelExecution(jobId: string): Promise { return this.request( `/execution/${encodeURIComponent(jobId)}/cancel`, diff --git a/tests/test_phase2_qualification_lifecycle.py b/tests/test_phase2_qualification_lifecycle.py index cc5a040..4c8966c 100644 --- a/tests/test_phase2_qualification_lifecycle.py +++ b/tests/test_phase2_qualification_lifecycle.py @@ -10,6 +10,7 @@ QualificationLifecycleConfig, QualificationProfileError, build_execution_lifecycle, + build_recipe_coordinator_factory, parse_execution_profile, ) from cortex_backend.execution.bundle_installer import SignedBundleInstaller @@ -141,3 +142,22 @@ def test_qualification_profile_composes_gate_provider_health_and_lifecycle(tmp_p assert provider_inputs == ["ready"] assert factory_calls == [True] assert lifecycle.stop().state == "stopped" + + +def test_recipe_factory_keeps_worker_attempt_injection_explicit_and_repository_bound(tmp_path): + repository = ExecutionRepository(tmp_path / "execution.sqlite", tmp_path / "artifacts") + worker_calls: list[object] = [] + + def worker_attempt_factory(job): + worker_calls.append(job) + return object() + + factory = build_recipe_coordinator_factory(worker_attempt_factory) + assert worker_calls == [] + + coordinator = factory(repository) + assert coordinator.repository is repository + assert coordinator.worker_factory is worker_attempt_factory + assert coordinator.artifact_boundary.repository is repository + coordinator.shutdown() + assert worker_calls == [] diff --git a/tests/test_phase2_recipe_api.py b/tests/test_phase2_recipe_api.py new file mode 100644 index 0000000..b874bb7 --- /dev/null +++ b/tests/test_phase2_recipe_api.py @@ -0,0 +1,228 @@ +"""Explicit qualification-only recipe API and client-boundary tests.""" + +from __future__ import annotations + +from io import BytesIO +import hashlib +from threading import Event +import time +from typing import Any + +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, + RecipeWorkerOutput, + build_recipe_coordinator_factory, +) +from cortex_backend.execution.lifecycle import ExecutionLifecycle, RuntimeHealth + + +ALLOWED_HOSTS = ("testserver", "127.0.0.1", "localhost", "::1") + + +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 _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']}"} + + +class _Attempt: + def __init__(self) -> None: + content = _image_bytes() + self.output = RecipeWorkerOutput( + content=content, + mime_type="image/png", + format="PNG", + width=4, + height=3, + sha256=hashlib.sha256(content).hexdigest(), + ) + self.cancelled = Event() + + def transform( + self, + _request_id: str, + _job_id: str, + _plan: Any, + _content: bytes, + cancel_event: Event, + ) -> RecipeWorkerOutput: + if cancel_event.is_set() or self.cancelled.is_set(): + raise RuntimeError("cancelled") + return self.output + + def cancel(self, _reason: str = "user") -> None: + self.cancelled.set() + + def close(self) -> None: + return None + + +def _app(tmp_path): + repository = ExecutionRepository( + tmp_path / "execution.sqlite", + tmp_path / "artifacts", + max_artifact_bytes=2 * 1024 * 1024, + ) + owner = repository.installation_principal_id + source_job, _ = repository.create_job( + job_id="source-job", + owner=owner, + request_id="source-request", + profile="artifact.transform.v1", + payload={}, + ) + source = repository.publish_artifact( + source_job.job_id, + name="source.png", + content=_image_bytes(), + mime_type="image/png", + ) + lifecycle = ExecutionLifecycle( + repository, + coordinator_factory=build_recipe_coordinator_factory(lambda _job: _Attempt()), + health_check=RuntimeHealth.ready, + enabled=True, + profile="qualification", + ) + app = create_app( + build_demo_dependencies(), + allowed_hosts=ALLOWED_HOSTS, + execution_lifecycle=lifecycle, + installation_principal_id=owner, + ) + return app, source.artifact_id + + +def _payload(artifact_id: str, *, request_id: str = "api-recipe", steps=None) -> dict: + return { + "request_id": request_id, + "source_artifact_id": artifact_id, + "plan": { + "schema_version": "artifact.transform.v1", + "input_artifact_id": artifact_id, + "steps": steps or [{"op": "grayscale"}], + "output_format": "png", + }, + } + + +def test_recipe_route_requires_ready_qualification_and_preserves_default_off(tmp_path): + app = create_app(build_demo_dependencies(), allowed_hosts=ALLOWED_HOSTS) + with TestClient(app) as client: + headers = _session(client, app) + response = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json=_payload("missing-artifact"), + ) + assert response.status_code == 404 + assert "path" not in response.text.lower() + + +def test_recipe_route_is_owner_scoped_idempotent_and_reaches_existing_execution_surface(tmp_path): + app, artifact_id = _app(tmp_path) + with TestClient(app) as client: + headers = _session(client, app) + accepted = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json=_payload(artifact_id), + ) + assert accepted.status_code == 202 + body = accepted.json() + assert body["profile"] == "recipe.image.v1" + + duplicate = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json=_payload(artifact_id), + ) + assert duplicate.status_code == 202 + assert duplicate.json()["job_id"] == body["job_id"] + + for _ in range(200): + current = client.get( + f"/api/v1/execution/{body['job_id']}", + headers=headers, + ) + if current.json()["status"] == "succeeded": + break + time.sleep(0.005) + assert current.status_code == 200 + assert current.json()["status"] == "succeeded" + assert current.json()["result"]["mime_type"] == "image/png" + assert current.json()["result"]["artifact_id"] + assert "path" not in current.text.lower() + + conflicting = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json=_payload( + artifact_id, + steps=[{"op": "brightness", "factor": "1.1"}], + ), + ) + assert conflicting.status_code == 409 + assert conflicting.json()["detail"] == "Recipe request conflicts with an existing request." + + tasks = client.get( + "/api/v1/execution/tasks?include_terminal=true", + headers=headers, + ) + assert tasks.status_code == 200 + assert tasks.json()["tasks"][0]["profile"] == "recipe.image.v1" + + +def test_recipe_route_rejects_mismatched_plan_and_foreign_artifact_without_leaks(tmp_path): + app, artifact_id = _app(tmp_path) + repository = app.state.execution_lifecycle.repository + foreign_job, _ = repository.create_job( + job_id="foreign-source", + owner="f" * 64, + request_id="foreign-source-request", + profile="artifact.transform.v1", + payload={}, + ) + foreign = repository.publish_artifact( + foreign_job.job_id, + name="foreign.png", + content=_image_bytes(), + mime_type="image/png", + ) + + with TestClient(app) as client: + headers = _session(client, app) + mismatch = _payload(artifact_id) + mismatch["plan"]["input_artifact_id"] = "another-artifact" + invalid = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json=mismatch, + ) + assert invalid.status_code == 422 + + foreign_response = client.post( + "/api/v1/execution/recipe/image", + headers=headers, + json=_payload(foreign.artifact_id, request_id="foreign-request"), + ) + assert foreign_response.status_code == 404 + assert "foreign-source" not in foreign_response.text + assert "path" not in foreign_response.text.lower()