cpkit 0.1.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.
Files changed (90) hide show
  1. cpkit/README.md +82 -0
  2. cpkit/__init__.py +42 -0
  3. cpkit/admin.py +53 -0
  4. cpkit/app.py +130 -0
  5. cpkit/audit/README.md +33 -0
  6. cpkit/audit/__init__.py +44 -0
  7. cpkit/audit/events_service.py +36 -0
  8. cpkit/audit/recorder.py +240 -0
  9. cpkit/audit/repository.py +64 -0
  10. cpkit/audit/router.py +46 -0
  11. cpkit/audit/service.py +34 -0
  12. cpkit/audit/types.py +38 -0
  13. cpkit/auth/README.md +38 -0
  14. cpkit/auth/__init__.py +123 -0
  15. cpkit/auth/api_key_router.py +52 -0
  16. cpkit/auth/api_key_service.py +143 -0
  17. cpkit/auth/api_keys.py +143 -0
  18. cpkit/auth/bundle.py +113 -0
  19. cpkit/auth/claims.py +37 -0
  20. cpkit/auth/config.py +162 -0
  21. cpkit/auth/dependencies.py +155 -0
  22. cpkit/auth/oidc.py +703 -0
  23. cpkit/auth/redirects.py +12 -0
  24. cpkit/auth/repositories.py +181 -0
  25. cpkit/auth/router.py +214 -0
  26. cpkit/auth/secrets.py +78 -0
  27. cpkit/auth/types.py +49 -0
  28. cpkit/bundle.py +252 -0
  29. cpkit/cli/README.md +21 -0
  30. cpkit/cli/__init__.py +14 -0
  31. cpkit/cli/__main__.py +5 -0
  32. cpkit/cli/base.py +200 -0
  33. cpkit/cli/schema.py +30 -0
  34. cpkit/cli/server.py +22 -0
  35. cpkit/config/README.md +13 -0
  36. cpkit/config/__init__.py +9 -0
  37. cpkit/config/env.py +31 -0
  38. cpkit/db/README.md +24 -0
  39. cpkit/db/__init__.py +23 -0
  40. cpkit/db/postgres.py +252 -0
  41. cpkit/dependencies.py +73 -0
  42. cpkit/errors/README.md +22 -0
  43. cpkit/errors/__init__.py +35 -0
  44. cpkit/errors/http.py +45 -0
  45. cpkit/errors/repository.py +32 -0
  46. cpkit/errors/service.py +74 -0
  47. cpkit/jobs/README.md +29 -0
  48. cpkit/jobs/__init__.py +49 -0
  49. cpkit/jobs/maintenance.py +15 -0
  50. cpkit/jobs/repository.py +306 -0
  51. cpkit/jobs/router.py +105 -0
  52. cpkit/jobs/service.py +138 -0
  53. cpkit/jobs/types.py +67 -0
  54. cpkit/jobs/worker.py +200 -0
  55. cpkit/logging/README.md +19 -0
  56. cpkit/logging/__init__.py +13 -0
  57. cpkit/logging/context.py +29 -0
  58. cpkit/logging/middleware.py +55 -0
  59. cpkit/logging/setup.py +81 -0
  60. cpkit/playbooks/README.md +25 -0
  61. cpkit/playbooks/__init__.py +40 -0
  62. cpkit/playbooks/ansible.py +359 -0
  63. cpkit/playbooks/repository.py +95 -0
  64. cpkit/playbooks/router.py +95 -0
  65. cpkit/playbooks/service.py +232 -0
  66. cpkit/playbooks/types.py +43 -0
  67. cpkit/repository.py +63 -0
  68. cpkit/resources/__init__.py +17 -0
  69. cpkit/resources/ddl.sql +150 -0
  70. cpkit/settings/README.md +22 -0
  71. cpkit/settings/__init__.py +18 -0
  72. cpkit/settings/keys.py +30 -0
  73. cpkit/settings/repository.py +108 -0
  74. cpkit/settings/router.py +62 -0
  75. cpkit/settings/service.py +105 -0
  76. cpkit/settings/types.py +25 -0
  77. cpkit/time.py +4 -0
  78. cpkit/webapp/README.md +71 -0
  79. cpkit/webapp/__init__.py +12 -0
  80. cpkit/webapp/index.html +970 -0
  81. cpkit/webapp/script.js +1690 -0
  82. cpkit/webapp/style.css +1561 -0
  83. cpkit-0.1.0.dist-info/METADATA +105 -0
  84. cpkit-0.1.0.dist-info/RECORD +90 -0
  85. cpkit-0.1.0.dist-info/WHEEL +4 -0
  86. cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  87. resources/README.md +16 -0
  88. resources/repository_maintenance_guide.md +90 -0
  89. resources/webapp_extension_guide.md +706 -0
  90. resources/webapp_extension_template.js +130 -0
