@lota-sdk/core 0.1.5

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 (153) hide show
  1. package/infrastructure/schema/00_workstream.surql +55 -0
  2. package/infrastructure/schema/01_memory.surql +47 -0
  3. package/infrastructure/schema/02_execution_plan.surql +62 -0
  4. package/infrastructure/schema/03_learned_skill.surql +32 -0
  5. package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
  6. package/package.json +128 -0
  7. package/src/ai/definitions.ts +308 -0
  8. package/src/bifrost/bifrost.ts +256 -0
  9. package/src/config/agent-defaults.ts +99 -0
  10. package/src/config/constants.ts +33 -0
  11. package/src/config/env-shapes.ts +122 -0
  12. package/src/config/logger.ts +29 -0
  13. package/src/config/model-constants.ts +31 -0
  14. package/src/config/search.ts +17 -0
  15. package/src/config/workstream-defaults.ts +68 -0
  16. package/src/db/base.service.ts +55 -0
  17. package/src/db/cursor-pagination.ts +73 -0
  18. package/src/db/memory-query-builder.ts +207 -0
  19. package/src/db/memory-store.helpers.ts +118 -0
  20. package/src/db/memory-store.rows.ts +29 -0
  21. package/src/db/memory-store.ts +974 -0
  22. package/src/db/memory-types.ts +193 -0
  23. package/src/db/memory.ts +505 -0
  24. package/src/db/record-id.ts +78 -0
  25. package/src/db/service.ts +932 -0
  26. package/src/db/startup.ts +152 -0
  27. package/src/db/tables.ts +20 -0
  28. package/src/document/org-document-chunking.ts +224 -0
  29. package/src/document/parsing.ts +40 -0
  30. package/src/embeddings/provider.ts +76 -0
  31. package/src/index.ts +302 -0
  32. package/src/queues/context-compaction.queue.ts +82 -0
  33. package/src/queues/document-processor.queue.ts +118 -0
  34. package/src/queues/memory-consolidation.queue.ts +65 -0
  35. package/src/queues/post-chat-memory.queue.ts +128 -0
  36. package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
  37. package/src/queues/regular-chat-memory-digest.config.ts +12 -0
  38. package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
  39. package/src/queues/skill-extraction.config.ts +9 -0
  40. package/src/queues/skill-extraction.queue.ts +62 -0
  41. package/src/redis/connection.ts +176 -0
  42. package/src/redis/index.ts +30 -0
  43. package/src/redis/org-memory-lock.ts +43 -0
  44. package/src/redis/redis-lease-lock.ts +158 -0
  45. package/src/runtime/agent-contract.ts +1 -0
  46. package/src/runtime/agent-prompt-context.ts +119 -0
  47. package/src/runtime/agent-runtime-policy.ts +192 -0
  48. package/src/runtime/agent-stream-helpers.ts +117 -0
  49. package/src/runtime/agent-types.ts +22 -0
  50. package/src/runtime/approval-continuation.ts +16 -0
  51. package/src/runtime/chat-attachments.ts +46 -0
  52. package/src/runtime/chat-message.ts +10 -0
  53. package/src/runtime/chat-request-routing.ts +21 -0
  54. package/src/runtime/chat-run-orchestration.ts +25 -0
  55. package/src/runtime/chat-run-registry.ts +20 -0
  56. package/src/runtime/chat-types.ts +18 -0
  57. package/src/runtime/context-compaction-constants.ts +11 -0
  58. package/src/runtime/context-compaction-runtime.ts +86 -0
  59. package/src/runtime/context-compaction.ts +909 -0
  60. package/src/runtime/execution-plan.ts +59 -0
  61. package/src/runtime/helper-model.ts +405 -0
  62. package/src/runtime/indexed-repositories-policy.ts +28 -0
  63. package/src/runtime/instruction-sections.ts +8 -0
  64. package/src/runtime/llm-content.ts +71 -0
  65. package/src/runtime/memory-block.ts +264 -0
  66. package/src/runtime/memory-digest-policy.ts +14 -0
  67. package/src/runtime/memory-format.ts +8 -0
  68. package/src/runtime/memory-pipeline.ts +570 -0
  69. package/src/runtime/memory-prompts-fact.ts +47 -0
  70. package/src/runtime/memory-prompts-parse.ts +3 -0
  71. package/src/runtime/memory-prompts-update.ts +37 -0
  72. package/src/runtime/memory-scope.ts +43 -0
  73. package/src/runtime/plugin-types.ts +10 -0
  74. package/src/runtime/retrieval-adapters.ts +25 -0
  75. package/src/runtime/retrieval-pipeline.ts +3 -0
  76. package/src/runtime/runtime-extensions.ts +154 -0
  77. package/src/runtime/skill-extraction-policy.ts +3 -0
  78. package/src/runtime/team-consultation-orchestrator.ts +245 -0
  79. package/src/runtime/team-consultation-prompts.ts +32 -0
  80. package/src/runtime/title-helpers.ts +12 -0
  81. package/src/runtime/turn-lifecycle.ts +28 -0
  82. package/src/runtime/workstream-chat-helpers.ts +187 -0
  83. package/src/runtime/workstream-routing-policy.ts +301 -0
  84. package/src/runtime/workstream-state.ts +261 -0
  85. package/src/services/attachment.service.ts +159 -0
  86. package/src/services/chat-attachments.service.ts +17 -0
  87. package/src/services/chat-run-registry.service.ts +3 -0
  88. package/src/services/context-compaction-runtime.ts +13 -0
  89. package/src/services/context-compaction.service.ts +115 -0
  90. package/src/services/document-chunk.service.ts +141 -0
  91. package/src/services/execution-plan.service.ts +890 -0
  92. package/src/services/learned-skill.service.ts +328 -0
  93. package/src/services/memory-assessment.service.ts +43 -0
  94. package/src/services/memory.service.ts +807 -0
  95. package/src/services/memory.utils.ts +84 -0
  96. package/src/services/mutating-approval.service.ts +110 -0
  97. package/src/services/recent-activity-title.service.ts +74 -0
  98. package/src/services/recent-activity.service.ts +397 -0
  99. package/src/services/workstream-change-tracker.service.ts +313 -0
  100. package/src/services/workstream-message.service.ts +283 -0
  101. package/src/services/workstream-title.service.ts +58 -0
  102. package/src/services/workstream-turn-preparation.ts +1340 -0
  103. package/src/services/workstream-turn.ts +37 -0
  104. package/src/services/workstream.service.ts +854 -0
  105. package/src/services/workstream.types.ts +118 -0
  106. package/src/storage/attachment-parser.ts +101 -0
  107. package/src/storage/attachment-storage.service.ts +391 -0
  108. package/src/storage/attachments.types.ts +11 -0
  109. package/src/storage/attachments.utils.ts +58 -0
  110. package/src/storage/generated-document-storage.service.ts +55 -0
  111. package/src/system-agents/agent-result.ts +27 -0
  112. package/src/system-agents/context-compacter.agent.ts +46 -0
  113. package/src/system-agents/delegated-agent-factory.ts +177 -0
  114. package/src/system-agents/helper-agent-options.ts +20 -0
  115. package/src/system-agents/memory-reranker.agent.ts +38 -0
  116. package/src/system-agents/memory.agent.ts +58 -0
  117. package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
  118. package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
  119. package/src/system-agents/researcher.agent.ts +34 -0
  120. package/src/system-agents/skill-extractor.agent.ts +88 -0
  121. package/src/system-agents/skill-manager.agent.ts +80 -0
  122. package/src/system-agents/title-generator.agent.ts +42 -0
  123. package/src/system-agents/workstream-tracker.agent.ts +58 -0
  124. package/src/tools/execution-plan.tool.ts +163 -0
  125. package/src/tools/fetch-webpage.tool.ts +132 -0
  126. package/src/tools/firecrawl-client.ts +12 -0
  127. package/src/tools/memory-block.tool.ts +55 -0
  128. package/src/tools/read-file-parts.tool.ts +80 -0
  129. package/src/tools/remember-memory.tool.ts +85 -0
  130. package/src/tools/research-topic.tool.ts +15 -0
  131. package/src/tools/search-tools.ts +55 -0
  132. package/src/tools/search-web.tool.ts +175 -0
  133. package/src/tools/team-think.tool.ts +125 -0
  134. package/src/tools/tool-contract.ts +21 -0
  135. package/src/tools/user-questions.tool.ts +18 -0
  136. package/src/utils/async.ts +50 -0
  137. package/src/utils/date-time.ts +34 -0
  138. package/src/utils/error.ts +10 -0
  139. package/src/utils/errors.ts +28 -0
  140. package/src/utils/hono-error-handler.ts +71 -0
  141. package/src/utils/string.ts +51 -0
  142. package/src/workers/bootstrap.ts +44 -0
  143. package/src/workers/memory-consolidation.worker.ts +318 -0
  144. package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
  145. package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
  146. package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
  147. package/src/workers/skill-extraction.runner.ts +331 -0
  148. package/src/workers/skill-extraction.worker.ts +22 -0
  149. package/src/workers/utils/repo-indexer-chunker.ts +331 -0
  150. package/src/workers/utils/repo-structure-extractor.ts +645 -0
  151. package/src/workers/utils/repomix-process-concurrency.ts +65 -0
  152. package/src/workers/utils/sandbox-error.ts +5 -0
  153. package/src/workers/worker-utils.ts +182 -0
