Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/studio-core.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Studio Core
on:
pull_request:
paths: ["core/**", "studio/**", "tests/studio/**", "pyproject.toml", ".github/workflows/studio-core.yml"]
push:
branches: [main, "studio/**"]
paths: ["core/**", "studio/**", "tests/studio/**", "pyproject.toml", ".github/workflows/studio-core.yml"]
workflow_dispatch:
permissions:
contents: read
jobs:
offline-core:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ["3.10"]
env:
YOLO_AUTOINSTALL: "false"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
- run: python -m pip install -e ".[studio]" pytest ruff codespell
- run: python -m pytest tests/studio -q
- run: python -m pytest --doctest-modules core studio -q
- run: ruff check core studio tests/studio
- run: ruff format --check core studio tests/studio
- run: codespell core studio tests/studio
1 change: 1 addition & 0 deletions core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Domain schema, path safety and credential sanitization for Studio tasks."""
46 changes: 46 additions & 0 deletions core/path_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Filesystem path resolution helpers for security boundaries."""

from __future__ import annotations

import stat
from pathlib import Path

__all__ = ["resolve_path_allow_missing"]


def resolve_path_allow_missing(path_value: str | Path) -> Path:
"""Resolve a path while rejecting broken symlinks and allowing missing plain paths.

``Path.resolve(strict=False)`` intentionally permits a missing leaf, which is
required for output directories that will be created later. It also permits
a broken symlink, however, and can therefore turn an unresolvable link into an
apparently contained path. Strictly resolve only components that are actual
symlinks before performing the ordinary non-strict normalization.

Args:
path_value: Local filesystem path to resolve.

Returns:
Path: Absolute, normalized path with all resolvable symlinks expanded.

Raises:
OSError: If an existing component cannot be inspected or a symlink is
broken or otherwise unresolvable.
RuntimeError: If symlink resolution encounters a loop.
"""
path = Path(path_value)
lexical_path = path if path.is_absolute() else Path.cwd() / path
current = Path(lexical_path.anchor)

for part in lexical_path.parts[1:]:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
# A missing ordinary component is valid for a path that will be
# created later. No child beneath it can be an existing symlink.
continue
if stat.S_ISLNK(mode):
current.resolve(strict=True)

return path.resolve(strict=False)
226 changes: 226 additions & 0 deletions core/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""Canonical domain schema for the Studio job execution platform.

This module defines the existing dispatcher and handler execution contract.

Semantics (unchanged from the original contract):
- Every field default that carries mutable state (``Metadata``,
``OutputConfig``, ``SecurityConstraints``, ``RuntimeTracking``) is
deep-copied by Pydantic at instance creation, so two jobs never share a
single class-definition-time instance. ``JobRequest.metadata.created_at``
is nevertheless stamped per submission by the API/backend so Recent Jobs
ordering reflects real creation times.
- ``allow_shell=False`` and ``path_whitelisted=True`` are the fail-closed
security defaults enforced by the dispatcher before any execution.
- ``TaskType`` lists every registered task handler, including the P1
``VAL`` entry.

Example:
>>> from core.schema import JobRequest, TaskType
>>> job = JobRequest(job_id="demo-001", task_type=TaskType.PREDICT)
>>> job.status.value
'pending'
"""

from __future__ import annotations

from collections.abc import Callable
from datetime import datetime, timezone
from enum import Enum
from typing import Any

from pydantic import BaseModel, Field, PrivateAttr, field_validator

from core.security import sanitize_log_text

__version__ = "1.0.0"

__all__ = [
"TERMINAL_STATUSES",
"ArtifactManifest",
"ErrorInfo",
"JobRequest",
"JobStatus",
"Metadata",
"OutputConfig",
"RuntimeTracking",
"SecurityConstraints",
"TaskType",
"__version__",
]


class TaskType(str, Enum):
"""Task identifier accepted by the job dispatcher and handler registry."""

PREDICT = "predict"
TRAIN = "train"
EXPORT = "export"
DIAGNOSE = "diagnose"
VAL = "val"


class JobStatus(str, Enum):
"""Lifecycle state of a job, enforced by the dispatcher state machine."""

PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"


TERMINAL_STATUSES = frozenset({JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED})


class Metadata(BaseModel):
"""Job bookkeeping metadata attached to every ``JobRequest``."""

created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
started_at: str | None = None
completed_at: str | None = None
created_by: str = "anonymous"
description: str | None = None
priority: str = "normal"
tags: list[str] = Field(default_factory=list)


class OutputConfig(BaseModel):
"""Output destination and artifact-capture policy for a job."""

output_dir: str = "runs/predict/job_20260824_f1_001"
save_images: bool = True
save_labels: bool = False
save_logs: bool = True
artifacts: list[str] = Field(default_factory=list)


class SecurityConstraints(BaseModel):
"""Fail-closed security policy enforced before and during job execution.

