labtasker-server 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import sqlalchemy as sa
6
+ from alembic import op
7
+
8
+ revision: str = "0001_initial"
9
+ down_revision: str | None = None
10
+ branch_labels: str | Sequence[str] | None = None
11
+ depends_on: str | Sequence[str] | None = None
12
+
13
+
14
+ def upgrade() -> None:
15
+ op.create_table("queues", sa.Column("name", sa.String(length=128), primary_key=True))
16
+ op.create_table(
17
+ "tasks",
18
+ sa.Column("queue_name", sa.String(length=128), nullable=False),
19
+ sa.Column("task_id", sa.String(length=14), nullable=False),
20
+ sa.Column("status", sa.String(length=16), nullable=False),
21
+ sa.Column("name", sa.String(length=256)),
22
+ sa.Column("args_json", sa.Text(), nullable=False),
23
+ sa.Column("metadata_json", sa.Text(), nullable=False),
24
+ sa.Column("result_json", sa.Text(), nullable=False),
25
+ sa.Column("priority", sa.Integer(), nullable=False),
26
+ sa.Column("attempt", sa.Integer(), nullable=False),
27
+ sa.Column("max_attempts", sa.Integer(), nullable=False),
28
+ sa.Column("created_at_us", sa.Integer(), nullable=False),
29
+ sa.Column("updated_at_us", sa.Integer(), nullable=False),
30
+ sa.Column("last_route", sa.String(length=128)),
31
+ sa.Column("started_at_us", sa.Integer()),
32
+ sa.Column("finished_at_us", sa.Integer()),
33
+ sa.Column("last_error_json", sa.Text()),
34
+ sa.Column("creation_hash", sa.String(length=64), nullable=False),
35
+ sa.Column("active_run_id", sa.String(length=14)),
36
+ sa.Column("lease_expires_at_us", sa.Integer()),
37
+ sa.Column("last_terminal_run_id", sa.String(length=14)),
38
+ sa.Column("last_terminal_action", sa.String(length=32)),
39
+ sa.Column("pending_at_us", sa.Integer()),
40
+ sa.CheckConstraint(
41
+ "status IN ('pending','running','succeeded','failed','cancelled')",
42
+ name="ck_tasks_status",
43
+ ),
44
+ sa.CheckConstraint("attempt >= 0", name="ck_tasks_attempt_nonnegative"),
45
+ sa.CheckConstraint("max_attempts > 0", name="ck_tasks_max_attempts_positive"),
46
+ sa.CheckConstraint(
47
+ "last_terminal_action IS NULL OR last_terminal_action IN "
48
+ "('complete','fail','unclaim','heartbeat_expired','cancel')",
49
+ name="ck_tasks_terminal_action",
50
+ ),
51
+ sa.CheckConstraint(
52
+ "json_valid(args_json) AND json_type(args_json) = 'object'",
53
+ name="ck_args_json",
54
+ ),
55
+ sa.CheckConstraint(
56
+ "json_valid(metadata_json) AND json_type(metadata_json) = 'object'",
57
+ name="ck_metadata_json",
58
+ ),
59
+ sa.CheckConstraint(
60
+ "json_valid(result_json) AND json_type(result_json) = 'object'",
61
+ name="ck_result_json",
62
+ ),
63
+ sa.CheckConstraint(
64
+ "(status = 'pending' AND pending_at_us IS NOT NULL "
65
+ "AND active_run_id IS NULL AND lease_expires_at_us IS NULL "
66
+ "AND attempt < max_attempts) OR status != 'pending'",
67
+ name="ck_tasks_pending_state",
68
+ ),
69
+ sa.CheckConstraint(
70
+ "(status = 'running' AND pending_at_us IS NULL "
71
+ "AND active_run_id IS NOT NULL AND lease_expires_at_us IS NOT NULL) "
72
+ "OR status != 'running'",
73
+ name="ck_tasks_running_state",
74
+ ),
75
+ sa.CheckConstraint(
76
+ "(status IN ('succeeded','failed','cancelled') AND pending_at_us IS NULL "
77
+ "AND active_run_id IS NULL AND lease_expires_at_us IS NULL) "
78
+ "OR status NOT IN ('succeeded','failed','cancelled')",
79
+ name="ck_tasks_terminal_state",
80
+ ),
81
+ sa.ForeignKeyConstraint(["queue_name"], ["queues.name"], ondelete="CASCADE"),
82
+ sa.PrimaryKeyConstraint("queue_name", "task_id"),
83
+ )
84
+ op.create_index(
85
+ "ix_tasks_claim",
86
+ "tasks",
87
+ ["queue_name", "status", sa.text("priority DESC"), "pending_at_us", "task_id"],
88
+ )
89
+ op.create_index("ix_tasks_expiry", "tasks", ["status", "lease_expires_at_us"])
90
+ op.create_index(
91
+ "ix_tasks_default_list",
92
+ "tasks",
93
+ ["queue_name", sa.text("created_at_us DESC"), sa.text("task_id DESC")],
94
+ )
95
+ op.create_index(
96
+ "ix_tasks_status_list",
97
+ "tasks",
98
+ ["queue_name", "status", sa.text("created_at_us DESC"), sa.text("task_id DESC")],
99
+ )
100
+ op.create_index(
101
+ "uq_tasks_active_run_id",
102
+ "tasks",
103
+ ["active_run_id"],
104
+ unique=True,
105
+ sqlite_where=sa.text("active_run_id IS NOT NULL"),
106
+ )
107
+ op.create_index(
108
+ "ix_tasks_terminal_run_id",
109
+ "tasks",
110
+ ["last_terminal_run_id"],
111
+ sqlite_where=sa.text("last_terminal_run_id IS NOT NULL"),
112
+ )
113
+ op.create_table(
114
+ "task_routes",
115
+ sa.Column("queue_name", sa.String(length=128), nullable=False),
116
+ sa.Column("task_id", sa.String(length=14), nullable=False),
117
+ sa.Column("route", sa.String(length=128), nullable=False),
118
+ sa.ForeignKeyConstraint(
119
+ ["queue_name", "task_id"],
120
+ ["tasks.queue_name", "tasks.task_id"],
121
+ ondelete="CASCADE",
122
+ ),
123
+ sa.PrimaryKeyConstraint("queue_name", "task_id", "route"),
124
+ )
125
+ op.create_index(
126
+ "ix_task_routes_route",
127
+ "task_routes",
128
+ ["queue_name", "route", "task_id"],
129
+ )
130
+
131
+
132
+ def downgrade() -> None:
133
+ op.drop_table("task_routes")
134
+ op.drop_table("tasks")
135
+ op.drop_table("queues")
@@ -0,0 +1 @@
1
+ """Labtasker v2 schema revisions."""
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import (
4
+ CheckConstraint,
5
+ ForeignKeyConstraint,
6
+ Index,
7
+ Integer,
8
+ String,
9
+ Text,
10
+ text,
11
+ )
12
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
13
+
14
+
15
+ class Base(DeclarativeBase):
16
+ pass
17
+
18
+
19
+ class QueueRow(Base):
20
+ __tablename__ = "queues"
21
+
22
+ name: Mapped[str] = mapped_column(String(128), primary_key=True)
23
+
24
+
25
+ class TaskRow(Base):
26
+ __tablename__ = "tasks"
27
+ __table_args__ = (
28
+ ForeignKeyConstraint(["queue_name"], ["queues.name"], ondelete="CASCADE"),
29
+ CheckConstraint(
30
+ "status IN ('pending','running','succeeded','failed','cancelled')",
31
+ name="ck_tasks_status",
32
+ ),
33
+ CheckConstraint("attempt >= 0", name="ck_tasks_attempt_nonnegative"),
34
+ CheckConstraint("max_attempts > 0", name="ck_tasks_max_attempts_positive"),
35
+ CheckConstraint(
36
+ "last_terminal_action IS NULL OR last_terminal_action IN "
37
+ "('complete','fail','unclaim','heartbeat_expired','cancel')",
38
+ name="ck_tasks_terminal_action",
39
+ ),
40
+ CheckConstraint(
41
+ "json_valid(args_json) AND json_type(args_json) = 'object'",
42
+ name="ck_args_json",
43
+ ),
44
+ CheckConstraint(
45
+ "json_valid(metadata_json) AND json_type(metadata_json) = 'object'",
46
+ name="ck_metadata_json",
47
+ ),
48
+ CheckConstraint(
49
+ "json_valid(result_json) AND json_type(result_json) = 'object'",
50
+ name="ck_result_json",
51
+ ),
52
+ CheckConstraint(
53
+ "(status = 'pending' AND pending_at_us IS NOT NULL "
54
+ "AND active_run_id IS NULL AND lease_expires_at_us IS NULL "
55
+ "AND attempt < max_attempts) OR status != 'pending'",
56
+ name="ck_tasks_pending_state",
57
+ ),
58
+ CheckConstraint(
59
+ "(status = 'running' AND pending_at_us IS NULL "
60
+ "AND active_run_id IS NOT NULL AND lease_expires_at_us IS NOT NULL) "
61
+ "OR status != 'running'",
62
+ name="ck_tasks_running_state",
63
+ ),
64
+ CheckConstraint(
65
+ "(status IN ('succeeded','failed','cancelled') AND pending_at_us IS NULL "
66
+ "AND active_run_id IS NULL AND lease_expires_at_us IS NULL) "
67
+ "OR status NOT IN ('succeeded','failed','cancelled')",
68
+ name="ck_tasks_terminal_state",
69
+ ),
70
+ Index(
71
+ "ix_tasks_claim",
72
+ "queue_name",
73
+ "status",
74
+ "priority",
75
+ "pending_at_us",
76
+ "task_id",
77
+ ),
78
+ Index("ix_tasks_expiry", "status", "lease_expires_at_us"),
79
+ Index("ix_tasks_default_list", "queue_name", "created_at_us", "task_id"),
80
+ Index("ix_tasks_status_list", "queue_name", "status", "created_at_us", "task_id"),
81
+ Index(
82
+ "uq_tasks_active_run_id",
83
+ "active_run_id",
84
+ unique=True,
85
+ sqlite_where=text("active_run_id IS NOT NULL"),
86
+ ),
87
+ Index(
88
+ "ix_tasks_terminal_run_id",
89
+ "last_terminal_run_id",
90
+ sqlite_where=text("last_terminal_run_id IS NOT NULL"),
91
+ ),
92
+ )
93
+
94
+ queue_name: Mapped[str] = mapped_column(String(128), primary_key=True)
95
+ task_id: Mapped[str] = mapped_column(String(14), primary_key=True)
96
+ status: Mapped[str] = mapped_column(String(16), nullable=False)
97
+ name: Mapped[str | None] = mapped_column(String(256))
98
+ args_json: Mapped[str] = mapped_column(Text, nullable=False)
99
+ metadata_json: Mapped[str] = mapped_column(Text, nullable=False)
100
+ result_json: Mapped[str] = mapped_column(Text, nullable=False)
101
+ priority: Mapped[int] = mapped_column(Integer, nullable=False)
102
+ attempt: Mapped[int] = mapped_column(Integer, nullable=False)
103
+ max_attempts: Mapped[int] = mapped_column(Integer, nullable=False)
104
+ created_at_us: Mapped[int] = mapped_column(Integer, nullable=False)
105
+ updated_at_us: Mapped[int] = mapped_column(Integer, nullable=False)
106
+ last_route: Mapped[str | None] = mapped_column(String(128))
107
+ started_at_us: Mapped[int | None] = mapped_column(Integer)
108
+ finished_at_us: Mapped[int | None] = mapped_column(Integer)
109
+ last_error_json: Mapped[str | None] = mapped_column(Text)
110
+ creation_hash: Mapped[str] = mapped_column(String(64), nullable=False)
111
+ active_run_id: Mapped[str | None] = mapped_column(String(14))
112
+ lease_expires_at_us: Mapped[int | None] = mapped_column(Integer)
113
+ last_terminal_run_id: Mapped[str | None] = mapped_column(String(14))
114
+ last_terminal_action: Mapped[str | None] = mapped_column(String(32))
115
+ pending_at_us: Mapped[int | None] = mapped_column(Integer)
116
+
117
+ routes: Mapped[list[TaskRouteRow]] = relationship(
118
+ back_populates="task",
119
+ cascade="all, delete-orphan",
120
+ lazy="selectin",
121
+ )
122
+
123
+
124
+ class TaskRouteRow(Base):
125
+ __tablename__ = "task_routes"
126
+ __table_args__ = (
127
+ ForeignKeyConstraint(
128
+ ["queue_name", "task_id"],
129
+ ["tasks.queue_name", "tasks.task_id"],
130
+ ondelete="CASCADE",
131
+ ),
132
+ Index("ix_task_routes_route", "queue_name", "route", "task_id"),
133
+ )
134
+
135
+ queue_name: Mapped[str] = mapped_column(String(128), primary_key=True)
136
+ task_id: Mapped[str] = mapped_column(String(14), primary_key=True)
137
+ route: Mapped[str] = mapped_column(String(128), primary_key=True)
138
+
139
+ task: Mapped[TaskRow] = relationship(back_populates="routes")
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ from dataclasses import dataclass
7
+
8
+ from labtasker_server.errors import DomainError, invalid
9
+ from labtasker_server.validation import (
10
+ INT64_MAX,
11
+ INT64_MIN,
12
+ validate_task_id,
13
+ validate_unicode_scalar,
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class TaskSelection:
19
+ queue: str
20
+ status: str | None
21
+ name: str | None
22
+ filter: str | None
23
+ order_by: str
24
+ descending: bool
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CursorPosition:
29
+ value: str | int | None
30
+ task_id: str
31
+
32
+
33
+ def encode_cursor(selection: TaskSelection, position: CursorPosition) -> str:
34
+ payload = {
35
+ "v": 1,
36
+ "selection": {
37
+ "queue": selection.queue,
38
+ "status": selection.status,
39
+ "name": selection.name,
40
+ "filter": selection.filter,
41
+ "order_by": selection.order_by,
42
+ "descending": selection.descending,
43
+ },
44
+ "position": {"value": position.value, "id": position.task_id},
45
+ }
46
+ encoded = json.dumps(
47
+ payload,
48
+ ensure_ascii=False,
49
+ allow_nan=False,
50
+ separators=(",", ":"),
51
+ sort_keys=True,
52
+ ).encode("utf-8")
53
+ return base64.urlsafe_b64encode(encoded).decode("ascii").rstrip("=")
54
+
55
+
56
+ def decode_cursor(cursor: str, selection: TaskSelection) -> CursorPosition:
57
+ try:
58
+ if not cursor or len(cursor) > 16_384 or not cursor.isascii():
59
+ raise ValueError
60
+ padding = "=" * (-len(cursor) % 4)
61
+ raw = base64.b64decode(cursor + padding, altchars=b"-_", validate=True)
62
+ payload = json.loads(raw)
63
+ if not isinstance(payload, dict) or set(payload) != {"v", "selection", "position"}:
64
+ raise ValueError
65
+ if payload["v"] != 1 or payload["selection"] != _selection_json(selection):
66
+ raise ValueError
67
+ position = payload["position"]
68
+ if not isinstance(position, dict) or set(position) != {"value", "id"}:
69
+ raise ValueError
70
+ task_id = position["id"]
71
+ value = position["value"]
72
+ if not isinstance(task_id, str) or isinstance(value, (bool, float, list, dict)):
73
+ raise ValueError
74
+ validate_task_id(task_id)
75
+ _validate_cursor_value(selection.order_by, value)
76
+ return CursorPosition(value=value, task_id=task_id)
77
+ except (
78
+ UnicodeError,
79
+ ValueError,
80
+ TypeError,
81
+ json.JSONDecodeError,
82
+ binascii.Error,
83
+ DomainError,
84
+ ) as error:
85
+ raise invalid(
86
+ "invalid_cursor", "Cursor is malformed or does not match this request."
87
+ ) from error
88
+
89
+
90
+ def _selection_json(selection: TaskSelection) -> dict[str, object]:
91
+ return {
92
+ "queue": selection.queue,
93
+ "status": selection.status,
94
+ "name": selection.name,
95
+ "filter": selection.filter,
96
+ "order_by": selection.order_by,
97
+ "descending": selection.descending,
98
+ }
99
+
100
+
101
+ def _validate_cursor_value(order_by: str, value: object) -> None:
102
+ numeric_fields = {
103
+ "priority",
104
+ "attempt",
105
+ "max_attempts",
106
+ "created_at",
107
+ "updated_at",
108
+ "started_at",
109
+ "finished_at",
110
+ }
111
+ nullable_fields = {"name", "last_route", "started_at", "finished_at"}
112
+ if value is None:
113
+ if order_by not in nullable_fields:
114
+ raise ValueError
115
+ return
116
+ if order_by in numeric_fields:
117
+ if (
118
+ not isinstance(value, int)
119
+ or isinstance(value, bool)
120
+ or not INT64_MIN <= value <= INT64_MAX
121
+ ):
122
+ raise ValueError
123
+ return
124
+ if not isinstance(value, str):
125
+ raise ValueError
126
+ validate_unicode_scalar(value, field="cursor.position.value")
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from datetime import datetime
5
+ from typing import Annotated, Any, Literal, TypeVar
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator
8
+ from pydantic_core import PydanticCustomError
9
+
10
+ from labtasker_server.errors import DomainError
11
+ from labtasker_server.validation import (
12
+ INT64_MAX,
13
+ INT64_MIN,
14
+ JSONValue,
15
+ canonical_routes,
16
+ validate_identifier,
17
+ validate_json_object,
18
+ validate_run_id,
19
+ validate_task_name,
20
+ validate_unicode_scalar,
21
+ )
22
+
23
+ TaskStatus = Literal["pending", "running", "succeeded", "failed", "cancelled"]
24
+ TaskOrderField = Literal[
25
+ "id",
26
+ "name",
27
+ "status",
28
+ "priority",
29
+ "attempt",
30
+ "max_attempts",
31
+ "last_route",
32
+ "created_at",
33
+ "updated_at",
34
+ "started_at",
35
+ "finished_at",
36
+ ]
37
+ Int64 = Annotated[StrictInt, Field(ge=INT64_MIN, le=INT64_MAX)]
38
+ PositiveInt64 = Annotated[StrictInt, Field(ge=1, le=INT64_MAX)]
39
+ T = TypeVar("T")
40
+ OMITTED: Any = None
41
+
42
+
43
+ class StrictModel(BaseModel):
44
+ model_config = ConfigDict(extra="forbid", strict=True)
45
+
46
+
47
+ class Queue(StrictModel):
48
+ name: str
49
+
50
+
51
+ class LastError(StrictModel):
52
+ type: str
53
+ message: str
54
+ traceback: str | None
55
+ occurred_at: datetime
56
+ attempt: int
57
+ run_id: str
58
+
59
+
60
+ class Task(StrictModel):
61
+ id: str
62
+ queue: str
63
+ status: TaskStatus
64
+ name: str | None
65
+ args: dict[str, JSONValue]
66
+ metadata: dict[str, JSONValue]
67
+ priority: int
68
+ attempt: int
69
+ max_attempts: int
70
+ routes: list[str]
71
+ result: dict[str, JSONValue]
72
+ last_error: LastError | None
73
+ last_route: str | None
74
+ created_at: datetime
75
+ updated_at: datetime
76
+ started_at: datetime | None
77
+ finished_at: datetime | None
78
+
79
+
80
+ class TaskCreate(StrictModel):
81
+ name: str | None = None
82
+ args: dict[str, JSONValue] = Field(default_factory=dict)
83
+ metadata: dict[str, JSONValue] = Field(default_factory=dict)
84
+ priority: Int64 = 0
85
+ max_attempts: PositiveInt64 = 3
86
+ routes: list[str] = Field(default_factory=lambda: ["default"])
87
+
88
+ @field_validator("name")
89
+ @classmethod
90
+ def validate_name(cls, value: str | None) -> str | None:
91
+ try:
92
+ return validate_task_name(value)
93
+ except DomainError as error:
94
+ raise _pydantic_error(error) from error
95
+
96
+ @field_validator("args", "metadata")
97
+ @classmethod
98
+ def validate_objects(cls, value: dict[str, JSONValue], info: object) -> dict[str, JSONValue]:
99
+ field_name = getattr(info, "field_name", "value")
100
+ try:
101
+ return validate_json_object(value, field=field_name)
102
+ except DomainError as error:
103
+ raise _pydantic_error(error) from error
104
+
105
+ @field_validator("routes")
106
+ @classmethod
107
+ def validate_routes(cls, value: list[str]) -> list[str]:
108
+ try:
109
+ return canonical_routes(value)
110
+ except DomainError as error:
111
+ raise _pydantic_error(error) from error
112
+
113
+
114
+ class TaskUpdate(StrictModel):
115
+ name: str | None = None
116
+ args: dict[str, JSONValue] = OMITTED
117
+ metadata: dict[str, JSONValue] = OMITTED
118
+ priority: Int64 = OMITTED
119
+ max_attempts: PositiveInt64 = OMITTED
120
+ routes: list[str] = OMITTED
121
+ result: dict[str, JSONValue] = OMITTED
122
+
123
+ @model_validator(mode="after")
124
+ def validate_nonempty(self) -> TaskUpdate:
125
+ if not self.model_fields_set:
126
+ raise PydanticCustomError("invalid_update", "Update must contain at least one field.")
127
+ return self
128
+
129
+ @field_validator("name")
130
+ @classmethod
131
+ def validate_name(cls, value: str | None) -> str | None:
132
+ return _validated(lambda: validate_task_name(value))
133
+
134
+ @field_validator("args", "metadata", "result")
135
+ @classmethod
136
+ def validate_objects(
137
+ cls,
138
+ value: dict[str, JSONValue],
139
+ info: object,
140
+ ) -> dict[str, JSONValue]:
141
+ field_name = getattr(info, "field_name", "value")
142
+ return _validated(lambda: validate_json_object(value, field=field_name))
143
+
144
+ @field_validator("routes")
145
+ @classmethod
146
+ def validate_routes(cls, value: list[str]) -> list[str]:
147
+ return _validated(lambda: canonical_routes(value))
148
+
149
+
150
+ class BulkUpdateRequest(StrictModel):
151
+ filter: str
152
+ changes: TaskUpdate
153
+
154
+
155
+ class BulkUpdateResult(StrictModel):
156
+ matched: int
157
+ updated: int
158
+
159
+
160
+ class TaskPage(StrictModel):
161
+ items: list[Task]
162
+ next_cursor: str | None
163
+
164
+
165
+ class CountResponse(StrictModel):
166
+ count: int
167
+
168
+
169
+ class ClaimRequest(StrictModel):
170
+ route: str
171
+ run_id: str
172
+
173
+ @field_validator("route")
174
+ @classmethod
175
+ def validate_route(cls, value: str) -> str:
176
+ return _validated(lambda: validate_identifier(value, kind="Route"))
177
+
178
+ @field_validator("run_id")
179
+ @classmethod
180
+ def validate_run(cls, value: str) -> str:
181
+ return _validated(lambda: validate_run_id(value))
182
+
183
+
184
+ class ClaimResponse(StrictModel):
185
+ task: Task
186
+ run_id: str
187
+ lease_expires_at: datetime
188
+
189
+
190
+ class RunRequest(StrictModel):
191
+ run_id: str
192
+
193
+ @field_validator("run_id")
194
+ @classmethod
195
+ def validate_run(cls, value: str) -> str:
196
+ return _validated(lambda: validate_run_id(value))
197
+
198
+
199
+ class HeartbeatResponse(StrictModel):
200
+ lease_expires_at: datetime
201
+
202
+
203
+ class CompleteRequest(RunRequest):
204
+ result: dict[str, JSONValue]
205
+
206
+ @field_validator("result")
207
+ @classmethod
208
+ def validate_result(cls, value: dict[str, JSONValue]) -> dict[str, JSONValue]:
209
+ return _validated(lambda: validate_json_object(value, field="result"))
210
+
211
+
212
+ class FailureReport(StrictModel):
213
+ type: str
214
+ message: str
215
+ traceback: str | None
216
+
217
+ @field_validator("type", "message", "traceback")
218
+ @classmethod
219
+ def validate_strings(cls, value: str | None, info: object) -> str | None:
220
+ if value is None:
221
+ return None
222
+ field_name = getattr(info, "field_name", "error")
223
+ return _validated(lambda: validate_unicode_scalar(value, field=field_name))
224
+
225
+
226
+ class FailRequest(RunRequest):
227
+ error: FailureReport
228
+
229
+
230
+ class ErrorItem(StrictModel):
231
+ location: list[str | int]
232
+ message: str
233
+
234
+
235
+ class ErrorBody(StrictModel):
236
+ code: str
237
+ message: str
238
+ details: dict[str, JSONValue]
239
+
240
+
241
+ class ErrorEnvelope(StrictModel):
242
+ error: ErrorBody
243
+
244
+
245
+ class HealthyResponse(StrictModel):
246
+ status: Literal["ok"]
247
+ api_version: Literal["2"]
248
+ database: Literal["ok"]
249
+
250
+
251
+ class UnhealthyResponse(StrictModel):
252
+ status: Literal["error"]
253
+ api_version: Literal["2"]
254
+ database: Literal["error"]
255
+
256
+
257
+ def _pydantic_error(error: DomainError) -> PydanticCustomError:
258
+ return PydanticCustomError(error.code, error.message, error.details)
259
+
260
+
261
+ def _validated(operation: Callable[[], T]) -> T:
262
+ try:
263
+ return operation()
264
+ except DomainError as error:
265
+ raise _pydantic_error(error) from error
@@ -0,0 +1 @@
1
+ """Application services owning Labtasker transactions and domain behavior."""