@@ -0,0 +1,69 @@
1
+ import { Queue, Worker } from 'bullmq'
2
+ import type { Job } from 'bullmq'
3
+
4
+ import { serverLogger } from '../config/logger'
5
+ import { databaseService } from '../db/service'
6
+ import { getRedisConnectionForBullMQ } from '../redis'
7
+ import { recentActivityTitleService } from '../services/recent-activity-title.service'
8
+ import {
9
+ attachWorkerEvents,
10
+ createTracedWorkerProcessor,
11
+ createWorkerShutdown,
12
+ registerShutdownSignals,
13
+ } from '../workers/worker-utils'
14
+ import type { WorkerHandle } from '../workers/worker-utils'
15
+
16
+ interface RecentActivityTitleRefinementJob {
17
+ activityId: string
18
+ }
19
+
20
+ const RECENT_ACTIVITY_TITLE_REFINEMENT_QUEUE = 'recent-activity-title-refinement'
21
+
22
+ let _recentActivityTitleRefinementQueue: Queue<RecentActivityTitleRefinementJob> | null = null
23
+ function getRecentActivityTitleRefinementQueue(): Queue<RecentActivityTitleRefinementJob> {
24
+ if (!_recentActivityTitleRefinementQueue) {
25
+ _recentActivityTitleRefinementQueue = new Queue<RecentActivityTitleRefinementJob>(
26
+ RECENT_ACTIVITY_TITLE_REFINEMENT_QUEUE,
27
+ {
28
+ connection: getRedisConnectionForBullMQ(),
29
+ defaultJobOptions: {
30
+ removeOnComplete: 200,
31
+ removeOnFail: 200,
32
+ attempts: 3,
33
+ backoff: { type: 'exponential', delay: 2_000 },
34
+ },
35
+ },
36
+ )
37
+ }
38
+ return _recentActivityTitleRefinementQueue
39
+ }
40
+
41
+ export async function enqueueRecentActivityTitleRefinement(job: RecentActivityTitleRefinementJob) {
42
+ return await getRecentActivityTitleRefinementQueue().add('refine-recent-activity-title', job, {
43
+ jobId: `recent-activity-title:${job.activityId}`,
44
+ })
45
+ }
46
+
47
+ async function processRecentActivityTitleRefinementJob(job: Job<RecentActivityTitleRefinementJob>): Promise<void> {
48
+ await databaseService.connect()
49
+ await recentActivityTitleService.refineRecentActivityTitle(job.data.activityId)
50
+ }
51
+
52
+ export function startRecentActivityTitleRefinementWorker(options: { registerSignals?: boolean } = {}): WorkerHandle {
53
+ const { registerSignals = import.meta.main } = options
54
+ const worker = new Worker(
55
+ RECENT_ACTIVITY_TITLE_REFINEMENT_QUEUE,
56
+ createTracedWorkerProcessor(RECENT_ACTIVITY_TITLE_REFINEMENT_QUEUE, processRecentActivityTitleRefinementJob),
57
+ { connection: getRedisConnectionForBullMQ(), concurrency: 2, lockDuration: 300_000 },
58
+ )
59
+
60
+ attachWorkerEvents(worker, 'Recent activity title refinement', serverLogger)
61
+
62
+ const shutdown = createWorkerShutdown(worker, 'Recent activity title refinement', serverLogger)
63
+
64
+ if (registerSignals) {
65
+ registerShutdownSignals({ name: 'Recent activity title refinement', shutdown, logger: serverLogger })
66
+ }
67
+
68
+ return { worker, shutdown }
69
+ }
@@ -0,0 +1,12 @@
1
+ const REGULAR_CHAT_MEMORY_DIGEST_DELAY_MS = 15 * 60 * 1000
2
+
3
+ export function buildRegularChatMemoryDigestDeduplicationId(orgId: string): string {
4
+ return `regular-chat-digest:${orgId}`
5
+ }
6
+
7
+ export function buildRegularChatMemoryDigestJobOptions(orgId: string) {
8
+ return {
9
+ delay: REGULAR_CHAT_MEMORY_DIGEST_DELAY_MS,
10
+ deduplication: { id: buildRegularChatMemoryDigestDeduplicationId(orgId) },
11
+ }
12
+ }
@@ -0,0 +1,73 @@
1
+ import { Queue, Worker } from 'bullmq'
2
+
3
+ import { serverLogger } from '../config/logger'
4
+ import { getRedisConnectionForBullMQ } from '../redis'
5
+ import {
6
+ attachWorkerEvents,
7
+ getWorkerPath,
8
+ createWorkerShutdown,
9
+ registerShutdownSignals,
10
+ } from '../workers/worker-utils'
11
+ import type { WorkerHandle } from '../workers/worker-utils'
12
+ import {
13
+ buildRegularChatMemoryDigestDeduplicationId,
14
+ buildRegularChatMemoryDigestJobOptions,
15
+ } from './regular-chat-memory-digest.config'
16
+
17
+ export interface RegularChatMemoryDigestJob {
18
+ orgId: string
19
+ }
20
+
21
+ const REGULAR_CHAT_MEMORY_DIGEST_QUEUE = 'regular-chat-memory-digest'
22
+
23
+ let _regularChatMemoryDigestQueue: Queue<RegularChatMemoryDigestJob> | null = null
24
+ function getRegularChatMemoryDigestQueue(): Queue<RegularChatMemoryDigestJob> {
25
+ if (!_regularChatMemoryDigestQueue) {
26
+ _regularChatMemoryDigestQueue = new Queue<RegularChatMemoryDigestJob>(REGULAR_CHAT_MEMORY_DIGEST_QUEUE, {
27
+ connection: getRedisConnectionForBullMQ(),
28
+ defaultJobOptions: {
29
+ removeOnComplete: 200,
30
+ removeOnFail: 200,
31
+ attempts: 2,
32
+ backoff: { type: 'exponential', delay: 5000 },
33
+ },
34
+ })
35
+ }
36
+ return _regularChatMemoryDigestQueue
37
+ }
38
+
39
+ export async function enqueueRegularChatMemoryDigest(job: RegularChatMemoryDigestJob) {
40
+ return await getRegularChatMemoryDigestQueue().add(
41
+ 'run-digest',
42
+ job,
43
+ buildRegularChatMemoryDigestJobOptions(job.orgId),
44
+ )
45
+ }
46
+
47
+ export async function clearRegularChatMemoryDigestDeduplicationKey(orgId: string): Promise<void> {
48
+ await getRegularChatMemoryDigestQueue().removeDeduplicationKey(buildRegularChatMemoryDigestDeduplicationId(orgId))
49
+ }
50
+
51
+ export function startRegularChatMemoryDigestWorker(options: { registerSignals?: boolean } = {}): WorkerHandle {
52
+ const { registerSignals = import.meta.main } = options
53
+ const processorPath = getWorkerPath('regular-chat-memory-digest.worker.ts')
54
+ const worker = new Worker(REGULAR_CHAT_MEMORY_DIGEST_QUEUE, processorPath, {
55
+ connection: getRedisConnectionForBullMQ(),
56
+ concurrency: 1,
57
+ lockDuration: 600_000,
58
+ })
59
+
60
+ attachWorkerEvents(worker, 'Regular chat memory digest', serverLogger)
61
+
62
+ const shutdown = createWorkerShutdown(worker, 'Regular chat memory digest', serverLogger)
63
+
64
+ if (registerSignals) {
65
+ registerShutdownSignals({ name: 'Regular chat memory digest', shutdown, logger: serverLogger })
66
+ }
67
+
68
+ return { worker, shutdown }
69
+ }
70
+
71
+ if (import.meta.main) {
72
+ startRegularChatMemoryDigestWorker()
73
+ }
@@ -0,0 +1,9 @@
1
+ const SKILL_EXTRACTION_DELAY_MS = 15 * 60 * 1000
2
+
3
+ export function buildSkillExtractionDeduplicationId(orgId: string): string {
4
+ return `skill-extraction:${orgId}`
5
+ }
6
+
7
+ export function buildSkillExtractionJobOptions(orgId: string) {
8
+ return { delay: SKILL_EXTRACTION_DELAY_MS, deduplication: { id: buildSkillExtractionDeduplicationId(orgId) } }
9
+ }
@@ -0,0 +1,62 @@
1
+ import { Queue, Worker } from 'bullmq'
2
+
3
+ import { serverLogger } from '../config/logger'
4
+ import { getRedisConnectionForBullMQ } from '../redis'
5
+ import {
6
+ attachWorkerEvents,
7
+ getWorkerPath,
8
+ createWorkerShutdown,
9
+ registerShutdownSignals,
10
+ } from '../workers/worker-utils'
11
+ import type { WorkerHandle } from '../workers/worker-utils'
12
+ import { buildSkillExtractionJobOptions } from './skill-extraction.config'
13
+
14
+ export interface SkillExtractionJob {
15
+ orgId: string
16
+ }
17
+
18
+ const SKILL_EXTRACTION_QUEUE = 'skill-extraction'
19
+
20
+ let _skillExtractionQueue: Queue<SkillExtractionJob> | null = null
21
+ function getSkillExtractionQueue(): Queue<SkillExtractionJob> {
22
+ if (!_skillExtractionQueue) {
23
+ _skillExtractionQueue = new Queue<SkillExtractionJob>(SKILL_EXTRACTION_QUEUE, {
24
+ connection: getRedisConnectionForBullMQ(),
25
+ defaultJobOptions: {
26
+ removeOnComplete: 200,
27
+ removeOnFail: 200,
28
+ attempts: 2,
29
+ backoff: { type: 'exponential', delay: 5000 },
30
+ },
31
+ })
32
+ }
33
+ return _skillExtractionQueue
34
+ }
35
+
36
+ export async function enqueueSkillExtraction(job: SkillExtractionJob) {
37
+ return await getSkillExtractionQueue().add('run-extraction', job, buildSkillExtractionJobOptions(job.orgId))
38
+ }
39
+
40
+ export function startSkillExtractionWorker(options: { registerSignals?: boolean } = {}): WorkerHandle {
41
+ const { registerSignals = import.meta.main } = options
42
+ const processorPath = getWorkerPath('skill-extraction.worker.ts')
43
+ const worker = new Worker(SKILL_EXTRACTION_QUEUE, processorPath, {
44
+ connection: getRedisConnectionForBullMQ(),
45
+ concurrency: 1,
46
+ lockDuration: 600_000,
47
+ })
48
+
49
+ attachWorkerEvents(worker, 'Skill extraction', serverLogger)
50
+
51
+ const shutdown = createWorkerShutdown(worker, 'Skill extraction', serverLogger)
52
+
53
+ if (registerSignals) {
54
+ registerShutdownSignals({ name: 'Skill extraction', shutdown, logger: serverLogger })
55
+ }
56
+
57
+ return { worker, shutdown }
58
+ }
59
+
60
+ if (import.meta.main) {
61
+ startSkillExtractionWorker()
62
+ }
@@ -0,0 +1,176 @@
1
+ import IORedis from 'ioredis'
2
+ import type { RedisOptions } from 'ioredis'
3
+
4
+ import { getErrorMessage } from '../utils/error'
5
+
6
+ export interface RedisConnectionLogger {
7
+ debug?: (message: string) => void
8
+ info?: (message: string) => void
9
+ warn?: (message: string) => void
10
+ error?: (message: string) => void
11
+ }
12
+
13
+ export interface CreateRedisConnectionManagerOptions {
14
+ url: string
15
+ redisOptions?: RedisOptions
16
+ healthCheckIntervalMs?: number
17
+ logger?: RedisConnectionLogger
18
+ }
19
+
20
+ export interface RedisConnectionManager {
21
+ getConnection(): IORedis
22
+ getConnectionForBullMQ(): IORedis
23
+ isConnectionHealthy(): boolean
24
+ closeConnection(): Promise<void>
25
+ }
26
+
27
+ const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 30_000
28
+
29
+ export const DEFAULT_REDIS_OPTIONS: RedisOptions = {
30
+ maxRetriesPerRequest: null,
31
+ enableReadyCheck: true,
32
+ enableOfflineQueue: true,
33
+ retryStrategy: (times: number) => {
34
+ const delay = Math.min(times * 50, 2000)
35
+ return delay
36
+ },
37
+ reconnectOnError: (err: Error) => {
38
+ const targetErrors = ['READONLY', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN']
39
+ return targetErrors.some((candidate) => err.message.includes(candidate))
40
+ },
41
+ connectTimeout: 10000,
42
+ lazyConnect: false,
43
+ }
44
+
45
+ function log(logger: RedisConnectionLogger | undefined, level: keyof RedisConnectionLogger, message: string): void {
46
+ const sink = logger?.[level]
47
+ if (typeof sink === 'function') {
48
+ sink(message)
49
+ }
50
+ }
51
+
52
+ class RedisConnectionManagerImpl implements RedisConnectionManager {
53
+ private redis: IORedis | null = null
54
+ private isHealthy = false
55
+ private healthCheckInterval: ReturnType<typeof setInterval> | null = null
56
+ private isInitialized = false
57
+ private isClosing = false
58
+
59
+ constructor(private readonly options: CreateRedisConnectionManagerOptions) {
60
+ this.initializeConnection()
61
+ }
62
+
63
+ private initializeConnection(): void {
64
+ if (this.isInitialized) {
65
+ return
66
+ }
67
+
68
+ this.isInitialized = true
69
+
70
+ try {
71
+ const redisOptions = this.options.redisOptions ?? DEFAULT_REDIS_OPTIONS
72
+ this.redis = new IORedis(this.options.url, redisOptions)
73
+ this.setupEventHandlers()
74
+ this.startHealthChecks()
75
+ } catch (error) {
76
+ log(this.options.logger, 'error', `Failed to initialize Redis connection: ${getErrorMessage(error)}`)
77
+ }
78
+ }
79
+
80
+ private setupEventHandlers(): void {
81
+ if (!this.redis) return
82
+
83
+ this.redis.on('connect', () => {
84
+ log(this.options.logger, 'info', 'Redis connected')
85
+ this.isHealthy = true
86
+ })
87
+
88
+ this.redis.on('ready', () => {
89
+ log(this.options.logger, 'info', 'Redis ready')
90
+ this.isHealthy = true
91
+ })
92
+
93
+ this.redis.on('error', (error: Error) => {
94
+ log(this.options.logger, 'error', `Redis error: ${error.message}`)
95
+ this.isHealthy = false
96
+ })
97
+
98
+ this.redis.on('close', () => {
99
+ log(
100
+ this.options.logger,
101
+ this.isClosing ? 'info' : 'warn',
102
+ this.isClosing ? 'Redis connection closed during shutdown' : 'Redis connection closed',
103
+ )
104
+ this.isHealthy = false
105
+ })
106
+
107
+ this.redis.on('reconnecting', () => {
108
+ log(this.options.logger, 'info', 'Redis reconnecting...')
109
+ })
110
+
111
+ this.redis.on('end', () => {
112
+ log(
113
+ this.options.logger,
114
+ this.isClosing ? 'info' : 'warn',
115
+ this.isClosing ? 'Redis connection ended during shutdown' : 'Redis connection ended',
116
+ )
117
+ this.isHealthy = false
118
+ })
119
+ }
120
+
121
+ private startHealthChecks(): void {
122
+ if (this.healthCheckInterval) {
123
+ return
124
+ }
125
+
126
+ const intervalMs = this.options.healthCheckIntervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS
127
+ this.healthCheckInterval = setInterval(async () => {
128
+ try {
129
+ if (this.redis && this.redis.status === 'ready') {
130
+ await this.redis.ping()
131
+ this.isHealthy = true
132
+ }
133
+ } catch (error) {
134
+ log(this.options.logger, 'warn', `Redis health check failed: ${getErrorMessage(error)}`)
135
+ this.isHealthy = false
136
+ }
137
+ }, intervalMs)
138
+ }
139
+
140
+ getConnection(): IORedis {
141
+ if (!this.redis) {
142
+ throw new Error('Redis connection not initialized')
143
+ }
144
+ return this.redis
145
+ }
146
+
147
+ getConnectionForBullMQ(): IORedis {
148
+ return this.getConnection()
149
+ }
150
+
151
+ isConnectionHealthy(): boolean {
152
+ return this.isHealthy && this.redis?.status === 'ready'
153
+ }
154
+
155
+ async closeConnection(): Promise<void> {
156
+ if (this.healthCheckInterval) {
157
+ clearInterval(this.healthCheckInterval)
158
+ this.healthCheckInterval = null
159
+ }
160
+
161
+ this.isClosing = true
162
+
163
+ try {
164
+ if (this.redis && this.redis.status !== 'end') {
165
+ await this.redis.quit()
166
+ }
167
+ } catch (error) {
168
+ log(this.options.logger, 'warn', `Redis quit failed, forcing disconnect: ${getErrorMessage(error)}`)
169
+ this.redis?.disconnect()
170
+ }
171
+ }
172
+ }
173
+
174
+ export function createRedisConnectionManager(options: CreateRedisConnectionManagerOptions): RedisConnectionManager {
175
+ return new RedisConnectionManagerImpl(options)
176
+ }
@@ -0,0 +1,30 @@
1
+ import type IORedis from 'ioredis'
2
+
3
+ import { createRedisConnectionManager } from './connection'
4
+ import type { RedisConnectionManager } from './connection'
5
+
6
+ export { createRedisConnectionManager }
7
+ export type { RedisConnectionManager }
8
+
9
+ let _redisManager: { getConnection(): IORedis; getConnectionForBullMQ(): IORedis } | undefined
10
+
11
+ export function setRedisConnectionManager(manager: {
12
+ getConnection(): IORedis
13
+ getConnectionForBullMQ(): IORedis
14
+ }): void {
15
+ _redisManager = manager
16
+ }
17
+
18
+ export function getRedisConnection(): IORedis {
19
+ if (!_redisManager) {
20
+ throw new Error('Redis connection manager not configured. Call setRedisConnectionManager() first.')
21
+ }
22
+ return _redisManager.getConnection()
23
+ }
24
+
25
+ export function getRedisConnectionForBullMQ(): IORedis {
26
+ if (!_redisManager) {
27
+ throw new Error('Redis connection manager not configured. Call setRedisConnectionManager() first.')
28
+ }
29
+ return _redisManager.getConnectionForBullMQ()
30
+ }
@@ -0,0 +1,43 @@
1
+ import { getRedisConnection } from '.'
2
+ import { serverLogger } from '../config/logger'
3
+ import { withRedisLeaseLock } from './redis-lease-lock'
4
+
5
+ const ORG_MEMORY_LOCK_PREFIX = 'lock:org-memory:org:'
6
+ const ORG_MEMORY_LOCK_TTL_MS = 120_000
7
+ const ORG_MEMORY_LOCK_RETRY_DELAY_MS = 500
8
+ const ORG_MEMORY_LOCK_REFRESH_INTERVAL_MS = 30_000
9
+ const ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS = 30_000
10
+ const ORG_MEMORY_LOCK_MAX_WAIT_MS = 15 * 60 * 1000
11
+
12
+ export async function withOrgMemoryLock<T>(orgId: string, fn: () => Promise<T>): Promise<T> {
13
+ const normalizedOrgId = orgId.trim()
14
+
15
+ if (!normalizedOrgId) {
16
+ throw new Error('Organization id is required for memory lock')
17
+ }
18
+
19
+ return withRedisLeaseLock(
20
+ {
21
+ redis: getRedisConnection(),
22
+ lockKey: `${ORG_MEMORY_LOCK_PREFIX}${normalizedOrgId}`,
23
+ lockTtlMs: ORG_MEMORY_LOCK_TTL_MS,
24
+ retryDelayMs: ORG_MEMORY_LOCK_RETRY_DELAY_MS,
25
+ refreshIntervalMs: ORG_MEMORY_LOCK_REFRESH_INTERVAL_MS,
26
+ waitLogIntervalMs: ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS,
27
+ maxWaitMs: ORG_MEMORY_LOCK_MAX_WAIT_MS,
28
+ label: 'org memory lock',
29
+ logger: {
30
+ debug: (message) => {
31
+ serverLogger.debug`${message}`
32
+ },
33
+ info: (message) => {
34
+ serverLogger.info`${message}`
35
+ },
36
+ warn: (message) => {
37
+ serverLogger.warn`${message}`
38
+ },
39
+ },
40
+ },
41
+ fn,
42
+ )
43
+ }
@@ -0,0 +1,158 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { setTimeout as delay } from 'node:timers/promises'
3
+
4
+ import type IORedis from 'ioredis'
5
+
6
+ import { getErrorMessage } from '../utils/error'
7
+
8
+ interface RedisLeaseLockLogger {
9
+ debug?: (message: string) => void
10
+ info?: (message: string) => void
11
+ warn?: (message: string) => void
12
+ }
13
+
14
+ export interface RedisLeaseLockOptions {
15
+ redis: IORedis
16
+ lockKey: string
17
+ lockTtlMs: number
18
+ retryDelayMs?: number
19
+ refreshIntervalMs?: number
20
+ waitLogIntervalMs?: number
21
+ maxWaitMs?: number
22
+ acquiredWaitInfoThresholdMs?: number
23
+ heldInfoThresholdMs?: number
24
+ logger?: RedisLeaseLockLogger
25
+ label?: string
26
+ }
27
+
28
+ const RELEASE_LOCK_SCRIPT = `
29
+ if redis.call("get", KEYS[1]) == ARGV[1] then
30
+ return redis.call("del", KEYS[1])
31
+ else
32
+ return 0
33
+ end
34
+ `
35
+
36
+ const REFRESH_LOCK_SCRIPT = `
37
+ if redis.call("get", KEYS[1]) == ARGV[1] then
38
+ return redis.call("pexpire", KEYS[1], ARGV[2])
39
+ else
40
+ return 0
41
+ end
42
+ `
43
+
44
+ function log(logger: RedisLeaseLockLogger | undefined, level: keyof RedisLeaseLockLogger, message: string): void {
45
+ const sink = logger?.[level]
46
+ if (typeof sink === 'function') {
47
+ sink(message)
48
+ }
49
+ }
50
+
51
+ async function acquireLeaseLock(
52
+ options: Required<Pick<RedisLeaseLockOptions, 'retryDelayMs' | 'waitLogIntervalMs' | 'maxWaitMs'>> &
53
+ RedisLeaseLockOptions & { lockValue: string; label: string },
54
+ ): Promise<void> {
55
+ const waitStart = Date.now()
56
+ let lastWaitLogAt = waitStart
57
+
58
+ for (;;) {
59
+ const result = await options.redis.set(options.lockKey, options.lockValue, 'PX', options.lockTtlMs, 'NX')
60
+
61
+ if (result === 'OK') {
62
+ return
63
+ }
64
+
65
+ const now = Date.now()
66
+ const waitedMs = now - waitStart
67
+ if (waitedMs >= options.maxWaitMs) {
68
+ throw new Error(`Timed out waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
69
+ }
70
+
71
+ if (now - lastWaitLogAt >= options.waitLogIntervalMs) {
72
+ log(options.logger, 'info', `Waiting for ${options.label} (${options.lockKey}) waitedMs=${waitedMs}`)
73
+ lastWaitLogAt = now
74
+ }
75
+
76
+ await delay(options.retryDelayMs)
77
+ }
78
+ }
79
+
80
+ async function refreshLeaseLock(options: RedisLeaseLockOptions & { lockValue: string; label: string }): Promise<void> {
81
+ const refreshed = await options.redis.eval(
82
+ REFRESH_LOCK_SCRIPT,
83
+ 1,
84
+ options.lockKey,
85
+ options.lockValue,
86
+ options.lockTtlMs.toString(),
87
+ )
88
+
89
+ const refreshedCount = typeof refreshed === 'number' ? refreshed : Number(refreshed)
90
+ if (refreshedCount === 1) return
91
+ throw new Error(`${options.label} refresh was rejected for key ${options.lockKey}`)
92
+ }
93
+
94
+ async function releaseLeaseLock(options: RedisLeaseLockOptions & { lockValue: string }): Promise<void> {
95
+ await options.redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, options.lockValue)
96
+ }
97
+
98
+ function startLeaseLockRefreshLoop(
99
+ options: Required<Pick<RedisLeaseLockOptions, 'refreshIntervalMs'>> &
100
+ RedisLeaseLockOptions & { lockValue: string; label: string },
101
+ ): () => void {
102
+ let stopped = false
103
+
104
+ const timer = setInterval(() => {
105
+ if (stopped) return
106
+ void refreshLeaseLock(options).catch((error: unknown) => {
107
+ log(options.logger, 'warn', `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`)
108
+ })
109
+ }, options.refreshIntervalMs)
110
+
111
+ timer.unref()
112
+
113
+ return () => {
114
+ stopped = true
115
+ clearInterval(timer)
116
+ }
117
+ }
118
+
119
+ export async function withRedisLeaseLock<T>(options: RedisLeaseLockOptions, fn: () => Promise<T>): Promise<T> {
120
+ const lockKey = options.lockKey.trim()
121
+ if (!lockKey) {
122
+ throw new Error('Redis lease lock requires a non-empty lock key')
123
+ }
124
+
125
+ const retryDelayMs = options.retryDelayMs ?? 500
126
+ const refreshIntervalMs = options.refreshIntervalMs ?? 30_000
127
+ const waitLogIntervalMs = options.waitLogIntervalMs ?? 30_000
128
+ const maxWaitMs = options.maxWaitMs ?? 45_000
129
+ const acquiredWaitInfoThresholdMs = options.acquiredWaitInfoThresholdMs ?? 1_000
130
+ const heldInfoThresholdMs = options.heldInfoThresholdMs ?? 5_000
131
+ const label = options.label ?? 'redis lease lock'
132
+
133
+ const lockValue = randomUUID()
134
+ const waitStart = Date.now()
135
+ await acquireLeaseLock({ ...options, lockKey, lockValue, label, retryDelayMs, waitLogIntervalMs, maxWaitMs })
136
+ const waitedMs = Date.now() - waitStart
137
+ if (waitedMs >= acquiredWaitInfoThresholdMs) {
138
+ log(options.logger, 'info', `Acquired ${label} (${lockKey}) after waiting waitedMs=${waitedMs}`)
139
+ }
140
+
141
+ const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs })
142
+ const holdStart = Date.now()
143
+
144
+ try {
145
+ return await fn()
146
+ } finally {
147
+ stopRefreshLoop()
148
+ const heldMs = Date.now() - holdStart
149
+ await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
150
+ log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
151
+ })
152
+ if (heldMs >= heldInfoThresholdMs) {
153
+ log(options.logger, 'info', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
154
+ } else {
155
+ log(options.logger, 'debug', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
156
+ }
157
+ }
158
+ }
@@ -0,0 +1 @@
1
+ export type AgentSkill = string