Regex-enhanced whitelisting (P1): ``allowed_path_patterns`` holds additional
regex patterns accepted by ``BaseTaskHandler._is_path_safe``. Patterns are
matched against the normalized resolved POSIX path and must consume the
entire path string; malformed patterns fail closed. For backward
compatibility, entries in ``allowed_paths`` starting with ``^`` are also
interpreted as regex patterns by the handler layer.
"""

path_whitelisted: bool = True
allow_shell: bool = False
allowed_paths: list[str] = Field(default_factory=list)
allowed_path_patterns: list[str] = Field(default_factory=list)


class RuntimeTracking(BaseModel):
"""Runtime supervision knobs: streaming, deadline and cancellation."""

stream_logs: bool = True
timeout_seconds: int = 300
cancellable: bool = True
cancel_requested: bool = False


class ErrorInfo(BaseModel):
"""Structured error attached to a ``FAILED`` job transition."""

code: str
message: str
timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())


class JobRequest(BaseModel):
"""Strongly typed job submission contract shared by UI, skills and dispatcher.

Attributes:
job_id: Unique job identifier (``{task_type}_{timestamp}_{uuid}`` from the UI).
task_type: Registered task type resolved by the handler registry.
status: Current lifecycle state; transitions are FSM-enforced.
metadata: Bookkeeping metadata (author, priority, tags, timestamps).
params: Task-specific parameters validated by the resolved handler.
output: Output directory and artifact capture configuration.
security_constraints: Fail-closed security policy for the job.
runtime_tracking: Deadline, streaming and cooperative-cancellation flags.
error: Structured failure detail; ``None`` while the job is not FAILED.
logs: Sanitized in-memory execution log lines; only written through
:meth:`append_log`, which redacts credentials before storage so
UI-facing and persisted logs never carry plaintext secrets.
"""

job_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_-]+$")
task_type: TaskType
status: JobStatus = JobStatus.PENDING
metadata: Metadata = Metadata()
params: dict[str, Any] = Field(default_factory=dict)
output: OutputConfig = OutputConfig()
security_constraints: SecurityConstraints = SecurityConstraints()
runtime_tracking: RuntimeTracking = RuntimeTracking()
error: ErrorInfo | None = None
logs: list[str] = Field(default_factory=list)
_log_event_sink: Callable[[int, str, bool], None] | None = PrivateAttr(default=None)

@field_validator("job_id")
@classmethod
def validate_job_id(cls, value: str) -> str:
"""Reject path-like reserved identifiers even though they contain allowed characters."""
if value in {".", ".."}:
raise ValueError("job_id must not be '.' or '..'")
return value

@property
def error_message(self) -> str:
"""Return the failure message attached to this job, or an empty string.

The dispatcher sanitizes error text before attaching ``ErrorInfo``, so
this accessor never exposes plaintext credentials.

Returns:
str: ``error.message`` when an error is attached, otherwise ``""``.
"""
return self.error.message if self.error else ""

def _set_log_event_sink(self, sink: Callable[[int, str, bool], None] | None) -> None:
"""Attach a process-local sink for sanitized incremental log events."""
self._log_event_sink = sink

def append_log(self, text: str, *, terminal: bool = False) -> None:
"""Append a log entry after routing it through the security sanitizer.

This is the canonical interface for writing UI-facing or persisted job
logs: every line is passed through
:func:`core.security.sanitize_log_text` before entering memory, so
Bearer tokens, ``sk-`` keys, ``KEY=value`` secrets and similar
credential fragments can never reach ``job.logs``.

Args:
text: Raw log text (single- or multi-line) to sanitize and store.
"""
sanitized = sanitize_log_text(text)
sequence = len(self.logs)
self.logs.append(sanitized)
if self._log_event_sink is not None:
self._log_event_sink(sequence, sanitized, terminal or self.status in TERMINAL_STATUSES)


class ArtifactManifest(BaseModel):
"""Structured artifact manifest capturing the minimal fields from Appendix B."""

experiment_id: str = Field(description="Unique experiment identifier, e.g. topic_seed_timestamp")
git_ref: str = Field(description="Version control anchor: tag + commit SHA")
config: dict[str, Any] = Field(default_factory=dict, description="Configuration file path and hash")
dataset: str = Field(description="Dataset name, version, split, and sample count")
hardware: str = Field(description="Hardware spec: GPU/CPU, VRAM, driver, CUDA/TRT version")
budget: str = Field(description="Resource budget: epochs, batch size, imgsz, GPU-hours")
seed: int = Field(description="Explicit random seed value")
metrics: dict[str, Any] = Field(
default_factory=dict,
description="Primary metrics, aux metrics, and confidence intervals",
)
artifact: dict[str, str] = Field(
default_factory=dict,
description="Mapping of artifact roles to paths and checksums",
)
status: str = Field(description="Execution outcome: success, failed, or inconclusive")
limitation: str | None = Field(
default=None,
description="Known constraints: single seed, scale, backend limitations",
)
Loading
Loading