diff --git a/backend/migrations/versions/0002_multi_runtime_versions.py b/backend/migrations/versions/0002_multi_runtime_versions.py new file mode 100644 index 0000000..4279bd0 --- /dev/null +++ b/backend/migrations/versions/0002_multi_runtime_versions.py @@ -0,0 +1,247 @@ +"""Immutable per-language judging configuration and extended submission language IDs. + +Revision ID: 0002_multi_runtime +Revises: 0001_judging_foundation +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0002_multi_runtime" +down_revision = "0001_judging_foundation" +branch_labels = None +depends_on = None + +CONFIGURATION_CHECK = """ + (judging_schema_version = 1 AND language_configurations IS NULL + AND toolchain IS NOT NULL AND compile_limits IS NOT NULL + AND run_limits IS NOT NULL AND job_wall_time_us IS NOT NULL) + OR + (judging_schema_version = 2 AND language_configurations IS NOT NULL + AND toolchain IS NULL AND compile_limits IS NULL + AND run_limits IS NULL AND job_wall_time_us IS NULL + AND public.kf_valid_language_configurations(languages, language_configurations)) +""" + + +def upgrade() -> None: + # Metadata-only additions/widening; do not UPDATE any immutable published rows. + op.add_column( + "problem_versions", + sa.Column("judging_schema_version", sa.SmallInteger(), nullable=False, server_default="1"), + ) + op.add_column( + "problem_versions", + sa.Column("language_configurations", JSONB(none_as_null=True), nullable=True), + ) + for column, kind in ( + ("toolchain", JSONB()), + ("compile_limits", JSONB()), + ("run_limits", JSONB()), + ("job_wall_time_us", sa.BigInteger()), + ): + op.alter_column("problem_versions", column, existing_type=kind, nullable=True) + op.alter_column( + "submissions", + "language", + existing_type=sa.String(8), + type_=sa.String(16), + existing_nullable=False, + ) + op.drop_constraint(op.f("ck_submissions_language"), "submissions", type_="check") + op.create_check_constraint( + op.f("ck_submissions_language"), + "submissions", + "language IN ('c17','cpp20','python313','java21')", + ) + op.execute(""" + CREATE FUNCTION public.kf_valid_runtime_limits(limits jsonb) + RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT + SET search_path = pg_catalog, public AS $$ + DECLARE entry record; + BEGIN + IF jsonb_typeof(limits) != 'object' OR + (SELECT count(*) FROM jsonb_object_keys(limits)) != 10 THEN RETURN false; END IF; + FOR entry IN SELECT * FROM (VALUES + ('cpu_time_us',1000::numeric,60000000::numeric), + ('wall_time_us',1000,120000000), ('memory_bytes',1,2147483648), + ('stack_bytes',1,268435456), ('processes',1,128), ('open_files',3,1024), + ('stdout_bytes',1,16777216), ('stderr_bytes',1,16777216), + ('file_bytes',1,67108864), ('workdir_bytes',1,268435456) + ) AS bounds(name,lo,hi) LOOP + IF NOT limits ? entry.name OR jsonb_typeof(limits->entry.name) != 'number' + OR (limits->>entry.name) !~ '^(0|[1-9][0-9]*)$' + OR length(limits->>entry.name) > 19 THEN RETURN false; END IF; + IF (limits->>entry.name)::numeric NOT BETWEEN entry.lo AND entry.hi + THEN RETURN false; END IF; + END LOOP; + RETURN (limits->>'stack_bytes')::numeric <= (limits->>'memory_bytes')::numeric + AND (limits->>'file_bytes')::numeric <= (limits->>'workdir_bytes')::numeric + AND (limits->>'stdout_bytes')::numeric <= (limits->>'file_bytes')::numeric + AND (limits->>'stderr_bytes')::numeric <= (limits->>'file_bytes')::numeric + AND (limits->>'stdout_bytes')::numeric + (limits->>'stderr_bytes')::numeric + <= (limits->>'workdir_bytes')::numeric; + END $$ + """) + op.execute(""" + CREATE FUNCTION public.kf_valid_language_configurations(languages jsonb, configs jsonb) + RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT + SET search_path = pg_catalog, public AS $$ + DECLARE item record; cfg jsonb; runtime jsonb; lang text; seen text[] := ARRAY[]::text[]; + phase jsonb; preparing boolean; + BEGIN + IF jsonb_typeof(languages) != 'array' OR jsonb_typeof(configs) != 'array' + THEN RETURN false; END IF; + IF jsonb_array_length(configs) NOT BETWEEN 1 AND 4 OR + jsonb_array_length(languages) != jsonb_array_length(configs) + THEN RETURN false; END IF; + FOR item IN SELECT value, ordinality FROM jsonb_array_elements(configs) + WITH ORDINALITY LOOP + cfg := item.value; + IF jsonb_typeof(cfg) != 'object' OR + (SELECT count(*) FROM jsonb_object_keys(cfg)) != 5 OR NOT cfg ?& + ARRAY['language','toolchain','compile_limits','run_limits','job_wall_time_us'] + THEN RETURN false; END IF; + IF jsonb_typeof(cfg->'language') != 'string' THEN RETURN false; END IF; + lang := cfg->>'language'; + IF lang NOT IN ('c17','cpp20','python313','java21') OR lang = ANY(seen) + OR languages->(item.ordinality::int-1) != cfg->'language' + THEN RETURN false; END IF; + seen := array_append(seen,lang); runtime := cfg->'toolchain'; + IF jsonb_typeof(runtime) != 'object' OR + (SELECT count(*) FROM jsonb_object_keys(runtime)) != 5 OR NOT runtime ?& + ARRAY['toolchain_id','image_kind','image_revision','image_digest','runtime_version'] + THEN RETURN false; END IF; + IF jsonb_typeof(runtime->'toolchain_id') != 'string' OR + jsonb_typeof(runtime->'image_kind') != 'string' OR + jsonb_typeof(runtime->'runtime_version') != 'string' OR + jsonb_typeof(runtime->'image_digest') != 'string' OR + (runtime->>'image_digest') !~ '^[0-9a-f]{64}$' OR + (runtime->>'runtime_version') !~ '^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$' OR + jsonb_typeof(runtime->'image_revision') != 'number' OR + (runtime->>'image_revision') !~ '^[1-9][0-9]{0,9}$' + THEN RETURN false; END IF; + IF (runtime->>'image_revision')::numeric > 4294967295 THEN RETURN false; END IF; + IF lang IN ('c17','cpp20') AND (runtime->>'toolchain_id' != 'gnu-cxx-v1' OR + runtime->>'image_kind' != 'linux_gnu_cxx_x86_64_v1') THEN RETURN false; END IF; + IF lang = 'python313' AND (runtime->>'toolchain_id' != 'cpython313-v1' OR + runtime->>'image_kind' != 'linux_cpython313_x86_64_v1' OR + runtime->>'runtime_version' != '3.13.16') THEN RETURN false; END IF; + IF lang = 'java21' AND (runtime->>'toolchain_id' != 'openjdk21-v1' OR + runtime->>'image_kind' != 'linux_openjdk21_x86_64_v1' OR + (runtime->>'runtime_version') !~ + '^21[.][0-9]+[.][0-9]+([.][0-9]+)?[+][1-9][0-9]*(-LTS)?$') + THEN RETURN false; END IF; + IF NOT public.kf_valid_runtime_limits(cfg->'compile_limits') OR + NOT public.kf_valid_runtime_limits(cfg->'run_limits') THEN RETURN false; END IF; + IF jsonb_typeof(cfg->'job_wall_time_us') != 'number' OR + (cfg->>'job_wall_time_us') !~ '^[1-9][0-9]{0,9}$' THEN RETURN false; END IF; + IF (cfg->>'job_wall_time_us')::numeric NOT BETWEEN 1000 AND 3600000000 OR + (cfg->'compile_limits'->>'wall_time_us')::numeric + + (cfg->'run_limits'->>'wall_time_us')::numeric > + (cfg->>'job_wall_time_us')::numeric + THEN RETURN false; END IF; + FOR preparing, phase IN SELECT true,cfg->'compile_limits' UNION ALL + SELECT false,cfg->'run_limits' LOOP + IF lang = 'python313' AND ((phase->>'cpu_time_us')::numeric < 10000 OR + (phase->>'wall_time_us')::numeric < 50000 OR + (phase->>'memory_bytes')::numeric < 33554432 OR + (phase->>'open_files')::numeric < 16) THEN RETURN false; END IF; + IF lang = 'java21' AND ((phase->>'cpu_time_us')::numeric < + CASE WHEN preparing THEN 1000000 ELSE 200000 END OR + (phase->>'wall_time_us')::numeric < + CASE WHEN preparing THEN 3000000 ELSE 1000000 END OR + (phase->>'memory_bytes')::numeric < + CASE WHEN preparing THEN 402653184 ELSE 268435456 END OR + (phase->>'processes')::numeric < 16 OR + (phase->>'open_files')::numeric < 32 OR + (phase->>'stack_bytes')::numeric < 262144 OR + (phase->>'memory_bytes')::numeric - 167772160 - + (phase->>'processes')::numeric * 262144 < 33554432) + THEN RETURN false; END IF; + END LOOP; + END LOOP; + RETURN true; + END $$ + """) + op.execute("REVOKE ALL ON FUNCTION public.kf_valid_runtime_limits(jsonb) FROM PUBLIC") + op.execute( + "REVOKE ALL ON FUNCTION public.kf_valid_language_configurations(jsonb,jsonb) FROM PUBLIC" + ) + op.execute("GRANT EXECUTE ON FUNCTION public.kf_valid_runtime_limits(jsonb) TO kf_oj_platform") + op.execute( + "GRANT EXECUTE ON FUNCTION public.kf_valid_language_configurations(jsonb,jsonb) " + "TO kf_oj_platform" + ) + op.create_check_constraint( + op.f("ck_problem_versions_judging_schema"), + "problem_versions", + "judging_schema_version IN (1,2)", + ) + op.create_check_constraint( + op.f("ck_problem_versions_language_configurations"), "problem_versions", CONFIGURATION_CHECK + ) + op.execute(""" + CREATE FUNCTION public.kf_submission_language() RETURNS trigger LANGUAGE plpgsql + SET search_path = pg_catalog, public AS $$ + DECLARE supported jsonb; + BEGIN + SELECT languages INTO supported FROM public.problem_versions + WHERE id=NEW.problem_version_id; + IF FOUND AND NOT supported @> jsonb_build_array(NEW.language) THEN + RAISE EXCEPTION 'submission language not supported by version' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END $$ + """) + op.execute("REVOKE ALL ON FUNCTION public.kf_submission_language() FROM PUBLIC") + op.execute(""" + CREATE TRIGGER kf_submission_language BEFORE INSERT OR UPDATE OF + language,problem_version_id ON public.submissions + FOR EACH ROW EXECUTE FUNCTION public.kf_submission_language() + """) + + +def downgrade() -> None: + # Losing a runtime configuration is not a rollback strategy. Preserve all user data. + op.execute(""" + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM problem_versions WHERE judging_schema_version = 2) OR + EXISTS (SELECT 1 FROM submissions WHERE language NOT IN ('c17','cpp20')) THEN + RAISE EXCEPTION 'cannot downgrade while multi-runtime data exists' + USING ERRCODE = '23514'; + END IF; + END $$ + """) + op.drop_constraint( + op.f("ck_problem_versions_language_configurations"), "problem_versions", type_="check" + ) + op.drop_constraint( + op.f("ck_problem_versions_judging_schema"), "problem_versions", type_="check" + ) + op.execute("DROP TRIGGER kf_submission_language ON public.submissions") + op.execute("DROP FUNCTION public.kf_submission_language()") + op.execute("DROP FUNCTION public.kf_valid_language_configurations(jsonb,jsonb)") + op.execute("DROP FUNCTION public.kf_valid_runtime_limits(jsonb)") + op.drop_column("problem_versions", "language_configurations") + op.drop_column("problem_versions", "judging_schema_version") + for column, kind in ( + ("toolchain", JSONB()), + ("compile_limits", JSONB()), + ("run_limits", JSONB()), + ("job_wall_time_us", sa.BigInteger()), + ): + op.alter_column("problem_versions", column, existing_type=kind, nullable=False) + op.drop_constraint(op.f("ck_submissions_language"), "submissions", type_="check") + op.create_check_constraint( + op.f("ck_submissions_language"), "submissions", "language IN ('c17','cpp20')" + ) + op.alter_column( + "submissions", + "language", + existing_type=sa.String(16), + type_=sa.String(8), + existing_nullable=False, + ) diff --git a/backend/src/kf_oj/contracts/judging_v2.py b/backend/src/kf_oj/contracts/judging_v2.py index 2723566..1077fff 100644 --- a/backend/src/kf_oj/contracts/judging_v2.py +++ b/backend/src/kf_oj/contracts/judging_v2.py @@ -302,6 +302,45 @@ class AdmissionPolicy(v1.WireModel): raise ValueError("job exceeds trusted runtime capability or budget") +class AdmissionRegistry(v1.WireModel): + """Explicit operator opt-in for both protocol generations; no silent conversion.""" + + schema_version: Version + legacy: v1.AdmissionPolicy | None + multi_runtime: AdmissionPolicy | None + + @model_validator(mode="after") + def at_least_one_protocol(self) -> Self: + if self.legacy is None and self.multi_runtime is None: + raise ValueError("node must advertise at least one explicit protocol capability") + if self.legacy is not None and self.multi_runtime is not None: + for field in ("max_concurrent_jobs", "prefetch", "max_retries"): + if getattr(self.legacy, field) != getattr(self.multi_runtime, field): + raise ValueError("protocol policies disagree on node capacity or retries") + return self + + def admit(self, manifest: v1.JobManifest | JobManifest, attempt_number: int) -> None: + AdmissionRegistry.model_validate(self.model_dump(mode="json")) + policy = self.legacy if manifest.schema_version == 1 else self.multi_runtime + admit_manifest(policy, manifest, attempt_number) + + +NodeAdmission = v1.AdmissionPolicy | AdmissionPolicy | AdmissionRegistry + + +def admit_manifest( + policy: NodeAdmission | None, manifest: v1.JobManifest | JobManifest, attempt_number: int +) -> None: + if isinstance(policy, AdmissionRegistry): + policy.admit(manifest, attempt_number) + elif manifest.schema_version == 1 and type(policy) is v1.AdmissionPolicy: + policy.admit(manifest, attempt_number) + elif manifest.schema_version == 2 and type(policy) is AdmissionPolicy: + policy.admit(manifest, attempt_number) + else: + raise ValueError("node does not advertise this manifest protocol") + + WIRE_MODELS = { "judge-manifest": JobManifest, "judge-lease": LeaseGrant, @@ -309,4 +348,5 @@ WIRE_MODELS = { "judge-result": JudgementReport, "judge-admission": AdmissionPolicy, "judge-stopped": StoppedReport, + "judge-registry": AdmissionRegistry, } diff --git a/backend/src/kf_oj/contracts/problems.py b/backend/src/kf_oj/contracts/problems.py index c406828..f017c3c 100644 --- a/backend/src/kf_oj/contracts/problems.py +++ b/backend/src/kf_oj/contracts/problems.py @@ -2,7 +2,8 @@ from typing import Annotated, Literal from pydantic import Field -from kf_oj.contracts.judging import Identifier, Label, Language, RunLimits, WireModel +from kf_oj.contracts.judging import Identifier, Label, RunLimits, WireModel +from kf_oj.contracts.judging_v2 import Language, PreparationKind, runtime_contract class PublicSample(WireModel): @@ -20,10 +21,36 @@ class ProblemSummary(WireModel): languages: tuple[Language, ...] +class PublicLanguageConfiguration(WireModel): + language: Language + runtime_version: str + source_name: str + entrypoint: str + preparation_kind: PreparationKind + compile_limits: RunLimits + run_limits: RunLimits + job_wall_time_us: int + + @classmethod + def from_configuration(cls, configuration): + source, _, _, _, preparation = runtime_contract(configuration.language) + return cls( + language=configuration.language, + runtime_version=configuration.toolchain.runtime_version, + source_name=source, + entrypoint="Main" if configuration.language == Language.JAVA21 else source, + preparation_kind=preparation, + compile_limits=configuration.compile_limits, + run_limits=configuration.run_limits, + job_wall_time_us=configuration.job_wall_time_us, + ) + + class ProblemDetail(ProblemSummary): statement: str samples: tuple[PublicSample, ...] - run_limits: RunLimits + run_limits: RunLimits | None + language_configurations: tuple[PublicLanguageConfiguration, ...] = () class ProblemPage(WireModel): diff --git a/backend/src/kf_oj/contracts/submissions.py b/backend/src/kf_oj/contracts/submissions.py index f5f3abf..b241aab 100644 --- a/backend/src/kf_oj/contracts/submissions.py +++ b/backend/src/kf_oj/contracts/submissions.py @@ -8,11 +8,11 @@ from kf_oj.contracts.judging import ( CompilationStatus, FailureCode, Identifier, - Language, Measurement, Verdict, WireModel, ) +from kf_oj.contracts.judging_v2 import Language EvaluationState = Literal[ "queued", "claimed", "compiling", "running", "cancelling", "finished", "cancelled", "failed" diff --git a/backend/src/kf_oj/core/settings.py b/backend/src/kf_oj/core/settings.py index a7c0adb..16ec416 100644 --- a/backend/src/kf_oj/core/settings.py +++ b/backend/src/kf_oj/core/settings.py @@ -4,7 +4,7 @@ from typing import Literal from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from kf_oj.contracts.judging import AdmissionPolicy +from kf_oj.contracts.judging_v2 import NodeAdmission class Settings(BaseSettings): @@ -24,7 +24,7 @@ class Settings(BaseSettings): artifact_root: Path | None = None artifact_max_bytes: int = Field(default=536870912, ge=1, le=16 * 1024**3) artifact_max_files: int = Field(default=4096, ge=1, le=65536) - judge_admission: AdmissionPolicy | None = None + judge_admission: NodeAdmission | None = None coordinator_url: str | None = None coordinator_token: SecretStr | None = None diff --git a/backend/src/kf_oj/modules/problems/import_format.py b/backend/src/kf_oj/modules/problems/import_format.py index b6236c8..882218e 100644 --- a/backend/src/kf_oj/modules/problems/import_format.py +++ b/backend/src/kf_oj/modules/problems/import_format.py @@ -2,8 +2,9 @@ from typing import Annotated, Literal, Self -from pydantic import Field, model_validator +from pydantic import Field, RootModel, model_validator +from kf_oj.contracts import judging_v2 as v2 from kf_oj.contracts.judging import ( MAX_CASES, MAX_JOB_WALL_US, @@ -52,3 +53,34 @@ class ProblemImport(WireModel): if len({(c.input_file, c.expected_file) for c in self.cases}) != len(self.cases): raise ValueError("duplicate testcase reference") return self + + +class ProblemImportV2(WireModel): + schema_version: v2.Version + slug: Label + title: Annotated[str, Field(strict=True, min_length=1, max_length=160)] + statement_file: FileName + language_configurations: Annotated[ + tuple[v2.LanguageConfiguration, ...], Field(min_length=1, max_length=4) + ] + comparison: Literal["exact_bytes"] + cases: Annotated[tuple[ImportCase, ...], Field(min_length=1, max_length=MAX_CASES)] + samples: Annotated[tuple[ImportSample, ...], Field(max_length=10)] + + @property + def languages(self) -> tuple[v2.Language, ...]: + return tuple(config.language for config in self.language_configurations) + + @model_validator(mode="after") + def unique(self) -> Self: + if len(set(self.languages)) != len(self.languages): + raise ValueError("duplicate language configuration") + if len({case.key for case in self.cases}) != len(self.cases): + raise ValueError("duplicate testcase key") + if len({(case.input_file, case.expected_file) for case in self.cases}) != len(self.cases): + raise ValueError("duplicate testcase reference") + return self + + +class ImportEnvelope(RootModel[ProblemImport | ProblemImportV2]): + pass diff --git a/backend/src/kf_oj/modules/problems/models.py b/backend/src/kf_oj/modules/problems/models.py index efe754e..7f2bb51 100644 --- a/backend/src/kf_oj/modules/problems/models.py +++ b/backend/src/kf_oj/modules/problems/models.py @@ -13,6 +13,7 @@ from sqlalchemy import ( ForeignKeyConstraint, Index, Integer, + SmallInteger, String, Text, UniqueConstraint, @@ -54,6 +55,17 @@ class ProblemVersion(CreatedAt, Base): CheckConstraint("char_length(title) BETWEEN 1 AND 160", name="title_size"), CheckConstraint("comparison = 'exact_bytes'", name="comparison"), CheckConstraint("job_wall_time_us BETWEEN 1000 AND 3600000000", name="job_wall"), + CheckConstraint("judging_schema_version IN (1,2)", name="judging_schema"), + CheckConstraint( + "(judging_schema_version = 1 AND language_configurations IS NULL " + "AND toolchain IS NOT NULL AND compile_limits IS NOT NULL " + "AND run_limits IS NOT NULL AND job_wall_time_us IS NOT NULL) OR " + "(judging_schema_version = 2 AND language_configurations IS NOT NULL " + "AND toolchain IS NULL AND compile_limits IS NULL " + "AND run_limits IS NULL AND job_wall_time_us IS NULL " + "AND public.kf_valid_language_configurations(languages, language_configurations))", + name="language_configurations", + ), Index("ix_problem_versions_public", "problem_id", "published_at"), ) @@ -63,10 +75,12 @@ class ProblemVersion(CreatedAt, Base): title: Mapped[str] = mapped_column(String(160)) statement: Mapped[str] = mapped_column(Text) languages: Mapped[list[str]] = mapped_column(JSONB) - toolchain: Mapped[dict] = mapped_column(JSONB) - compile_limits: Mapped[dict] = mapped_column(JSONB) - run_limits: Mapped[dict] = mapped_column(JSONB) - job_wall_time_us: Mapped[int] = mapped_column(BigInteger) + judging_schema_version: Mapped[int] = mapped_column(SmallInteger, server_default="1") + language_configurations: Mapped[list[dict] | None] = mapped_column(JSONB(none_as_null=True)) + toolchain: Mapped[dict | None] = mapped_column(JSONB(none_as_null=True)) + compile_limits: Mapped[dict | None] = mapped_column(JSONB(none_as_null=True)) + run_limits: Mapped[dict | None] = mapped_column(JSONB(none_as_null=True)) + job_wall_time_us: Mapped[int | None] = mapped_column(BigInteger) comparison: Mapped[str] = mapped_column(String(16), server_default="exact_bytes") public_samples: Mapped[list[dict]] = mapped_column(JSONB) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/src/kf_oj/modules/problems/public.py b/backend/src/kf_oj/modules/problems/public.py index ecc0a23..4518a08 100644 --- a/backend/src/kf_oj/modules/problems/public.py +++ b/backend/src/kf_oj/modules/problems/public.py @@ -4,7 +4,9 @@ from dataclasses import dataclass from typing import Protocol from uuid import UUID -from kf_oj.contracts.judging import Language, RunLimits, TestCase, Toolchain +from kf_oj.contracts import judging as v1 +from kf_oj.contracts import judging_v2 as v2 +from kf_oj.contracts.judging_versions import Manifest class ProblemUnavailable(ValueError): @@ -15,12 +17,63 @@ class ProblemUnavailable(ValueError): class JudgingVersion: version_id: UUID problem_id: UUID - languages: tuple[Language, ...] - toolchain: Toolchain - compile_limits: RunLimits - run_limits: RunLimits - job_wall_time_us: int - cases: tuple[TestCase, ...] + languages: tuple[v1.Language | v2.Language, ...] + toolchain: v1.Toolchain | None + compile_limits: v1.RunLimits | None + run_limits: v1.RunLimits | None + job_wall_time_us: int | None + cases: tuple[v1.TestCase, ...] + judging_schema_version: int = 1 + language_configurations: tuple[v2.LanguageConfiguration, ...] = () + + def manifest( + self, + evaluation_id: UUID, + submission_id: UUID, + language: v1.Language | v2.Language, + source: v1.ArtifactReference, + ) -> Manifest: + """Build only from the immutable authorized version, preserving v1 wire bytes.""" + if language not in self.languages: + raise ProblemUnavailable("language unavailable") + fields = dict( + schema_version=self.judging_schema_version, + kind="judge.manifest", + evaluation_id=evaluation_id, + submission_id=submission_id, + problem_version_id=self.version_id, + language=language, + source=source, + cases=self.cases, + comparison="exact_bytes", + ) + if self.judging_schema_version == 1: + if self.language_configurations: + raise ProblemUnavailable("inconsistent legacy version") + return v1.JobManifest( + **fields, + toolchain=self.toolchain, + compile_limits=self.compile_limits, + run_limits=self.run_limits, + job_wall_time_us=self.job_wall_time_us, + ) + if self.judging_schema_version != 2 or any( + field is not None + for field in ( + self.toolchain, + self.compile_limits, + self.run_limits, + self.job_wall_time_us, + ) + ): + raise ProblemUnavailable("unsupported judging configuration") + if tuple(c.language for c in self.language_configurations) != self.languages: + raise ProblemUnavailable("inconsistent language configurations") + configuration = next(c for c in self.language_configurations if c.language == language) + return v2.JobManifest( + **fields, + **configuration.model_dump(exclude={"language"}), + ) class ProblemPort(Protocol): diff --git a/backend/src/kf_oj/modules/problems/service.py b/backend/src/kf_oj/modules/problems/service.py index 5e414dd..0e4b4a9 100644 --- a/backend/src/kf_oj/modules/problems/service.py +++ b/backend/src/kf_oj/modules/problems/service.py @@ -5,23 +5,28 @@ from uuid import UUID, uuid4, uuid5 from sqlalchemy import func, select from sqlalchemy.ext.asyncio import async_sessionmaker +from kf_oj.contracts import judging_v2 as v2 from kf_oj.contracts.judging import ( MAX_PACKAGE_BYTES, MAX_SOURCE_BYTES, - AdmissionPolicy, ArtifactReference, - JobManifest, Language, RunLimits, TestCase, Toolchain, decode_wire, ) -from kf_oj.contracts.problems import ProblemDetail, ProblemPage, ProblemSummary, PublicSample +from kf_oj.contracts.problems import ( + ProblemDetail, + ProblemPage, + ProblemSummary, + PublicLanguageConfiguration, + PublicSample, +) from kf_oj.infra.artifacts.models import Artifact from kf_oj.infra.artifacts.repository import ArtifactRepository from kf_oj.modules.problems import repository -from kf_oj.modules.problems.import_format import ProblemImport +from kf_oj.modules.problems.import_format import ImportEnvelope, ProblemImport from kf_oj.modules.problems.local_files import LocalImportFiles from kf_oj.modules.problems.models import Problem, ProblemCase, ProblemVersion from kf_oj.modules.problems.public import JudgingVersion, ProblemUnavailable @@ -32,7 +37,7 @@ class ProblemService: self, sessions: async_sessionmaker, artifacts: ArtifactRepository | None = None, - policy: AdmissionPolicy | None = None, + policy: v2.NodeAdmission | None = None, ): self.sessions, self.artifacts, self.policy = sessions, artifacts, policy @@ -41,7 +46,12 @@ class ProblemService: raise ValueError("artifact store and trusted node policy required for import") files = LocalImportFiles(root) try: - manifest = decode_wire(ProblemImport, files.read(manifest_file, 256 * 1024)) + manifest = decode_wire(ImportEnvelope, files.read(manifest_file, 256 * 1024)).root + stdout_limit = ( + manifest.run_limits.stdout_bytes + if isinstance(manifest, ProblemImport) + else min(c.run_limits.stdout_bytes for c in manifest.language_configurations) + ) statement = files.read(manifest.statement_file, 256 * 1024).decode("utf-8") if not statement or "\x00" in statement or "\x00" in manifest.title: raise ValueError("invalid public text") @@ -52,9 +62,7 @@ class ProblemService: for case in manifest.cases: data = files.read(case.input_file, remaining) remaining -= len(data) - answer = files.read( - case.expected_file, min(remaining, manifest.run_limits.stdout_bytes) - ) + answer = files.read(case.expected_file, min(remaining, stdout_limit)) remaining -= len(answer) payloads.append((data, answer)) samples = tuple( @@ -75,22 +83,27 @@ class ProblemService: ) for case, (data, answer) in zip(manifest.cases, payloads, strict=True) ) - check = JobManifest( - schema_version=1, - kind="judge.manifest", - evaluation_id=uuid4(), - submission_id=uuid4(), - problem_version_id=version_id, - language=manifest.languages[0], - toolchain=manifest.toolchain, - source=ArtifactReference(digest="0" * 64, bytes=MAX_SOURCE_BYTES), - cases=cases, - comparison=manifest.comparison, - compile_limits=manifest.compile_limits, - run_limits=manifest.run_limits, - job_wall_time_us=manifest.job_wall_time_us, + legacy = isinstance(manifest, ProblemImport) + projection = JudgingVersion( + version_id, + uuid4(), + manifest.languages, + manifest.toolchain if legacy else None, + manifest.compile_limits if legacy else None, + manifest.run_limits if legacy else None, + manifest.job_wall_time_us if legacy else None, + cases, + manifest.schema_version, + () if legacy else manifest.language_configurations, ) - self.policy.admit(check, 1) + for language in manifest.languages: + check = projection.manifest( + uuid4(), + uuid4(), + language, + ArtifactReference(digest="0" * 64, bytes=MAX_SOURCE_BYTES), + ) + v2.admit_manifest(self.policy, check, 1) async with self.sessions.begin() as session: # Lock existing and not-yet-existing logical slugs before assigning revisions. await repository.lock_slug(session, manifest.slug) @@ -110,10 +123,18 @@ class ProblemService: title=manifest.title, statement=statement, languages=[lang.value for lang in manifest.languages], - toolchain=manifest.toolchain.model_dump(mode="json"), - compile_limits=manifest.compile_limits.model_dump(mode="json"), - run_limits=manifest.run_limits.model_dump(mode="json"), - job_wall_time_us=manifest.job_wall_time_us, + judging_schema_version=manifest.schema_version, + language_configurations=( + None + if legacy + else [c.model_dump(mode="json") for c in manifest.language_configurations] + ), + toolchain=manifest.toolchain.model_dump(mode="json") if legacy else None, + compile_limits=manifest.compile_limits.model_dump(mode="json") + if legacy + else None, + run_limits=manifest.run_limits.model_dump(mode="json") if legacy else None, + job_wall_time_us=manifest.job_wall_time_us if legacy else None, comparison=manifest.comparison, public_samples=[s.model_dump() for s in samples], ) @@ -176,7 +197,17 @@ class ProblemService: **self._summary(problem, version).model_dump(), statement=version.statement, samples=tuple(PublicSample.model_validate(s) for s in version.public_samples), - run_limits=RunLimits.model_validate(version.run_limits), + run_limits=( + RunLimits.model_validate(version.run_limits) + if version.judging_schema_version == 1 + else None + ), + language_configurations=tuple( + PublicLanguageConfiguration.from_configuration( + v2.LanguageConfiguration.model_validate(c) + ) + for c in version.language_configurations or () + ), ) async def for_submission(self, session, version_id: UUID) -> JudgingVersion: @@ -215,10 +246,24 @@ class ProblemService: return JudgingVersion( version.id, problem.id, - tuple(Language(v) for v in version.languages), - Toolchain.model_validate(version.toolchain), - RunLimits.model_validate(version.compile_limits), - RunLimits.model_validate(version.run_limits), + tuple( + (Language if version.judging_schema_version == 1 else v2.Language)(v) + for v in version.languages + ), + Toolchain.model_validate(version.toolchain) + if version.judging_schema_version == 1 + else None, + RunLimits.model_validate(version.compile_limits) + if version.judging_schema_version == 1 + else None, + RunLimits.model_validate(version.run_limits) + if version.judging_schema_version == 1 + else None, version.job_wall_time_us, tuple(refs), + version.judging_schema_version, + tuple( + v2.LanguageConfiguration.model_validate(c) + for c in version.language_configurations or () + ), ) diff --git a/backend/src/kf_oj/modules/submissions/models.py b/backend/src/kf_oj/modules/submissions/models.py index a16bfe2..d0ed4f5 100644 --- a/backend/src/kf_oj/modules/submissions/models.py +++ b/backend/src/kf_oj/modules/submissions/models.py @@ -28,7 +28,7 @@ class Submission(CreatedAt, Base): __table_args__ = ( UniqueConstraint("account_id", "idempotency_key"), UniqueConstraint("id", "problem_version_id"), - CheckConstraint("language IN ('c17', 'cpp20')", name="language"), + CheckConstraint("language IN ('c17','cpp20','python313','java21')", name="language"), CheckConstraint("request_digest ~ '^[0-9a-f]{64}$'", name="request_digest"), ForeignKeyConstraint( ["problem_version_id", "problem_id"], @@ -44,7 +44,7 @@ class Submission(CreatedAt, Base): account_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id")) problem_id: Mapped[UUID] problem_version_id: Mapped[UUID] - language: Mapped[str] = mapped_column(String(8)) + language: Mapped[str] = mapped_column(String(16)) source_digest: Mapped[str] = mapped_column(String(64)) artifact_state: Mapped[str] = mapped_column(String(8), Computed("'ready'", persisted=True)) idempotency_key: Mapped[UUID] diff --git a/backend/src/kf_oj/modules/submissions/service.py b/backend/src/kf_oj/modules/submissions/service.py index 0d8158d..640d129 100644 --- a/backend/src/kf_oj/modules/submissions/service.py +++ b/backend/src/kf_oj/modules/submissions/service.py @@ -6,7 +6,7 @@ from uuid import uuid4 from sqlalchemy import func, select, text -from kf_oj.contracts.judging import JobManifest +from kf_oj.contracts.judging_v2 import admit_manifest from kf_oj.contracts.submissions import CreateSubmission, SubmissionCreated from kf_oj.infra.artifacts.repository import ArtifactRepository from kf_oj.infra.messaging.models import Outbox @@ -38,7 +38,9 @@ class SubmissionService: async def create(self, principal: Principal, request: CreateSubmission) -> SubmissionCreated: # Validate even trusted non-HTTP callers. No path or argv is accepted. - request = CreateSubmission.model_validate(request.model_dump()) + request = CreateSubmission.model_validate( + request.model_dump(exclude={"language"}) | {"language": request.language} + ) source = request.source.encode("utf-8") request_digest = hashlib.sha256( json.dumps( @@ -92,22 +94,8 @@ class SubmissionService: # but cannot leave an evaluation that refers to non-durable bytes. reference = await self.artifacts.put(session, source) submission_id, evaluation_id = uuid4(), uuid4() - manifest = JobManifest( - schema_version=1, - kind="judge.manifest", - evaluation_id=evaluation_id, - submission_id=submission_id, - problem_version_id=version.version_id, - language=request.language, - toolchain=version.toolchain, - source=reference, - cases=version.cases, - comparison="exact_bytes", - compile_limits=version.compile_limits, - run_limits=version.run_limits, - job_wall_time_us=version.job_wall_time_us, - ) - self.admission.admit(manifest, 1) + manifest = version.manifest(evaluation_id, submission_id, request.language, reference) + admit_manifest(self.admission, manifest, 1) submission = Submission( id=submission_id, account_id=principal.account_id, diff --git a/backend/src/kf_oj/modules/submissions/worker_access.py b/backend/src/kf_oj/modules/submissions/worker_access.py index 1f14fc1..91e2c7a 100644 --- a/backend/src/kf_oj/modules/submissions/worker_access.py +++ b/backend/src/kf_oj/modules/submissions/worker_access.py @@ -64,15 +64,12 @@ class WorkerAccess: raise WorkerAccessDenied("worker snapshot rejected") version = await self.problems.for_submission(session, manifest.problem_version_id) await self.artifacts.check_metadata(session, manifest.source) - if ( - version.problem_id != submission.problem_id - or manifest.language not in version.languages - or manifest.toolchain != version.toolchain - or manifest.cases != version.cases - or manifest.compile_limits != version.compile_limits - or manifest.run_limits != version.run_limits - or manifest.job_wall_time_us != version.job_wall_time_us - ): + expected = version.manifest( + manifest.evaluation_id, manifest.submission_id, manifest.language, manifest.source + ) + if version.problem_id != submission.problem_id or manifest_bytes( + manifest + ) != manifest_bytes(expected): raise WorkerAccessDenied("worker snapshot rejected") return manifest except (ValueError, ProblemUnavailable): diff --git a/backend/tests/test_judging_v2_contracts.py b/backend/tests/test_judging_v2_contracts.py index 1d16382..8c63439 100644 --- a/backend/tests/test_judging_v2_contracts.py +++ b/backend/tests/test_judging_v2_contracts.py @@ -98,25 +98,27 @@ def test_shared_capabilities_refuse_absent_changed_runtime_and_over_budget(): def test_results_bind_exact_source_runtime_attempt_and_case_order(language): manifest = fixture(f"judge-manifest.valid-{language}", v2.JobManifest) result = fixture(f"judge-result.valid-{language}", v2.JudgementReport) - result.validate_for_manifest(manifest, result.manifest_digest) + digest = hashlib.sha256(versions.manifest_bytes(manifest)).hexdigest() + assert digest == (FIXTURES / f"canonical-manifest.{language}.sha256").read_text().strip() + result.validate_for_manifest(manifest, digest) for field, value in (("digest", "f" * 64), ("bytes", manifest.source.bytes + 1)): other = manifest.model_copy( update={"source": manifest.source.model_copy(update={field: value})} ) with pytest.raises(ValueError): - result.validate_for_manifest(other, result.manifest_digest) + result.validate_for_manifest(other, digest) with pytest.raises(ValueError): result.validate_for_manifest(manifest, "f" * 64) changed_runtime = manifest.model_copy( update={"toolchain": manifest.toolchain.model_copy(update={"image_digest": "e" * 64})} ) with pytest.raises(ValueError): - result.validate_for_manifest(changed_runtime, result.manifest_digest) + result.validate_for_manifest(changed_runtime, digest) short = result.model_dump(mode="json") short.update(cases=short["cases"][:1], run_cpu_time_us=100, run_memory_peak_bytes=256 * 1024) valid_partial = v2.JudgementReport.model_validate(short) with pytest.raises(ValueError): - valid_partial.validate_for_manifest(manifest, result.manifest_digest) + valid_partial.validate_for_manifest(manifest, digest) def test_python_check_is_not_an_accepted_result_and_stage_kind_is_bound(): @@ -172,7 +174,9 @@ def test_storage_boundary_reads_both_versions_without_rewriting_or_cross_version (FIXTURES / "judge-manifest.valid-python313.json").read_bytes() ) report = versions.decode_report((FIXTURES / "judge-result.valid-python313.json").read_bytes()) - versions.validate_report(report, manifest, report.manifest_digest) + versions.validate_report( + report, manifest, hashlib.sha256(versions.manifest_bytes(manifest)).hexdigest() + ) for result, task in ((old_report, manifest), (report, old_manifest)): with pytest.raises(ValueError): versions.validate_report(result, task, result.manifest_digest) @@ -184,3 +188,35 @@ def test_storage_boundary_reads_both_versions_without_rewriting_or_cross_version ): with pytest.raises(ValueError): versions.decode_manifest(invalid) + + +def test_registry_is_explicit_for_each_protocol_and_retains_legacy_configuration(): + from kf_oj.core.settings import Settings + + registry = fixture("judge-registry.valid", v2.AdmissionRegistry) + original = ROOT / "tests/fixtures/contracts" + legacy = v1.decode_wire(v1.JobManifest, (original / "judge-manifest.valid.json").read_bytes()) + registry.admit(legacy, 1) + assert registry.legacy.model_dump(mode="json") == json.loads( + (original / "judge-admission.valid.json").read_text() + ) + for language in v2.Language: + manifest = fixture(f"judge-manifest.valid-{language}", v2.JobManifest) + registry.admit(manifest, 1) + with pytest.raises(ValueError, match="protocol"): + v2.admit_manifest(registry.legacy, manifest, 1) + for field, value in (("image_digest", "f" * 64), ("runtime_version", "other")): + changed = manifest.model_copy( + update={"toolchain": manifest.toolchain.model_copy(update={field: value})} + ) + with pytest.raises(ValueError): + registry.admit(changed, 1) + with pytest.raises(ValueError, match="protocol"): + v2.admit_manifest(registry.multi_runtime, legacy, 1) + for policy in (registry.legacy, registry.multi_runtime, registry): + settings = Settings(_env_file=None, judge_admission=policy.model_dump(mode="json")) + assert type(settings.judge_admission) is type(policy) + raw = registry.model_dump(mode="json") + raw["multi_runtime"]["prefetch"] = 2 + with pytest.raises(ValueError): + Settings(_env_file=None, judge_admission=raw) diff --git a/backend/tests/test_multi_runtime_versions.py b/backend/tests/test_multi_runtime_versions.py new file mode 100644 index 0000000..c11432e --- /dev/null +++ b/backend/tests/test_multi_runtime_versions.py @@ -0,0 +1,454 @@ +"""Real temporary PostgreSQL data/authority acceptance; no submitted code is executed.""" + +import asyncio +import copy +import hashlib +import json +import secrets +import subprocess +from pathlib import Path +from uuid import uuid4 + +import asyncpg +import httpx +import pytest +from conftest import connect, migrate +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from test_database_foundation import fixture_rows +from test_problems import import_fixture, platform_role +from test_worker_artifacts import install_attempt + +from kf_oj.api.app import create_app +from kf_oj.contracts import judging as v1 +from kf_oj.contracts import judging_v2 as v2 +from kf_oj.contracts.judging_versions import manifest_bytes +from kf_oj.contracts.submissions import CreateSubmission +from kf_oj.core.settings import Settings +from kf_oj.infra.artifacts.repository import ArtifactRepository +from kf_oj.infra.artifacts.store import ArtifactStore +from kf_oj.infra.messaging.models import Outbox +from kf_oj.modules.problems.models import ProblemVersion +from kf_oj.modules.problems.service import ProblemService +from kf_oj.modules.submissions.models import Evaluation, Submission +from kf_oj.modules.submissions.public import IdempotencyConflict, SubmissionUnavailable + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "tests/fixtures/contracts-v2" + + +def configurations(): + return [ + { + key: json.loads((FIXTURES / f"judge-manifest.valid-{language}.json").read_text())[key] + for key in ("language", "toolchain", "compile_limits", "run_limits", "job_wall_time_us") + } + for language in v2.Language + ] + + +def multi_import(root, *, slug="multi-runtime"): + legacy, _ = import_fixture(root, slug=slug) + manifest = { + key: legacy[key] + for key in ("slug", "title", "statement_file", "comparison", "cases", "samples") + } + manifest.update(schema_version=2, language_configurations=configurations()) + (root / "problem.json").write_text(json.dumps(manifest)) + registry = v1.decode_wire( + v2.AdmissionRegistry, (FIXTURES / "judge-registry.valid.json").read_bytes() + ) + return manifest, registry + + +def test_upgrade_preserves_published_rows_and_legacy_attempt_bytes(postgres_url): + migrate(postgres_url, "downgrade", "0001_judging_foundation") + + async def seed(): + c = await connect(postgres_url) + try: + ids = await fixture_rows(c) + manifest = v1.decode_wire( + v1.JobManifest, + (ROOT / "tests/fixtures/contracts/judge-manifest.valid.json").read_bytes(), + ) + canonical = v1.manifest_bytes(manifest) + await c.execute("SET ROLE kf_oj_coordinator") + await c.execute( + """INSERT INTO judge_attempts + (id,evaluation_id,worker_id,number,fencing_version,lease_token_hash, + expires_at,manifest,manifest_digest) + VALUES ($1,$2,$3,1,1,$4,now()+interval '30 seconds',$5,$6)""", + uuid4(), + ids[5], + uuid4(), + "d" * 64, + canonical.decode(), + hashlib.sha256(canonical).hexdigest(), + ) + await c.execute("RESET ROLE") + return await snapshot(c) + finally: + await c.close() + + async def snapshot(c): + version = await c.fetchval( + "SELECT (to_jsonb(p)-'judging_schema_version'-'language_configurations')::text " + "FROM problem_versions p" + ) + submission = await c.fetchval("SELECT to_jsonb(s)::text FROM submissions s") + attempt = await c.fetchrow("SELECT manifest,manifest_digest FROM judge_attempts") + decoded = v1.JobManifest.model_validate_json(attempt["manifest"]) + assert hashlib.sha256(v1.manifest_bytes(decoded)).hexdigest() == attempt["manifest_digest"] + return version, submission, dict(attempt) + + before = asyncio.run(seed()) + migrate(postgres_url, "upgrade", "head") + + async def read(): + c = await connect(postgres_url) + try: + assert await c.fetchval("SELECT judging_schema_version FROM problem_versions") == 1 + assert await c.fetchval("SELECT language_configurations FROM problem_versions") is None + return await snapshot(c) + finally: + await c.close() + + assert asyncio.run(read()) == before + migrate(postgres_url, "downgrade", "0001_judging_foundation") + migrate(postgres_url, "upgrade", "head") + assert asyncio.run(read()) == before + migrate(postgres_url, "check") + + +async def insert_version(c, problem, cfgs, *, languages=None, revision=1): + version = uuid4() + await c.execute( + """INSERT INTO problem_versions + (id,problem_id,revision,title,statement,languages,judging_schema_version, + language_configurations,public_samples) + VALUES ($1,$2,$3,'Multi runtime','Public',$4,2,$5,'[]')""", + version, + problem, + revision, + json.dumps(languages if languages is not None else [c["language"] for c in cfgs]), + json.dumps(cfgs), + ) + return version + + +def test_sql_configuration_constraints_authority_and_downgrade_refusal(postgres_url): + async def run(): + c = await connect(postgres_url) + try: + await c.execute("SET ROLE kf_oj_platform") + problem = uuid4() + await c.execute("INSERT INTO problems(id,slug) VALUES($1,'multi-sql')", problem) + cfgs = configurations() + bad_sets = [] + for field, value in ( + ("language", "shell"), + ("toolchain", None), + ("compile_limits", None), + ("job_wall_time_us", True), + ("job_wall_time_us", 1000), + ): + bad = copy.deepcopy(cfgs) + bad[0][field] = value + bad_sets.append(bad) + for field, value in ( + ("image_digest", "invalid"), + ("image_revision", True), + ("runtime_version", "3.14.0"), + ("image_kind", "linux_gnu_cxx_x86_64_v1"), + ): + bad = copy.deepcopy(cfgs) + bad[2]["toolchain"][field] = value + bad_sets.append(bad) + for field, value in ( + ("processes", 8), + ("memory_bytes", 64 * v1.MIB), + ("cpu_time_us", "1"), + ("stack_bytes", 1), + ): + bad = copy.deepcopy(cfgs) + bad[3]["run_limits"][field] = value + bad_sets.append(bad) + bad = copy.deepcopy(cfgs) + bad[0]["argv"] = ["/bin/sh"] + bad_sets.append(bad) + bad = copy.deepcopy(cfgs) + bad[0]["toolchain"]["host_path"] = "/host/private" + bad_sets.append(bad) + bad_sets.extend([[], None, [cfgs[0], cfgs[0]]]) + for bad in bad_sets: + with pytest.raises(asyncpg.CheckViolationError): + await insert_version(c, problem, bad, languages=[c["language"] for c in cfgs]) + with pytest.raises(asyncpg.CheckViolationError): + await insert_version( + c, problem, cfgs, languages=["java21", "python313", "cpp20", "c17"] + ) + version = await insert_version(c, problem, cfgs) + with pytest.raises(asyncpg.InsufficientPrivilegeError): + await c.execute("UPDATE problem_versions SET language_configurations='[]'") + await c.execute("RESET ROLE") + with pytest.raises(asyncpg.CheckViolationError): + await c.execute("UPDATE problem_versions SET toolchain='{}'") + await c.execute("SET ROLE kf_oj_coordinator") + with pytest.raises(asyncpg.InsufficientPrivilegeError): + await c.execute("INSERT INTO problems(id,slug) VALUES($1,'forbidden')", uuid4()) + with pytest.raises(asyncpg.InsufficientPrivilegeError): + await c.execute("UPDATE problem_versions SET language_configurations='[]'") + return version + finally: + await c.close() + + version = asyncio.run(run()) + with pytest.raises(subprocess.CalledProcessError) as rejected: + migrate(postgres_url, "downgrade", "0001_judging_foundation") + assert b"cannot downgrade while multi-runtime data exists" in rejected.value.stderr + + async def preserved(): + c = await connect(postgres_url) + try: + assert ( + await c.fetchval("SELECT count(*) FROM problem_versions WHERE id=$1", version) == 1 + ) + assert ( + await c.fetchval("SELECT version_num FROM alembic_version") == "0002_multi_runtime" + ) + finally: + await c.close() + + asyncio.run(preserved()) + + +def test_real_four_language_import_public_projection_and_initial_submissions( + postgres_url, tmp_path, monkeypatch +): + _, registry = multi_import(tmp_path / "multi") + import_fixture(tmp_path / "legacy", slug="legacy-runtime") + + async def run(): + engine = create_async_engine(postgres_url) + platform_role(engine) + mod = __import__("kf_oj.api.app", fromlist=["build_engine"]) + monkeypatch.setattr(mod, "build_engine", lambda _: engine) + app = create_app( + Settings( + _env_file=None, + environment="test", + database_url=postgres_url, + artifact_root=tmp_path / "artifacts", + judge_admission=registry, + ) + ) + async with app.router.lifespan_context(app): + owner = await app.state.identity.create_account( + "multi_owner", secrets.token_urlsafe(24) + ) + version = await app.state.problems.import_directory(tmp_path / "multi") + old = await app.state.problems.import_directory(tmp_path / "legacy") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + public = await client.get(f"/api/v1/problems/versions/{version}") + assert public.status_code == 200 + data = public.json() + assert data["run_limits"] is None + assert data["languages"] == [v.value for v in v2.Language] + assert [c["source_name"] for c in data["language_configurations"]] == [ + "main.c", + "main.cpp", + "main.py", + "Main.java", + ] + assert data["language_configurations"][2]["preparation_kind"] == "syntax_check" + assert data["language_configurations"][3]["entrypoint"] == "Main" + for private in ( + "image_digest", + "image_kind", + "hidden-input", + "hidden-expected", + str(tmp_path), + ): + assert private not in public.text + for language in v2.Language: + request = CreateSubmission( + problem_version_id=version, + language=language, + idempotency_key=uuid4(), + source="Synthetic transport bytes; never executed", + ) + results = await asyncio.gather( + *(app.state.submissions.create(owner, request) for _ in range(2)) + ) + assert results[0].submission_id == results[1].submission_id + async with app.state.sessions() as s: + projected = await app.state.problems.for_submission(s, version) + ref = app.state.problems.artifacts.store.reference(request.source.encode()) + manifest = projected.manifest( + results[0].evaluation_id, results[0].submission_id, language, ref + ) + assert manifest.schema_version == 2 and manifest.toolchain.runtime_version + v2.admit_manifest(registry, manifest, 1) + assert manifest_bytes(manifest) + with pytest.raises(IdempotencyConflict): + await app.state.submissions.create( + owner, request.model_copy(update={"source": "Different"}) + ) + with pytest.raises(SubmissionUnavailable): + await app.state.submissions.create( + owner, + CreateSubmission( + problem_version_id=old, + language="python313", + idempotency_key=uuid4(), + source="x", + ), + ) + async with app.state.sessions() as s: + for model in (Submission, Evaluation, Outbox): + assert await s.scalar(select(func.count()).select_from(model)) == 4 + row = await s.get(ProblemVersion, version) + assert row.toolchain is None and row.run_limits is None + assert len(row.language_configurations) == 4 + # Actual SQL membership guard also rejects new-language rows on old versions. + admin = await connect(postgres_url) + try: + source_digest = await admin.fetchval( + "SELECT source_digest FROM submissions LIMIT 1" + ) + problem_id = await admin.fetchval( + "SELECT problem_id FROM problem_versions WHERE id=$1", old + ) + await admin.execute("SET ROLE kf_oj_platform") + with pytest.raises(asyncpg.CheckViolationError): + await admin.execute( + """INSERT INTO submissions + (id,account_id,problem_id,problem_version_id,language,source_digest, + idempotency_key,request_digest) + VALUES($1,$2,$3,$4,'java21',$5,$6,$7)""", + uuid4(), + owner.account_id, + problem_id, + old, + source_digest, + uuid4(), + "a" * 64, + ) + await admin.execute("RESET ROLE") + with pytest.raises(asyncpg.CheckViolationError): + await admin.execute( + "UPDATE problem_versions SET language_configurations='[]' WHERE id=$1", + version, + ) + finally: + await admin.close() + + asyncio.run(run()) + + +def test_import_refuses_missing_protocol_and_runtime_identity_without_writes( + postgres_url, tmp_path +): + manifest, registry = multi_import(tmp_path / "multi") + store = ArtifactStore(tmp_path / "artifacts") + + async def run(): + engine = create_async_engine(postgres_url) + platform_role(engine) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + service = ProblemService(sessions, ArtifactRepository(store), registry.legacy) + with pytest.raises(ValueError, match="protocol"): + await service.import_directory(tmp_path / "multi") + manifest["language_configurations"][2]["toolchain"]["image_digest"] = "f" * 64 + (tmp_path / "multi/problem.json").write_text(json.dumps(manifest)) + service = ProblemService(sessions, ArtifactRepository(store), registry) + with pytest.raises(ValueError, match="capability"): + await service.import_directory(tmp_path / "multi") + async with sessions() as s: + assert await s.scalar(select(func.count()).select_from(ProblemVersion)) == 0 + finally: + await engine.dispose() + + try: + asyncio.run(run()) + finally: + store.close() + + +def test_v2_worker_reads_immutable_language_snapshot_with_live_lease(postgres_url, tmp_path): + _, registry = multi_import(tmp_path / "multi") + + async def run(): + admin = create_async_engine(postgres_url) + sessions = async_sessionmaker(admin, expire_on_commit=False) + app = create_app( + Settings( + _env_file=None, + environment="test", + database_url=postgres_url, + artifact_root=tmp_path / "artifacts", + judge_admission=registry, + ) + ) + try: + async with app.router.lifespan_context(app): + platform_role(app.state.sessions.kw["bind"]) + account = await app.state.identity.create_account( + "worker_owner", secrets.token_urlsafe(24) + ) + version = await app.state.problems.import_directory(tmp_path / "multi") + for language in ("python313", "java21"): + binding, token, manifest = await install_attempt( + sessions, app, account, version, language=language + ) + # Controlled DB lease fixture, not an actual Coordinator claim or execution. + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + path = ( + f"/api/v1/internal/evaluations/{binding.evaluation_id}" + f"/attempts/{binding.attempt_id}" + ) + headers = { + "Authorization": "Bearer " + token, + "X-Worker-ID": str(binding.worker_id), + "X-Fencing-Version": "1", + } + response = await client.get(path + "/manifest", headers=headers) + assert response.status_code == 200 and response.content == manifest_bytes( + manifest + ) + assert manifest.schema_version == 2 and manifest.language == language + response = await client.get( + path + "/artifacts/" + manifest.source.digest, headers=headers + ) + assert ( + response.status_code == 200 and response.content == b"synthetic source" + ) + assert ( + await client.get( + path + "/manifest", headers=headers | {"X-Worker-ID": str(uuid4())} + ) + ).status_code == 404 + assert ( + await client.get( + path + "/manifest", headers=headers | {"X-Fencing-Version": "2"} + ) + ).status_code == 404 + async with sessions.begin() as s: + await s.execute(text("SET LOCAL ROLE kf_oj_coordinator")) + await s.execute( + text("UPDATE evaluations SET state='cancelling' WHERE id=:id"), + {"id": binding.evaluation_id}, + ) + assert ( + await client.get(path + "/manifest", headers=headers) + ).status_code == 404 + finally: + await admin.dispose() + + asyncio.run(run()) diff --git a/backend/tests/test_worker_artifacts.py b/backend/tests/test_worker_artifacts.py index 8b23846..9f5410e 100644 --- a/backend/tests/test_worker_artifacts.py +++ b/backend/tests/test_worker_artifacts.py @@ -30,7 +30,9 @@ def test_canonical_manifest_snapshot(): assert manifest_bytes(manifest) == (root / "canonical-manifest.v1.txt").read_bytes() -async def install_attempt(sessions, app, account, version_id, *, source=b"synthetic source"): +async def install_attempt( + sessions, app, account, version_id, *, source=b"synthetic source", language="c17" +): # Explicit controlled DB fixture, not a simulated Coordinator API or claimed execution. # E implements actual transactional claim; here the real B01 schema protects associations. submission_id, evaluation_id, attempt_id, worker_id = (uuid4() for _ in range(4)) @@ -44,7 +46,7 @@ async def install_attempt(sessions, app, account, version_id, *, source=b"synthe account_id=account.account_id, problem_id=projected.problem_id, problem_version_id=version_id, - language="c17", + language=language, source_digest=source_ref.digest, idempotency_key=uuid4(), request_digest="e" * 64, @@ -58,21 +60,7 @@ async def install_attempt(sessions, app, account, version_id, *, source=b"synthe id=evaluation_id, submission_id=submission_id, problem_version_id=version_id ) ) - manifest = JobManifest( - schema_version=1, - kind="judge.manifest", - evaluation_id=evaluation_id, - submission_id=submission_id, - problem_version_id=version_id, - language="c17", - toolchain=projected.toolchain, - source=source_ref, - cases=projected.cases, - comparison="exact_bytes", - compile_limits=projected.compile_limits, - run_limits=projected.run_limits, - job_wall_time_us=projected.job_wall_time_us, - ) + manifest = projected.manifest(evaluation_id, submission_id, language, source_ref) async with sessions.begin() as session: await session.execute(text("SET LOCAL ROLE kf_oj_coordinator")) now = await session.scalar(select(func.now())) diff --git a/contracts/openapi/platform.v1.json b/contracts/openapi/platform.v1.json index 827833c..9640b05 100644 --- a/contracts/openapi/platform.v1.json +++ b/contracts/openapi/platform.v1.json @@ -72,7 +72,7 @@ "type": "string" }, "language": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/Language-Input" }, "problem_version_id": { "format": "uuid", @@ -202,7 +202,7 @@ "type": "string" }, "language": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging__Language" }, "problem_version_id": { "format": "uuid", @@ -247,10 +247,12 @@ "title": "JobManifest", "type": "object" }, - "Language": { + "Language-Input": { "enum": [ "c17", - "cpp20" + "cpp20", + "python313", + "java21" ], "title": "Language", "type": "string" @@ -306,6 +308,14 @@ "title": "LoginResponse", "type": "object" }, + "PreparationKind": { + "enum": [ + "compile", + "syntax_check" + ], + "title": "PreparationKind", + "type": "string" + }, "ProblemDetail": { "additionalProperties": false, "properties": { @@ -314,9 +324,17 @@ "title": "Id", "type": "string" }, + "language_configurations": { + "default": [], + "items": { + "$ref": "#/components/schemas/PublicLanguageConfiguration" + }, + "title": "Language Configurations", + "type": "array" + }, "languages": { "items": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" }, "title": "Languages", "type": "array" @@ -326,7 +344,14 @@ "type": "integer" }, "run_limits": { - "$ref": "#/components/schemas/RunLimits" + "anyOf": [ + { + "$ref": "#/components/schemas/RunLimits" + }, + { + "type": "null" + } + ] }, "samples": { "items": { @@ -408,7 +433,7 @@ }, "languages": { "items": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" }, "title": "Languages", "type": "array" @@ -554,6 +579,51 @@ "title": "PublicJudgement", "type": "object" }, + "PublicLanguageConfiguration": { + "additionalProperties": false, + "properties": { + "compile_limits": { + "$ref": "#/components/schemas/RunLimits" + }, + "entrypoint": { + "title": "Entrypoint", + "type": "string" + }, + "job_wall_time_us": { + "title": "Job Wall Time Us", + "type": "integer" + }, + "language": { + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" + }, + "preparation_kind": { + "$ref": "#/components/schemas/PreparationKind" + }, + "run_limits": { + "$ref": "#/components/schemas/RunLimits" + }, + "runtime_version": { + "title": "Runtime Version", + "type": "string" + }, + "source_name": { + "title": "Source Name", + "type": "string" + } + }, + "required": [ + "language", + "runtime_version", + "source_name", + "entrypoint", + "preparation_kind", + "compile_limits", + "run_limits", + "job_wall_time_us" + ], + "title": "PublicLanguageConfiguration", + "type": "object" + }, "PublicSample": { "additionalProperties": false, "properties": { @@ -674,7 +744,7 @@ "type": "string" }, "language": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" }, "problem_version_id": { "format": "uuid", @@ -740,7 +810,7 @@ ] }, "language": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" }, "problem_version_id": { "format": "uuid", @@ -841,7 +911,7 @@ "additionalProperties": false, "properties": { "language": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" }, "source": { "maxLength": 262144, @@ -890,7 +960,7 @@ ] }, "language": { - "$ref": "#/components/schemas/Language" + "$ref": "#/components/schemas/kf_oj__contracts__judging_v2__Language" }, "problem_version_id": { "format": "uuid", @@ -1052,6 +1122,24 @@ ], "title": "Verdict", "type": "string" + }, + "kf_oj__contracts__judging__Language": { + "enum": [ + "c17", + "cpp20" + ], + "title": "Language", + "type": "string" + }, + "kf_oj__contracts__judging_v2__Language": { + "enum": [ + "c17", + "cpp20", + "python313", + "java21" + ], + "title": "Language", + "type": "string" } }, "securitySchemes": { diff --git a/contracts/schemas/judge-registry.v2.schema.json b/contracts/schemas/judge-registry.v2.schema.json new file mode 100644 index 0000000..d5f1375 --- /dev/null +++ b/contracts/schemas/judge-registry.v2.schema.json @@ -0,0 +1,349 @@ +{ + "$comment": "Multi-runtime protocol. Also validate runtime/source/attempt bindings, startup and node budgets, canonical bounded class sets and result consistency.", + "$defs": { + "Language": { + "enum": [ + "c17", + "cpp20", + "python313", + "java21" + ], + "title": "Language", + "type": "string" + }, + "RunLimits": { + "additionalProperties": false, + "properties": { + "cpu_time_us": { + "maximum": 60000000, + "minimum": 1000, + "title": "Cpu Time Us", + "type": "integer" + }, + "file_bytes": { + "maximum": 67108864, + "minimum": 1, + "title": "File Bytes", + "type": "integer" + }, + "memory_bytes": { + "maximum": 2147483648, + "minimum": 1, + "title": "Memory Bytes", + "type": "integer" + }, + "open_files": { + "maximum": 1024, + "minimum": 3, + "title": "Open Files", + "type": "integer" + }, + "processes": { + "maximum": 128, + "minimum": 1, + "title": "Processes", + "type": "integer" + }, + "stack_bytes": { + "maximum": 268435456, + "minimum": 1, + "title": "Stack Bytes", + "type": "integer" + }, + "stderr_bytes": { + "maximum": 16777216, + "minimum": 1, + "title": "Stderr Bytes", + "type": "integer" + }, + "stdout_bytes": { + "maximum": 16777216, + "minimum": 1, + "title": "Stdout Bytes", + "type": "integer" + }, + "wall_time_us": { + "maximum": 120000000, + "minimum": 1000, + "title": "Wall Time Us", + "type": "integer" + }, + "workdir_bytes": { + "maximum": 268435456, + "minimum": 1, + "title": "Workdir Bytes", + "type": "integer" + } + }, + "required": [ + "cpu_time_us", + "wall_time_us", + "memory_bytes", + "stack_bytes", + "processes", + "open_files", + "stdout_bytes", + "stderr_bytes", + "file_bytes", + "workdir_bytes" + ], + "title": "RunLimits", + "type": "object" + }, + "RuntimeCapability": { + "additionalProperties": false, + "properties": { + "compile_ceiling": { + "$ref": "#/$defs/RunLimits" + }, + "language": { + "$ref": "#/$defs/Language" + }, + "run_ceiling": { + "$ref": "#/$defs/RunLimits" + }, + "toolchain": { + "$ref": "#/$defs/RuntimeIdentity" + } + }, + "required": [ + "language", + "toolchain", + "compile_ceiling", + "run_ceiling" + ], + "title": "RuntimeCapability", + "type": "object" + }, + "RuntimeIdentity": { + "additionalProperties": false, + "description": "An exact identity matched against trusted configuration, never a host path.", + "properties": { + "image_digest": { + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-f]{64}$", + "title": "Image Digest", + "type": "string" + }, + "image_kind": { + "enum": [ + "linux_gnu_cxx_x86_64_v1", + "linux_cpython313_x86_64_v1", + "linux_openjdk21_x86_64_v1" + ], + "title": "Image Kind", + "type": "string" + }, + "image_revision": { + "maximum": 4294967295, + "minimum": 1, + "title": "Image Revision", + "type": "integer" + }, + "runtime_version": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$", + "title": "Runtime Version", + "type": "string" + }, + "toolchain_id": { + "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$", + "title": "Toolchain Id", + "type": "string" + } + }, + "required": [ + "toolchain_id", + "image_kind", + "image_revision", + "image_digest", + "runtime_version" + ], + "title": "RuntimeIdentity", + "type": "object" + }, + "Toolchain": { + "additionalProperties": false, + "properties": { + "image_kind": { + "const": "linux_gnu_cxx_x86_64_v1", + "title": "Image Kind", + "type": "string" + }, + "image_revision": { + "maximum": 4294967295, + "minimum": 1, + "title": "Image Revision", + "type": "integer" + }, + "toolchain_id": { + "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$", + "title": "Toolchain Id", + "type": "string" + } + }, + "required": [ + "toolchain_id", + "image_kind", + "image_revision" + ], + "title": "Toolchain", + "type": "object" + }, + "kf_oj__contracts__judging__AdmissionPolicy": { + "additionalProperties": false, + "description": "Trusted node policy. A manifest never supplies or replaces this policy.", + "properties": { + "compile_ceiling": { + "$ref": "#/$defs/RunLimits" + }, + "max_concurrent_jobs": { + "default": 1, + "maximum": 64, + "minimum": 1, + "title": "Max Concurrent Jobs", + "type": "integer" + }, + "max_job_wall_time_us": { + "maximum": 3600000000, + "minimum": 1000, + "title": "Max Job Wall Time Us", + "type": "integer" + }, + "max_package_bytes": { + "maximum": 67108864, + "minimum": 1, + "title": "Max Package Bytes", + "type": "integer" + }, + "max_retries": { + "default": 2, + "maximum": 4, + "minimum": 0, + "title": "Max Retries", + "type": "integer" + }, + "prefetch": { + "default": 1, + "maximum": 64, + "minimum": 1, + "title": "Prefetch", + "type": "integer" + }, + "run_ceiling": { + "$ref": "#/$defs/RunLimits" + }, + "toolchain": { + "$ref": "#/$defs/Toolchain" + } + }, + "required": [ + "max_job_wall_time_us", + "max_package_bytes", + "toolchain", + "compile_ceiling", + "run_ceiling" + ], + "title": "AdmissionPolicy", + "type": "object" + }, + "kf_oj__contracts__judging_v2__AdmissionPolicy": { + "additionalProperties": false, + "properties": { + "capabilities": { + "items": { + "$ref": "#/$defs/RuntimeCapability" + }, + "maxItems": 4, + "minItems": 1, + "title": "Capabilities", + "type": "array" + }, + "max_concurrent_jobs": { + "default": 1, + "maximum": 64, + "minimum": 1, + "title": "Max Concurrent Jobs", + "type": "integer" + }, + "max_job_wall_time_us": { + "maximum": 3600000000, + "minimum": 1000, + "title": "Max Job Wall Time Us", + "type": "integer" + }, + "max_package_bytes": { + "maximum": 67108864, + "minimum": 1, + "title": "Max Package Bytes", + "type": "integer" + }, + "max_retries": { + "default": 2, + "maximum": 4, + "minimum": 0, + "title": "Max Retries", + "type": "integer" + }, + "prefetch": { + "default": 1, + "maximum": 64, + "minimum": 1, + "title": "Prefetch", + "type": "integer" + }, + "schema_version": { + "const": 2, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "schema_version", + "max_job_wall_time_us", + "max_package_bytes", + "capabilities" + ], + "title": "AdmissionPolicy", + "type": "object" + } + }, + "$id": "urn:kf-oj:judge-registry:v2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Explicit operator opt-in for both protocol generations; no silent conversion.", + "properties": { + "legacy": { + "anyOf": [ + { + "$ref": "#/$defs/kf_oj__contracts__judging__AdmissionPolicy" + }, + { + "type": "null" + } + ] + }, + "multi_runtime": { + "anyOf": [ + { + "$ref": "#/$defs/kf_oj__contracts__judging_v2__AdmissionPolicy" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "const": 2, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "schema_version", + "legacy", + "multi_runtime" + ], + "title": "AdmissionRegistry", + "type": "object" +} diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index db5fc3d..ec4b37f 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -254,7 +254,7 @@ export interface components { * Format: uuid */ idempotency_key: string; - language: components["schemas"]["Language"]; + language: components["schemas"]["Language-Input"]; /** * Problem Version Id * Format: uuid @@ -320,7 +320,7 @@ export interface components { * @constant */ kind: "judge.manifest"; - language: components["schemas"]["Language"]; + language: components["schemas"]["kf_oj__contracts__judging__Language"]; /** * Problem Version Id * Format: uuid @@ -344,7 +344,7 @@ export interface components { * Language * @enum {string} */ - Language: "c17" | "cpp20"; + "Language-Input": "c17" | "cpp20" | "python313" | "java21"; /** LoginRequest */ LoginRequest: { /** @@ -366,6 +366,11 @@ export interface components { expires_at: string; user: components["schemas"]["CurrentUser"]; }; + /** + * PreparationKind + * @enum {string} + */ + PreparationKind: "compile" | "syntax_check"; /** ProblemDetail */ ProblemDetail: { /** @@ -373,11 +378,16 @@ export interface components { * Format: uuid */ id: string; + /** + * Language Configurations + * @default [] + */ + language_configurations: components["schemas"]["PublicLanguageConfiguration"][]; /** Languages */ - languages: components["schemas"]["Language"][]; + languages: components["schemas"]["kf_oj__contracts__judging_v2__Language"][]; /** Revision */ revision: number; - run_limits: components["schemas"]["RunLimits"]; + run_limits: components["schemas"]["RunLimits"] | null; /** Samples */ samples: components["schemas"]["PublicSample"][]; /** Slug */ @@ -407,7 +417,7 @@ export interface components { */ id: string; /** Languages */ - languages: components["schemas"]["Language"][]; + languages: components["schemas"]["kf_oj__contracts__judging_v2__Language"][]; /** Revision */ revision: number; /** Slug */ @@ -446,6 +456,21 @@ export interface components { /** Memory Peak Bytes */ memory_peak_bytes: number | null; }; + /** PublicLanguageConfiguration */ + PublicLanguageConfiguration: { + compile_limits: components["schemas"]["RunLimits"]; + /** Entrypoint */ + entrypoint: string; + /** Job Wall Time Us */ + job_wall_time_us: number; + language: components["schemas"]["kf_oj__contracts__judging_v2__Language"]; + preparation_kind: components["schemas"]["PreparationKind"]; + run_limits: components["schemas"]["RunLimits"]; + /** Runtime Version */ + runtime_version: string; + /** Source Name */ + source_name: string; + }; /** PublicSample */ PublicSample: { /** @@ -494,7 +519,7 @@ export interface components { * Format: uuid */ evaluation_id: string; - language: components["schemas"]["Language"]; + language: components["schemas"]["kf_oj__contracts__judging_v2__Language"]; /** * Problem Version Id * Format: uuid @@ -526,7 +551,7 @@ export interface components { */ evaluation_id: string; failure_code: components["schemas"]["FailureCode"] | null; - language: components["schemas"]["Language"]; + language: components["schemas"]["kf_oj__contracts__judging_v2__Language"]; /** * Problem Version Id * Format: uuid @@ -559,7 +584,7 @@ export interface components { }; /** SubmissionSource */ SubmissionSource: { - language: components["schemas"]["Language"]; + language: components["schemas"]["kf_oj__contracts__judging_v2__Language"]; /** Source */ source: string; /** @@ -583,7 +608,7 @@ export interface components { */ evaluation_id: string; failure_code: components["schemas"]["FailureCode"] | null; - language: components["schemas"]["Language"]; + language: components["schemas"]["kf_oj__contracts__judging_v2__Language"]; /** * Problem Version Id * Format: uuid @@ -646,6 +671,16 @@ export interface components { * @enum {string} */ Verdict: "accepted" | "wrong_answer" | "compilation_error" | "runtime_error" | "time_limit_exceeded" | "memory_limit_exceeded" | "output_limit_exceeded" | "cancelled" | "system_error"; + /** + * Language + * @enum {string} + */ + kf_oj__contracts__judging__Language: "c17" | "cpp20"; + /** + * Language + * @enum {string} + */ + kf_oj__contracts__judging_v2__Language: "c17" | "cpp20" | "python313" | "java21"; }; responses: never; parameters: never; diff --git a/frontend/src/views/ProblemDetailView.vue b/frontend/src/views/ProblemDetailView.vue index 0bcaef6..3b52ac4 100644 --- a/frontend/src/views/ProblemDetailView.vue +++ b/frontend/src/views/ProblemDetailView.vue @@ -37,6 +37,11 @@ const wireLanguage = computed(() => const supported = computed( () => wireLanguage.value !== null && !!problem.value?.languages.includes(wireLanguage.value), ) +const runLimits = computed( + () => + problem.value?.language_configurations?.find((c) => c.language === wireLanguage.value) + ?.run_limits ?? problem.value?.run_limits, +) const draftText = computed( () => ({ @@ -182,9 +187,10 @@ onBeforeUnmount(() => {