hindsight-api 0.2.1__py3-none-any.whl → 0.4.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 (88) hide show
  1. hindsight_api/admin/__init__.py +1 -0
  2. hindsight_api/admin/cli.py +311 -0
  3. hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
  4. hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
  5. hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
  6. hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
  7. hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
  8. hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
  9. hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
  10. hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
  11. hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
  12. hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
  13. hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
  14. hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
  15. hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
  16. hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
  17. hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
  18. hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
  19. hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
  20. hindsight_api/api/http.py +1406 -118
  21. hindsight_api/api/mcp.py +11 -196
  22. hindsight_api/config.py +359 -27
  23. hindsight_api/engine/consolidation/__init__.py +5 -0
  24. hindsight_api/engine/consolidation/consolidator.py +859 -0
  25. hindsight_api/engine/consolidation/prompts.py +69 -0
  26. hindsight_api/engine/cross_encoder.py +706 -88
  27. hindsight_api/engine/db_budget.py +284 -0
  28. hindsight_api/engine/db_utils.py +11 -0
  29. hindsight_api/engine/directives/__init__.py +5 -0
  30. hindsight_api/engine/directives/models.py +37 -0
  31. hindsight_api/engine/embeddings.py +553 -29
  32. hindsight_api/engine/entity_resolver.py +8 -5
  33. hindsight_api/engine/interface.py +40 -17
  34. hindsight_api/engine/llm_wrapper.py +744 -68
  35. hindsight_api/engine/memory_engine.py +2505 -1017
  36. hindsight_api/engine/mental_models/__init__.py +14 -0
  37. hindsight_api/engine/mental_models/models.py +53 -0
  38. hindsight_api/engine/query_analyzer.py +4 -3
  39. hindsight_api/engine/reflect/__init__.py +18 -0
  40. hindsight_api/engine/reflect/agent.py +933 -0
  41. hindsight_api/engine/reflect/models.py +109 -0
  42. hindsight_api/engine/reflect/observations.py +186 -0
  43. hindsight_api/engine/reflect/prompts.py +483 -0
  44. hindsight_api/engine/reflect/tools.py +437 -0
  45. hindsight_api/engine/reflect/tools_schema.py +250 -0
  46. hindsight_api/engine/response_models.py +168 -4
  47. hindsight_api/engine/retain/bank_utils.py +79 -201
  48. hindsight_api/engine/retain/fact_extraction.py +424 -195
  49. hindsight_api/engine/retain/fact_storage.py +35 -12
  50. hindsight_api/engine/retain/link_utils.py +29 -24
  51. hindsight_api/engine/retain/orchestrator.py +24 -43
  52. hindsight_api/engine/retain/types.py +11 -2
  53. hindsight_api/engine/search/graph_retrieval.py +43 -14
  54. hindsight_api/engine/search/link_expansion_retrieval.py +391 -0
  55. hindsight_api/engine/search/mpfp_retrieval.py +362 -117
  56. hindsight_api/engine/search/reranking.py +2 -2
  57. hindsight_api/engine/search/retrieval.py +848 -201
  58. hindsight_api/engine/search/tags.py +172 -0
  59. hindsight_api/engine/search/think_utils.py +42 -141
  60. hindsight_api/engine/search/trace.py +12 -1
  61. hindsight_api/engine/search/tracer.py +26 -6
  62. hindsight_api/engine/search/types.py +21 -3
  63. hindsight_api/engine/task_backend.py +113 -106
  64. hindsight_api/engine/utils.py +1 -152
  65. hindsight_api/extensions/__init__.py +10 -1
  66. hindsight_api/extensions/builtin/tenant.py +5 -1
  67. hindsight_api/extensions/context.py +10 -1
  68. hindsight_api/extensions/operation_validator.py +81 -4
  69. hindsight_api/extensions/tenant.py +26 -0
  70. hindsight_api/main.py +69 -6
  71. hindsight_api/mcp_local.py +12 -53
  72. hindsight_api/mcp_tools.py +494 -0
  73. hindsight_api/metrics.py +433 -48
  74. hindsight_api/migrations.py +141 -1
  75. hindsight_api/models.py +3 -3
  76. hindsight_api/pg0.py +53 -0
  77. hindsight_api/server.py +39 -2
  78. hindsight_api/worker/__init__.py +11 -0
  79. hindsight_api/worker/main.py +296 -0
  80. hindsight_api/worker/poller.py +486 -0
  81. {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/METADATA +16 -6
  82. hindsight_api-0.4.0.dist-info/RECORD +112 -0
  83. {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/entry_points.txt +2 -0
  84. hindsight_api/engine/retain/observation_regeneration.py +0 -254
  85. hindsight_api/engine/search/observation_utils.py +0 -125
  86. hindsight_api/engine/search/scoring.py +0 -159
  87. hindsight_api-0.2.1.dist-info/RECORD +0 -75
  88. {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/WHEEL +0 -0
@@ -22,6 +22,7 @@ from pathlib import Path
22
22
 
23
23
  from alembic import command
24
24
  from alembic.config import Config
25
+ from alembic.script.revision import ResolutionError
25
26
  from sqlalchemy import create_engine, text
26
27
 
27
28
  logger = logging.getLogger(__name__)
@@ -78,7 +79,18 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
78
79
  alembic_cfg.set_main_option("target_schema", schema)
79
80
 
80
81
  # Run migrations
81
- command.upgrade(alembic_cfg, "head")
82
+ try:
83
+ command.upgrade(alembic_cfg, "head")
84
+ except ResolutionError as e:
85
+ # This happens during rolling deployments when a newer version of the code
86
+ # has already run migrations, and this older replica doesn't have the new
87
+ # migration files. The database is already at a newer revision than we know.
88
+ # This is safe to ignore - the newer code has already applied its migrations.
89
+ logger.warning(
90
+ f"Database is at a newer migration revision than this code version knows about. "
91
+ f"This is expected during rolling deployments. Skipping migrations. Error: {e}"
92
+ )
93
+ return
82
94
 
83
95
  logger.info(f"Database migrations completed successfully for schema '{schema_name}'")
84
96
 
@@ -229,3 +241,131 @@ def check_migration_status(
229
241
  except Exception as e:
230
242
  logger.warning(f"Unable to check migration status: {e}")
231
243
  return None, None
244
+
245
+
246
+ def ensure_embedding_dimension(
247
+ database_url: str,
248
+ required_dimension: int,
249
+ schema: str | None = None,
250
+ ) -> None:
251
+ """
252
+ Ensure the embedding column dimension matches the model's dimension.
253
+
254
+ This function checks the current vector column dimension in the database
255
+ and adjusts it if necessary:
256
+ - If dimensions match: no action needed
257
+ - If dimensions differ and table is empty: ALTER COLUMN to new dimension
258
+ - If dimensions differ and table has data: raise error with migration guidance
259
+
260
+ Args:
261
+ database_url: SQLAlchemy database URL
262
+ required_dimension: The embedding dimension required by the model
263
+ schema: Target PostgreSQL schema name (None for public)
264
+
265
+ Raises:
266
+ RuntimeError: If dimension mismatch with existing data
267
+ """
268
+ schema_name = schema or "public"
269
+
270
+ engine = create_engine(database_url)
271
+ with engine.connect() as conn:
272
+ # Check if memory_units table exists
273
+ table_exists = conn.execute(
274
+ text("""
275
+ SELECT EXISTS (
276
+ SELECT 1 FROM information_schema.tables
277
+ WHERE table_schema = :schema AND table_name = 'memory_units'
278
+ )
279
+ """),
280
+ {"schema": schema_name},
281
+ ).scalar()
282
+
283
+ if not table_exists:
284
+ logger.debug(f"memory_units table does not exist in schema '{schema_name}', skipping dimension check")
285
+ return
286
+
287
+ # Get current column dimension from pg_attribute
288
+ # pgvector stores dimension in atttypmod
289
+ current_dim = conn.execute(
290
+ text("""
291
+ SELECT atttypmod
292
+ FROM pg_attribute a
293
+ JOIN pg_class c ON a.attrelid = c.oid
294
+ JOIN pg_namespace n ON c.relnamespace = n.oid
295
+ WHERE n.nspname = :schema
296
+ AND c.relname = 'memory_units'
297
+ AND a.attname = 'embedding'
298
+ """),
299
+ {"schema": schema_name},
300
+ ).scalar()
301
+
302
+ if current_dim is None:
303
+ logger.warning("Could not determine current embedding dimension, skipping check")
304
+ return
305
+
306
+ # pgvector stores dimension directly in atttypmod (no offset like other types)
307
+ current_dimension = current_dim
308
+
309
+ if current_dimension == required_dimension:
310
+ logger.debug(f"Embedding dimension OK: {current_dimension}")
311
+ return
312
+
313
+ logger.info(
314
+ f"Embedding dimension mismatch: database has {current_dimension}, model requires {required_dimension}"
315
+ )
316
+
317
+ # Check if table has data
318
+ row_count = conn.execute(
319
+ text(f"SELECT COUNT(*) FROM {schema_name}.memory_units WHERE embedding IS NOT NULL")
320
+ ).scalar()
321
+
322
+ if row_count > 0:
323
+ raise RuntimeError(
324
+ f"Cannot change embedding dimension from {current_dimension} to {required_dimension}: "
325
+ f"memory_units table contains {row_count} rows with embeddings. "
326
+ f"To change dimensions, you must either:\n"
327
+ f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; then restart\n"
328
+ f" 2. Use a model with {current_dimension}-dimensional embeddings"
329
+ )
330
+
331
+ # Table is empty, safe to alter column
332
+ logger.info(f"Altering embedding column dimension from {current_dimension} to {required_dimension}")
333
+
334
+ # Drop the HNSW index on embedding column if it exists
335
+ # Only drop indexes that use 'hnsw' and reference the 'embedding' column
336
+ conn.execute(
337
+ text(f"""
338
+ DO $$
339
+ DECLARE idx_name TEXT;
340
+ BEGIN
341
+ FOR idx_name IN
342
+ SELECT indexname FROM pg_indexes
343
+ WHERE schemaname = '{schema_name}'
344
+ AND tablename = 'memory_units'
345
+ AND indexdef LIKE '%hnsw%'
346
+ AND indexdef LIKE '%embedding%'
347
+ LOOP
348
+ EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
349
+ END LOOP;
350
+ END $$;
351
+ """)
352
+ )
353
+
354
+ # Alter the column type
355
+ conn.execute(
356
+ text(f"ALTER TABLE {schema_name}.memory_units ALTER COLUMN embedding TYPE vector({required_dimension})")
357
+ )
358
+ conn.commit()
359
+
360
+ # Recreate the HNSW index
361
+ conn.execute(
362
+ text(f"""
363
+ CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
364
+ ON {schema_name}.memory_units
365
+ USING hnsw (embedding vector_cosine_ops)
366
+ WITH (m = 16, ef_construction = 64)
367
+ """)
368
+ )
369
+ conn.commit()
370
+
371
+ logger.info(f"Successfully changed embedding dimension to {required_dimension}")
hindsight_api/models.py CHANGED
@@ -41,6 +41,8 @@ from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID
41
41
  from sqlalchemy.ext.asyncio import AsyncAttrs
42
42
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
43
43
 
44
+ from .config import EMBEDDING_DIMENSION
45
+
44
46
 
45
47
  class Base(AsyncAttrs, DeclarativeBase):
46
48
  """Base class for all models."""
@@ -81,7 +83,7 @@ class MemoryUnit(Base):
81
83
  bank_id: Mapped[str] = mapped_column(Text, nullable=False)
82
84
  document_id: Mapped[str | None] = mapped_column(Text)
83
85
  text: Mapped[str] = mapped_column(Text, nullable=False)
84
- embedding = mapped_column(Vector(384)) # pgvector type
86
+ embedding = mapped_column(Vector(EMBEDDING_DIMENSION)) # pgvector type
85
87
  context: Mapped[str | None] = mapped_column(Text)
86
88
  event_date: Mapped[datetime] = mapped_column(
87
89
  TIMESTAMP(timezone=True), nullable=False
@@ -93,7 +95,6 @@ class MemoryUnit(Base):
93
95
  mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
94
96
  fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world")
95
97
  confidence_score: Mapped[float | None] = mapped_column(Float)
96
- access_count: Mapped[int] = mapped_column(Integer, server_default="0")
97
98
  unit_metadata: Mapped[dict] = mapped_column(
98
99
  "metadata", JSONB, server_default=sql_text("'{}'::jsonb")
99
100
  ) # User-defined metadata (str->str)
@@ -129,7 +130,6 @@ class MemoryUnit(Base):
129
130
  Index("idx_memory_units_document_id", "document_id"),
130
131
  Index("idx_memory_units_event_date", "event_date", postgresql_ops={"event_date": "DESC"}),
131
132
  Index("idx_memory_units_bank_date", "bank_id", "event_date", postgresql_ops={"event_date": "DESC"}),
132
- Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}),
133
133
  Index("idx_memory_units_fact_type", "fact_type"),
134
134
  Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"),
135
135
  Index(
hindsight_api/pg0.py CHANGED
@@ -132,3 +132,56 @@ async def stop_embedded_postgres() -> None:
132
132
  global _default_instance
133
133
  if _default_instance:
134
134
  await _default_instance.stop()
135
+
136
+
137
+ def parse_pg0_url(db_url: str) -> tuple[bool, str | None, int | None]:
138
+ """
139
+ Parse a database URL and check if it's a pg0:// embedded database URL.
140
+
141
+ Supports:
142
+ - "pg0" -> default instance "hindsight"
143
+ - "pg0://instance-name" -> named instance
144
+ - "pg0://instance-name:port" -> named instance with explicit port
145
+ - Any other URL (e.g., postgresql://) -> not a pg0 URL
146
+
147
+ Args:
148
+ db_url: The database URL to parse
149
+
150
+ Returns:
151
+ Tuple of (is_pg0, instance_name, port)
152
+ - is_pg0: True if this is a pg0 URL
153
+ - instance_name: The instance name (or None if not pg0)
154
+ - port: The explicit port (or None for auto-assign)
155
+ """
156
+ if db_url == "pg0":
157
+ return True, "hindsight", None
158
+
159
+ if db_url.startswith("pg0://"):
160
+ url_part = db_url[6:] # Remove "pg0://"
161
+ if ":" in url_part:
162
+ instance_name, port_str = url_part.rsplit(":", 1)
163
+ return True, instance_name or "hindsight", int(port_str)
164
+ else:
165
+ return True, url_part or "hindsight", None
166
+
167
+ return False, None, None
168
+
169
+
170
+ async def resolve_database_url(db_url: str) -> str:
171
+ """
172
+ Resolve a database URL, handling pg0:// embedded database URLs.
173
+
174
+ If the URL is a pg0:// URL, starts the embedded PostgreSQL and returns
175
+ the actual postgresql:// connection URL. Otherwise, returns the URL unchanged.
176
+
177
+ Args:
178
+ db_url: Database URL (pg0://, pg0, or postgresql://)
179
+
180
+ Returns:
181
+ The resolved postgresql:// connection URL
182
+ """
183
+ is_pg0, instance_name, port = parse_pg0_url(db_url)
184
+ if is_pg0:
185
+ pg0 = EmbeddedPostgres(name=instance_name, port=port)
186
+ return await pg0.ensure_running()
187
+ return db_url
hindsight_api/server.py CHANGED
@@ -7,6 +7,7 @@ This module provides the ASGI app for uvicorn import string usage:
7
7
  For CLI usage, use the hindsight-api command instead.
8
8
  """
9
9
 
10
+ import logging
10
11
  import os
11
12
  import warnings
12
13
 
@@ -17,6 +18,12 @@ warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProt
17
18
  from hindsight_api import MemoryEngine
18
19
  from hindsight_api.api import create_app
19
20
  from hindsight_api.config import get_config
21
+ from hindsight_api.extensions import (
22
+ DefaultExtensionContext,
23
+ OperationValidatorExtension,
24
+ TenantExtension,
25
+ load_extension,
26
+ )
20
27
 
21
28
  # Disable tokenizers parallelism to avoid warnings
22
29
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
@@ -25,12 +32,42 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
25
32
  config = get_config()
26
33
  config.configure_logging()
27
34
 
35
+ # Load operation validator extension if configured
36
+ operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
37
+ if operation_validator:
38
+ logging.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
39
+
40
+ # Load tenant extension if configured
41
+ tenant_extension = load_extension("TENANT", TenantExtension)
42
+ if tenant_extension:
43
+ logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}")
44
+
28
45
  # Create app at module level (required for uvicorn import string)
29
46
  # MemoryEngine reads configuration from environment variables automatically
30
- _memory = MemoryEngine()
47
+ # Note: run_migrations=True by default, but migrations are idempotent so safe with workers
48
+ _memory = MemoryEngine(
49
+ operation_validator=operation_validator,
50
+ tenant_extension=tenant_extension,
51
+ run_migrations=config.run_migrations_on_startup,
52
+ )
53
+
54
+ # Set extension context on tenant extension (needed for schema provisioning)
55
+ if tenant_extension:
56
+ extension_context = DefaultExtensionContext(
57
+ database_url=config.database_url,
58
+ memory_engine=_memory,
59
+ )
60
+ tenant_extension.set_context(extension_context)
61
+ logging.info("Extension context set on tenant extension")
31
62
 
32
63
  # Create unified app with both HTTP and optionally MCP
33
- app = create_app(memory=_memory, http_api_enabled=True, mcp_api_enabled=config.mcp_enabled, mcp_mount_path="/mcp")
64
+ app = create_app(
65
+ memory=_memory,
66
+ http_api_enabled=True,
67
+ mcp_api_enabled=config.mcp_enabled,
68
+ mcp_mount_path="/mcp",
69
+ initialize_memory=True,
70
+ )
34
71
 
35
72
 
36
73
  if __name__ == "__main__":
@@ -0,0 +1,11 @@
1
+ """
2
+ Worker package for distributed task processing.
3
+
4
+ This package provides:
5
+ - WorkerPoller: Polls PostgreSQL for pending tasks and executes them
6
+ - main: CLI entry point for hindsight-worker
7
+ """
8
+
9
+ from .poller import WorkerPoller
10
+
11
+ __all__ = ["WorkerPoller"]
@@ -0,0 +1,296 @@
1
+ """
2
+ Command-line interface for Hindsight Worker.
3
+
4
+ Run the worker with:
5
+ hindsight-worker
6
+
7
+ Stop with Ctrl+C (graceful shutdown).
8
+ """
9
+
10
+ import argparse
11
+ import asyncio
12
+ import atexit
13
+ import logging
14
+ import os
15
+ import signal
16
+ import socket
17
+ import sys
18
+ import warnings
19
+
20
+ from ..config import get_config
21
+ from ..engine.task_backend import SyncTaskBackend
22
+ from .poller import WorkerPoller
23
+
24
+ # Filter deprecation warnings from third-party libraries
25
+ warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
26
+ warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
27
+
28
+ # Disable tokenizers parallelism to avoid warnings
29
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def create_worker_app(poller: WorkerPoller, memory):
35
+ """Create a minimal FastAPI app for worker metrics and health."""
36
+ from fastapi import FastAPI
37
+ from fastapi.responses import JSONResponse, Response
38
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
39
+
40
+ from ..metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
41
+
42
+ app = FastAPI(
43
+ title="Hindsight Worker",
44
+ description="Worker process for distributed task execution",
45
+ )
46
+
47
+ # Initialize OpenTelemetry metrics
48
+ try:
49
+ prometheus_reader = initialize_metrics(service_name="hindsight-worker", service_version="1.0.0")
50
+ create_metrics_collector()
51
+ app.state.prometheus_reader = prometheus_reader
52
+ logger.info("Metrics initialized - available at /metrics endpoint")
53
+ except Exception as e:
54
+ logger.warning(f"Failed to initialize metrics: {e}. Metrics will be disabled.")
55
+ app.state.prometheus_reader = None
56
+
57
+ # Set up DB pool metrics if available
58
+ metrics_collector = get_metrics_collector()
59
+ if memory._pool is not None and hasattr(metrics_collector, "set_db_pool"):
60
+ metrics_collector.set_db_pool(memory._pool)
61
+ logger.info("DB pool metrics configured")
62
+
63
+ @app.get(
64
+ "/health",
65
+ summary="Health check endpoint",
66
+ description="Returns worker health status including database connectivity",
67
+ tags=["Monitoring"],
68
+ )
69
+ async def health_endpoint():
70
+ """Health check endpoint."""
71
+ health = await memory.health_check()
72
+ health["worker_id"] = poller.worker_id
73
+ health["is_shutdown"] = poller.is_shutdown
74
+ status_code = 200 if health.get("status") == "healthy" else 503
75
+ return JSONResponse(content=health, status_code=status_code)
76
+
77
+ @app.get(
78
+ "/metrics",
79
+ summary="Prometheus metrics endpoint",
80
+ description="Exports metrics in Prometheus format for scraping",
81
+ tags=["Monitoring"],
82
+ )
83
+ async def metrics_endpoint():
84
+ """Return Prometheus metrics."""
85
+ metrics_data = generate_latest()
86
+ return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST)
87
+
88
+ @app.get(
89
+ "/",
90
+ summary="Worker info",
91
+ description="Basic worker information",
92
+ tags=["Info"],
93
+ )
94
+ async def root():
95
+ """Return basic worker info."""
96
+ return {
97
+ "service": "hindsight-worker",
98
+ "worker_id": poller.worker_id,
99
+ "is_shutdown": poller.is_shutdown,
100
+ }
101
+
102
+ return app
103
+
104
+
105
+ def main():
106
+ """Main entry point for the hindsight-worker CLI."""
107
+ # Load configuration from environment
108
+ config = get_config()
109
+
110
+ parser = argparse.ArgumentParser(
111
+ prog="hindsight-worker",
112
+ description="Hindsight Worker - distributed task processor",
113
+ )
114
+
115
+ # Worker options
116
+ parser.add_argument(
117
+ "--worker-id",
118
+ default=config.worker_id or socket.gethostname(),
119
+ help="Worker identifier (default: hostname, env: HINDSIGHT_API_WORKER_ID)",
120
+ )
121
+ parser.add_argument(
122
+ "--poll-interval",
123
+ type=int,
124
+ default=config.worker_poll_interval_ms,
125
+ help=f"Poll interval in milliseconds (default: {config.worker_poll_interval_ms}, env: HINDSIGHT_API_WORKER_POLL_INTERVAL_MS)",
126
+ )
127
+ parser.add_argument(
128
+ "--batch-size",
129
+ type=int,
130
+ default=config.worker_batch_size,
131
+ help=f"Tasks to claim per poll (default: {config.worker_batch_size}, env: HINDSIGHT_API_WORKER_BATCH_SIZE)",
132
+ )
133
+ parser.add_argument(
134
+ "--max-retries",
135
+ type=int,
136
+ default=config.worker_max_retries,
137
+ help=f"Max retries before marking failed (default: {config.worker_max_retries}, env: HINDSIGHT_API_WORKER_MAX_RETRIES)",
138
+ )
139
+
140
+ # HTTP server options
141
+ parser.add_argument(
142
+ "--http-port",
143
+ type=int,
144
+ default=config.worker_http_port,
145
+ help=f"HTTP port for metrics/health endpoints (default: {config.worker_http_port}, env: HINDSIGHT_API_WORKER_HTTP_PORT)",
146
+ )
147
+ parser.add_argument(
148
+ "--http-host",
149
+ default="0.0.0.0",
150
+ help="HTTP host to bind (default: 0.0.0.0)",
151
+ )
152
+
153
+ # Logging options
154
+ parser.add_argument(
155
+ "--log-level",
156
+ default=config.log_level,
157
+ choices=["critical", "error", "warning", "info", "debug", "trace"],
158
+ help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)",
159
+ )
160
+
161
+ args = parser.parse_args()
162
+
163
+ # Configure logging
164
+ config.configure_logging()
165
+
166
+ # Import MemoryEngine here to avoid circular imports
167
+ from .. import MemoryEngine
168
+
169
+ print(f"Starting Hindsight Worker: {args.worker_id}")
170
+ print(f" Poll interval: {args.poll_interval}ms")
171
+ print(f" Batch size: {args.batch_size}")
172
+ print(f" Max retries: {args.max_retries}")
173
+ print(f" HTTP server: {args.http_host}:{args.http_port}")
174
+ print()
175
+
176
+ # Global references for cleanup
177
+ memory = None
178
+ poller = None
179
+
180
+ async def run():
181
+ nonlocal memory, poller
182
+ import uvicorn
183
+
184
+ from ..extensions import TenantExtension, load_extension
185
+
186
+ # Initialize MemoryEngine
187
+ # Workers use SyncTaskBackend because they execute tasks directly,
188
+ # they don't need to store tasks (they poll from DB)
189
+ memory = MemoryEngine(
190
+ run_migrations=False, # Workers don't run migrations
191
+ task_backend=SyncTaskBackend(),
192
+ )
193
+
194
+ await memory.initialize()
195
+
196
+ print(f"Database connected: {config.database_url}")
197
+
198
+ # Load tenant extension for dynamic schema discovery
199
+ tenant_extension = load_extension("TENANT", TenantExtension)
200
+
201
+ if tenant_extension:
202
+ print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
203
+ else:
204
+ print("No tenant extension configured, using public schema only")
205
+
206
+ # Create a single poller that handles all schemas dynamically
207
+ poller = WorkerPoller(
208
+ pool=memory._pool,
209
+ worker_id=args.worker_id,
210
+ executor=memory.execute_task,
211
+ poll_interval_ms=args.poll_interval,
212
+ batch_size=args.batch_size,
213
+ max_retries=args.max_retries,
214
+ tenant_extension=tenant_extension,
215
+ )
216
+
217
+ # Create the HTTP app for metrics/health
218
+ app = create_worker_app(poller, memory)
219
+
220
+ # Setup signal handlers for graceful shutdown
221
+ shutdown_requested = asyncio.Event()
222
+
223
+ def signal_handler(signum, frame):
224
+ print(f"\nReceived signal {signum}, initiating graceful shutdown...")
225
+ shutdown_requested.set()
226
+
227
+ signal.signal(signal.SIGINT, signal_handler)
228
+ signal.signal(signal.SIGTERM, signal_handler)
229
+
230
+ # Create uvicorn config and server
231
+ uvicorn_config = uvicorn.Config(
232
+ app,
233
+ host=args.http_host,
234
+ port=args.http_port,
235
+ log_level="info", # Reduce uvicorn noise
236
+ access_log=False,
237
+ )
238
+ server = uvicorn.Server(uvicorn_config)
239
+
240
+ # Run the poller and HTTP server concurrently
241
+ poller_task = asyncio.create_task(poller.run())
242
+ http_task = asyncio.create_task(server.serve())
243
+
244
+ print(f"Worker started. Metrics available at http://{args.http_host}:{args.http_port}/metrics")
245
+
246
+ # Wait for shutdown signal
247
+ await shutdown_requested.wait()
248
+
249
+ # Graceful shutdown
250
+ print("Shutting down HTTP server...")
251
+ server.should_exit = True
252
+
253
+ print("Waiting for poller to finish...")
254
+ await poller.shutdown_graceful(timeout=30.0)
255
+ poller_task.cancel()
256
+ try:
257
+ await poller_task
258
+ except asyncio.CancelledError:
259
+ pass
260
+
261
+ # Wait for HTTP server to finish
262
+ try:
263
+ await asyncio.wait_for(http_task, timeout=5.0)
264
+ except asyncio.TimeoutError:
265
+ http_task.cancel()
266
+ try:
267
+ await http_task
268
+ except asyncio.CancelledError:
269
+ pass
270
+
271
+ # Close memory engine
272
+ await memory.close()
273
+ print("Worker shutdown complete")
274
+
275
+ def cleanup():
276
+ """Synchronous cleanup for atexit."""
277
+ if memory is not None and memory._pg0 is not None:
278
+ try:
279
+ loop = asyncio.new_event_loop()
280
+ loop.run_until_complete(memory._pg0.stop())
281
+ loop.close()
282
+ print("\npg0 stopped.")
283
+ except Exception as e:
284
+ print(f"\nError stopping pg0: {e}")
285
+
286
+ atexit.register(cleanup)
287
+
288
+ try:
289
+ asyncio.run(run())
290
+ except KeyboardInterrupt:
291
+ print("\nWorker interrupted")
292
+ sys.exit(0)
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()