cpkit/db/postgres.py ADDED
@@ -0,0 +1,252 @@
1
+ """Low-level Postgres metadata database infrastructure."""
2
+
3
+ import logging
4
+ import os
5
+ from typing import Any
6
+
7
+ from psycopg import DatabaseError, InterfaceError, OperationalError
8
+ from psycopg import errors as psycopg_errors
9
+ from psycopg.abc import Dumper
10
+ from psycopg.pq import Format
11
+ from psycopg.rows import class_row
12
+ from psycopg.types.array import ListDumper
13
+ from psycopg.types.json import Jsonb, JsonbDumper
14
+ from psycopg_pool import ConnectionPool
15
+
16
+ from cpkit.errors import (
17
+ RepositoryConflictError,
18
+ RepositoryError,
19
+ RepositoryPermissionError,
20
+ RepositoryUnavailableError,
21
+ RepositoryValidationError,
22
+ )
23
+
24
+ CPKIT_DB_URL = os.getenv("CPKIT_DB_URL")
25
+ pool: ConnectionPool | None = None
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class Dict2JsonbDumper(JsonbDumper):
30
+ def dump(self, obj):
31
+ return super().dump(Jsonb(obj))
32
+
33
+
34
+ class SelectorDumper(Dumper):
35
+ """Choose the correct dumper for list payloads."""
36
+
37
+ format = Format.BINARY
38
+ oid = None
39
+
40
+ _dict_dumper = Dict2JsonbDumper(list)
41
+ _list_dumper = ListDumper(list)
42
+
43
+ def upgrade(self, obj, format: Format) -> Dumper:
44
+ if obj and isinstance(obj[0], dict):
45
+ return self._dict_dumper
46
+ return self._list_dumper
47
+
48
+
49
+ def execute_stmt(
50
+ stmt: str,
51
+ bind_args: tuple = (),
52
+ *,
53
+ operation: str | None = None,
54
+ ) -> None:
55
+ with get_pool().connection() as conn:
56
+ _register_dumpers(conn)
57
+
58
+ with conn.cursor() as cur:
59
+ try:
60
+ stmt = _normalize_stmt(stmt)
61
+ cur.execute(stmt, bind_args)
62
+ except Exception as err:
63
+ raise translate_database_error(err, operation) from err
64
+
65
+
66
+ def fetch_all(
67
+ stmt: str,
68
+ bind_args: tuple,
69
+ row_type,
70
+ *,
71
+ operation: str | None = None,
72
+ ) -> list[Any]:
73
+ with get_pool().connection() as conn:
74
+ _register_dumpers(conn)
75
+
76
+ with conn.cursor(row_factory=class_row(row_type)) as cur:
77
+ try:
78
+ stmt = _normalize_stmt(stmt)
79
+ cur.execute(stmt, bind_args)
80
+ return cur.fetchall()
81
+ except Exception as err:
82
+ raise translate_database_error(err, operation) from err
83
+
84
+
85
+ def fetch_one(
86
+ stmt: str,
87
+ bind_args: tuple,
88
+ row_type,
89
+ *,
90
+ operation: str | None = None,
91
+ ) -> Any | None:
92
+ with get_pool().connection() as conn:
93
+ _register_dumpers(conn)
94
+
95
+ with conn.cursor(row_factory=class_row(row_type)) as cur:
96
+ try:
97
+ stmt = _normalize_stmt(stmt)
98
+ cur.execute(stmt, bind_args)
99
+ return cur.fetchone()
100
+ except Exception as err:
101
+ raise translate_database_error(err, operation) from err
102
+
103
+
104
+ def fetch_scalar(
105
+ stmt: str,
106
+ bind_args: tuple = (),
107
+ *,
108
+ operation: str | None = None,
109
+ ) -> Any | None:
110
+ with get_pool().connection() as conn:
111
+ _register_dumpers(conn)
112
+
113
+ with conn.cursor() as cur:
114
+ try:
115
+ stmt = _normalize_stmt(stmt)
116
+ cur.execute(stmt, bind_args)
117
+ row = cur.fetchone()
118
+ if row is None:
119
+ return None
120
+ return row[0]
121
+ except Exception as err:
122
+ raise translate_database_error(err, operation) from err
123
+
124
+
125
+ def _register_dumpers(conn) -> None:
126
+ conn.adapters.register_dumper(set, ListDumper)
127
+ conn.adapters.register_dumper(dict, Dict2JsonbDumper)
128
+ conn.adapters.register_dumper(list, SelectorDumper)
129
+
130
+
131
+ def _normalize_stmt(stmt: str) -> str:
132
+ return " ".join([s.strip() for s in stmt.split("\n")])
133
+
134
+
135
+ def initialize_postgres(db_url: str | None = None) -> None:
136
+ global pool
137
+
138
+ effective_db_url = db_url or CPKIT_DB_URL
139
+ if not effective_db_url:
140
+ raise EnvironmentError("CPKIT_DB_URL env variable not found!")
141
+
142
+ if pool is not None:
143
+ return
144
+
145
+ pool = ConnectionPool(
146
+ effective_db_url,
147
+ kwargs={"autocommit": True},
148
+ configure=_register_dumpers,
149
+ )
150
+
151
+
152
+ def get_pool() -> ConnectionPool:
153
+ if pool is None:
154
+ raise RuntimeError("Database pool not initialized. Ensure lifespan ran.")
155
+ return pool
156
+
157
+
158
+ def close_db() -> None:
159
+ global pool
160
+
161
+ if pool is not None:
162
+ pool.close()
163
+
164
+ pool = None
165
+
166
+
167
+ def translate_database_error(
168
+ err: Exception,
169
+ operation: str | None,
170
+ *,
171
+ unavailable_error_types: tuple[type[Exception], ...] = (),
172
+ ) -> RepositoryError:
173
+ operation_name = operation or "database.statement"
174
+ sqlstate = getattr(err, "sqlstate", None)
175
+
176
+ if unavailable_error_types and isinstance(err, unavailable_error_types):
177
+ logger.warning(
178
+ "Database unavailable [operation=%s error=%s reason=%s]",
179
+ operation_name,
180
+ err.__class__.__name__,
181
+ getattr(err, "reason", str(err)),
182
+ )
183
+ return RepositoryUnavailableError(
184
+ "Database is temporarily unavailable.",
185
+ operation=operation_name,
186
+ retryable=True,
187
+ )
188
+
189
+ logger.exception(
190
+ "Database operation failed [operation=%s sqlstate=%s error=%s]",
191
+ operation_name,
192
+ sqlstate,
193
+ err.__class__.__name__,
194
+ )
195
+
196
+ if isinstance(
197
+ err,
198
+ (
199
+ OperationalError,
200
+ InterfaceError,
201
+ psycopg_errors.SerializationFailure,
202
+ psycopg_errors.DeadlockDetected,
203
+ ),
204
+ ):
205
+ return RepositoryUnavailableError(
206
+ "Database is temporarily unavailable.",
207
+ operation=operation_name,
208
+ retryable=True,
209
+ )
210
+
211
+ if isinstance(err, psycopg_errors.UniqueViolation):
212
+ return RepositoryConflictError(
213
+ "Database write conflicts with existing data.",
214
+ operation=operation_name,
215
+ )
216
+
217
+ if isinstance(err, psycopg_errors.DuplicateDatabase):
218
+ return RepositoryConflictError(
219
+ "Database already exists.",
220
+ operation=operation_name,
221
+ )
222
+
223
+ if isinstance(
224
+ err,
225
+ (
226
+ psycopg_errors.ForeignKeyViolation,
227
+ psycopg_errors.CheckViolation,
228
+ psycopg_errors.NotNullViolation,
229
+ psycopg_errors.InvalidTextRepresentation,
230
+ ),
231
+ ):
232
+ return RepositoryValidationError(
233
+ "Database rejected invalid data.",
234
+ operation=operation_name,
235
+ )
236
+
237
+ if isinstance(err, psycopg_errors.InsufficientPrivilege):
238
+ return RepositoryPermissionError(
239
+ "Database permission denied.",
240
+ operation=operation_name,
241
+ )
242
+
243
+ if isinstance(err, DatabaseError):
244
+ return RepositoryError(
245
+ "Database operation failed.",
246
+ operation=operation_name,
247
+ )
248
+
249
+ return RepositoryError(
250
+ "Repository operation failed.",
251
+ operation=operation_name,
252
+ )
cpkit/dependencies.py ADDED
@@ -0,0 +1,73 @@
1
+ """Application-scoped dependency callables exposed by cpkit."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import Depends, Request, Security
6
+
7
+ from .auth import access_key_scheme, signature_scheme, timestamp_scheme
8
+ from .repository import get_repo
9
+
10
+ _active_bundle: Any | None = None
11
+
12
+
13
+ def configure_cpkit_dependencies(bundle: Any) -> None:
14
+ """Install the active cpkit bundle used by exported dependencies."""
15
+ global _active_bundle
16
+ _active_bundle = bundle
17
+
18
+
19
+ async def require_authenticated(
20
+ request: Request,
21
+ repo: Any = Depends(get_repo),
22
+ access_key: str | None = Security(access_key_scheme),
23
+ signature: str | None = Security(signature_scheme),
24
+ timestamp: str | None = Security(timestamp_scheme),
25
+ ) -> dict[str, Any]:
26
+ """Return claims for the current caller."""
27
+ return await _bundle().require_authenticated(
28
+ request,
29
+ repo,
30
+ access_key,
31
+ signature,
32
+ timestamp,
33
+ )
34
+
35
+
36
+ def require_user(
37
+ claims: dict[str, Any] = Security(require_authenticated),
38
+ ) -> dict[str, Any]:
39
+ """Require a role that permits mutating application operations."""
40
+ return _bundle().require_user(claims)
41
+
42
+
43
+ def require_readonly(
44
+ request: Request,
45
+ claims: dict[str, Any] = Security(require_authenticated),
46
+ ) -> dict[str, Any]:
47
+ """Allow read-only roles on GET and require user roles on writes."""
48
+ return _bundle().require_readonly(request, claims)
49
+
50
+
51
+ def require_admin(
52
+ claims: dict[str, Any] = Security(require_authenticated),
53
+ ) -> dict[str, Any]:
54
+ """Require an administrator role."""
55
+ return _bundle().require_admin(claims)
56
+
57
+
58
+ def get_access_scope(claims: dict[str, Any]) -> tuple[list[str], bool]:
59
+ """Return normalized caller groups plus whether the caller is an admin."""
60
+ return _bundle().get_access_scope(claims)
61
+
62
+
63
+ def get_audit_actor(
64
+ claims: dict[str, Any] = Security(require_authenticated),
65
+ ) -> str:
66
+ """Return the identifier that should be written into audit logs."""
67
+ return _bundle().get_audit_actor(claims)
68
+
69
+
70
+ def _bundle() -> Any:
71
+ if _active_bundle is None:
72
+ raise RuntimeError("cpkit dependencies are not configured.")
73
+ return _active_bundle
cpkit/errors/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # Errors
2
+
3
+ The errors package defines cpkit's exception layers. The layers are deliberately
4
+ separate so low-level DB details do not leak directly into API responses.
5
+
6
+ ## Files
7
+
8
+ - `repository.py`: Errors raised by repository/database operations.
9
+ - `service.py`: User-facing service errors and repository-to-service
10
+ translation.
11
+ - `http.py`: Service-to-FastAPI HTTP response translation.
12
+
13
+ ## Error Flow
14
+
15
+ 1. DB helpers raise `RepositoryError` subclasses.
16
+ 2. Services catch repository errors and call `from_repository_error()`.
17
+ 3. Routers catch `ServiceError` and call `raise_http_from_service_error()`.
18
+
19
+ This keeps SQL and driver details out of routers while still letting the UI show
20
+ clear messages such as "not found", "not authorized", or "temporarily
21
+ unavailable".
22
+
@@ -0,0 +1,35 @@
1
+ """Shared framework exception types."""
2
+
3
+ from .http import raise_http_from_service_error
4
+ from .repository import (
5
+ RepositoryConflictError,
6
+ RepositoryError,
7
+ RepositoryPermissionError,
8
+ RepositoryUnavailableError,
9
+ RepositoryValidationError,
10
+ )
11
+ from .service import (
12
+ ServiceAuthorizationError,
13
+ ServiceConflictError,
14
+ ServiceError,
15
+ ServiceNotFoundError,
16
+ ServiceUnavailableError,
17
+ ServiceValidationError,
18
+ from_repository_error,
19
+ )
20
+
21
+ __all__ = [
22
+ "RepositoryConflictError",
23
+ "RepositoryError",
24
+ "RepositoryPermissionError",
25
+ "RepositoryUnavailableError",
26
+ "RepositoryValidationError",
27
+ "ServiceAuthorizationError",
28
+ "ServiceConflictError",
29
+ "ServiceError",
30
+ "ServiceNotFoundError",
31
+ "ServiceUnavailableError",
32
+ "ServiceValidationError",
33
+ "from_repository_error",
34
+ "raise_http_from_service_error",
35
+ ]
cpkit/errors/http.py ADDED
@@ -0,0 +1,45 @@
1
+ """FastAPI translation helpers for framework service errors."""
2
+
3
+ from fastapi import HTTPException, status
4
+
5
+ from .service import (
6
+ ServiceAuthorizationError,
7
+ ServiceConflictError,
8
+ ServiceError,
9
+ ServiceNotFoundError,
10
+ ServiceUnavailableError,
11
+ ServiceValidationError,
12
+ )
13
+
14
+
15
+ def raise_http_from_service_error(err: ServiceError) -> None:
16
+ """Raise an HTTPException that matches a service-layer error."""
17
+ if isinstance(err, ServiceNotFoundError):
18
+ raise HTTPException(
19
+ status_code=status.HTTP_404_NOT_FOUND,
20
+ detail=err.user_message,
21
+ )
22
+ if isinstance(err, ServiceValidationError):
23
+ raise HTTPException(
24
+ status_code=status.HTTP_400_BAD_REQUEST,
25
+ detail=err.user_message,
26
+ )
27
+ if isinstance(err, ServiceConflictError):
28
+ raise HTTPException(
29
+ status_code=status.HTTP_409_CONFLICT,
30
+ detail=err.user_message,
31
+ )
32
+ if isinstance(err, ServiceAuthorizationError):
33
+ raise HTTPException(
34
+ status_code=status.HTTP_403_FORBIDDEN,
35
+ detail=err.user_message,
36
+ )
37
+ if isinstance(err, ServiceUnavailableError):
38
+ raise HTTPException(
39
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
40
+ detail=err.user_message,
41
+ )
42
+ raise HTTPException(
43
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
44
+ detail=err.user_message,
45
+ )
@@ -0,0 +1,32 @@
1
+ """Repository-layer exception types."""
2
+
3
+
4
+ class RepositoryError(Exception):
5
+ """Base exception for repository and infrastructure failures."""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str = "Repository operation failed.",
10
+ *,
11
+ operation: str | None = None,
12
+ retryable: bool = False,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.operation = operation
16
+ self.retryable = retryable
17
+
18
+
19
+ class RepositoryUnavailableError(RepositoryError):
20
+ """Raised when the database is temporarily unavailable."""
21
+
22
+
23
+ class RepositoryConflictError(RepositoryError):
24
+ """Raised when a write conflicts with existing data."""
25
+
26
+
27
+ class RepositoryValidationError(RepositoryError):
28
+ """Raised when the database rejects invalid data."""
29
+
30
+
31
+ class RepositoryPermissionError(RepositoryError):
32
+ """Raised when the database denies access to an operation."""
@@ -0,0 +1,74 @@
1
+ """Service-layer exception types and repository error translation."""
2
+
3
+ from .repository import (
4
+ RepositoryConflictError,
5
+ RepositoryError,
6
+ RepositoryPermissionError,
7
+ RepositoryUnavailableError,
8
+ RepositoryValidationError,
9
+ )
10
+
11
+
12
+ class ServiceError(Exception):
13
+ """Base exception for errors that may be shown in a UI or API response."""
14
+
15
+ default_title = "Error"
16
+ default_message = "Something went wrong. Please try again."
17
+
18
+ def __init__(
19
+ self,
20
+ message: str | None = None,
21
+ *,
22
+ title: str | None = None,
23
+ ) -> None:
24
+ self.user_title = title or self.default_title
25
+ self.user_message = message or self.default_message
26
+ super().__init__(self.user_message)
27
+
28
+
29
+ class ServiceUnavailableError(ServiceError):
30
+ default_title = "Temporarily Unavailable"
31
+ default_message = "The service is temporarily unavailable. Please try again."
32
+
33
+
34
+ class ServiceConflictError(ServiceError):
35
+ default_title = "Conflict"
36
+ default_message = "The requested change conflicts with existing data."
37
+
38
+
39
+ class ServiceValidationError(ServiceError):
40
+ default_title = "Invalid Request"
41
+ default_message = "The submitted data is invalid."
42
+
43
+
44
+ class ServiceAuthorizationError(ServiceError):
45
+ default_title = "Not Authorized"
46
+ default_message = "You are not authorized to perform this action."
47
+
48
+
49
+ class ServiceNotFoundError(ServiceError):
50
+ default_title = "Not Found"
51
+ default_message = "The requested item could not be found."
52
+
53
+
54
+ def from_repository_error(
55
+ err: RepositoryError,
56
+ *,
57
+ unavailable_message: str | None = None,
58
+ conflict_message: str | None = None,
59
+ validation_message: str | None = None,
60
+ permission_message: str | None = None,
61
+ fallback_message: str | None = None,
62
+ fallback_title: str | None = None,
63
+ ) -> ServiceError:
64
+ """Translate a repository exception into a service exception."""
65
+
66
+ if isinstance(err, RepositoryUnavailableError):
67
+ return ServiceUnavailableError(unavailable_message)
68
+ if isinstance(err, RepositoryConflictError):
69
+ return ServiceConflictError(conflict_message)
70
+ if isinstance(err, RepositoryValidationError):
71
+ return ServiceValidationError(validation_message)
72
+ if isinstance(err, RepositoryPermissionError):
73
+ return ServiceAuthorizationError(permission_message)
74
+ return ServiceError(fallback_message, title=fallback_title)
cpkit/jobs/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Jobs
2
+
3
+ The jobs package owns cpkit's framework job history and queue mechanics. A job
4
+ is represented by a row in `cpkit.jobs`; queued work is represented by a row in
5
+ `cpkit.mq`, where the queue message id doubles as the job id for normal app
6
+ jobs.
7
+
8
+ ## Files
9
+
10
+ - `types.py`: Job, task, queue, stats, and detail response models.
11
+ - `repository.py`: Repository mixins for jobs, tasks, linked resources, and the
12
+ queue table.
13
+ - `service.py`: Job listing, detail loading, stats, and rescheduling rules.
14
+ - `router.py`: Jobs API routes.
15
+ - `worker.py`: Background queue poller and handler dispatch.
16
+ - `maintenance.py`: Framework maintenance queue handlers, such as zombie job
17
+ cleanup.
18
+
19
+ ## Runtime Flow
20
+
21
+ 1. App code enqueues work through the repository/service.
22
+ 2. The queue worker claims due messages from `cpkit.mq`.
23
+ 3. The worker resolves a handler and calls it with `(job_id, payload, created_by)`.
24
+ 4. Handlers update `cpkit.jobs` and write task rows as work progresses.
25
+ 5. The Jobs page reads job rows, task rows, and stats through the Jobs API.
26
+
27
+ When a normal queue message is being processed, the worker sets the current
28
+ audit job context so audit records can store the job id separately from details.
29
+
cpkit/jobs/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Framework-owned job queue primitives."""
2
+
3
+ from .maintenance import FAIL_ZOMBIE_JOBS_MESSAGE_TYPE, create_fail_zombie_jobs_handler
4
+ from .repository import (
5
+ JOBS_TABLE,
6
+ QUEUE_TABLE,
7
+ TASKS_TABLE,
8
+ JobsRepositoryMixin,
9
+ QueueJobRepositoryMixin,
10
+ QueueRepositoryMixin,
11
+ )
12
+ from .router import create_jobs_router
13
+ from .service import JobsService
14
+ from .types import (
15
+ IntID,
16
+ Job,
17
+ JobDetailsResponse,
18
+ JobID,
19
+ JobRescheduleResponse,
20
+ JobStatsResponse,
21
+ LinkedResourceRef,
22
+ QueueMessage,
23
+ Task,
24
+ )
25
+ from .worker import create_queue_worker, run_queue_worker
26
+
27
+ __all__ = [
28
+ "FAIL_ZOMBIE_JOBS_MESSAGE_TYPE",
29
+ "IntID",
30
+ "JOBS_TABLE",
31
+ "Job",
32
+ "JobDetailsResponse",
33
+ "JobID",
34
+ "JobRescheduleResponse",
35
+ "JobStatsResponse",
36
+ "JobsRepositoryMixin",
37
+ "JobsService",
38
+ "LinkedResourceRef",
39
+ "QUEUE_TABLE",
40
+ "QueueMessage",
41
+ "QueueJobRepositoryMixin",
42
+ "QueueRepositoryMixin",
43
+ "TASKS_TABLE",
44
+ "Task",
45
+ "create_fail_zombie_jobs_handler",
46
+ "create_queue_worker",
47
+ "create_jobs_router",
48
+ "run_queue_worker",
49
+ ]
@@ -0,0 +1,15 @@
1
+ """Framework job maintenance handlers."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ FAIL_ZOMBIE_JOBS_MESSAGE_TYPE = "FAIL_ZOMBIE_JOBS"
7
+
8
+
9
+ def create_fail_zombie_jobs_handler(get_repo: Callable[[], Any]):
10
+ """Create a queue handler that marks stale framework jobs as failed."""
11
+
12
+ def fail_zombie_jobs(_job_id: int, _command: Any, _requested_by: str) -> None:
13
+ get_repo().fail_zombie_jobs()
14
+
15
+ return fail_zombie_jobs