@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.
- package/infrastructure/schema/00_workstream.surql +55 -0
- package/infrastructure/schema/01_memory.surql +47 -0
- package/infrastructure/schema/02_execution_plan.surql +62 -0
- package/infrastructure/schema/03_learned_skill.surql +32 -0
- package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
- package/package.json +128 -0
- package/src/ai/definitions.ts +308 -0
- package/src/bifrost/bifrost.ts +256 -0
- package/src/config/agent-defaults.ts +99 -0
- package/src/config/constants.ts +33 -0
- package/src/config/env-shapes.ts +122 -0
- package/src/config/logger.ts +29 -0
- package/src/config/model-constants.ts +31 -0
- package/src/config/search.ts +17 -0
- package/src/config/workstream-defaults.ts +68 -0
- package/src/db/base.service.ts +55 -0
- package/src/db/cursor-pagination.ts +73 -0
- package/src/db/memory-query-builder.ts +207 -0
- package/src/db/memory-store.helpers.ts +118 -0
- package/src/db/memory-store.rows.ts +29 -0
- package/src/db/memory-store.ts +974 -0
- package/src/db/memory-types.ts +193 -0
- package/src/db/memory.ts +505 -0
- package/src/db/record-id.ts +78 -0
- package/src/db/service.ts +932 -0
- package/src/db/startup.ts +152 -0
- package/src/db/tables.ts +20 -0
- package/src/document/org-document-chunking.ts +224 -0
- package/src/document/parsing.ts +40 -0
- package/src/embeddings/provider.ts +76 -0
- package/src/index.ts +302 -0
- package/src/queues/context-compaction.queue.ts +82 -0
- package/src/queues/document-processor.queue.ts +118 -0
- package/src/queues/memory-consolidation.queue.ts +65 -0
- package/src/queues/post-chat-memory.queue.ts +128 -0
- package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
- package/src/queues/regular-chat-memory-digest.config.ts +12 -0
- package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
- package/src/queues/skill-extraction.config.ts +9 -0
- package/src/queues/skill-extraction.queue.ts +62 -0
- package/src/redis/connection.ts +176 -0
- package/src/redis/index.ts +30 -0
- package/src/redis/org-memory-lock.ts +43 -0
- package/src/redis/redis-lease-lock.ts +158 -0
- package/src/runtime/agent-contract.ts +1 -0
- package/src/runtime/agent-prompt-context.ts +119 -0
- package/src/runtime/agent-runtime-policy.ts +192 -0
- package/src/runtime/agent-stream-helpers.ts +117 -0
- package/src/runtime/agent-types.ts +22 -0
- package/src/runtime/approval-continuation.ts +16 -0
- package/src/runtime/chat-attachments.ts +46 -0
- package/src/runtime/chat-message.ts +10 -0
- package/src/runtime/chat-request-routing.ts +21 -0
- package/src/runtime/chat-run-orchestration.ts +25 -0
- package/src/runtime/chat-run-registry.ts +20 -0
- package/src/runtime/chat-types.ts +18 -0
- package/src/runtime/context-compaction-constants.ts +11 -0
- package/src/runtime/context-compaction-runtime.ts +86 -0
- package/src/runtime/context-compaction.ts +909 -0
- package/src/runtime/execution-plan.ts +59 -0
- package/src/runtime/helper-model.ts +405 -0
- package/src/runtime/indexed-repositories-policy.ts +28 -0
- package/src/runtime/instruction-sections.ts +8 -0
- package/src/runtime/llm-content.ts +71 -0
- package/src/runtime/memory-block.ts +264 -0
- package/src/runtime/memory-digest-policy.ts +14 -0
- package/src/runtime/memory-format.ts +8 -0
- package/src/runtime/memory-pipeline.ts +570 -0
- package/src/runtime/memory-prompts-fact.ts +47 -0
- package/src/runtime/memory-prompts-parse.ts +3 -0
- package/src/runtime/memory-prompts-update.ts +37 -0
- package/src/runtime/memory-scope.ts +43 -0
- package/src/runtime/plugin-types.ts +10 -0
- package/src/runtime/retrieval-adapters.ts +25 -0
- package/src/runtime/retrieval-pipeline.ts +3 -0
- package/src/runtime/runtime-extensions.ts +154 -0
- package/src/runtime/skill-extraction-policy.ts +3 -0
- package/src/runtime/team-consultation-orchestrator.ts +245 -0
- package/src/runtime/team-consultation-prompts.ts +32 -0
- package/src/runtime/title-helpers.ts +12 -0
- package/src/runtime/turn-lifecycle.ts +28 -0
- package/src/runtime/workstream-chat-helpers.ts +187 -0
- package/src/runtime/workstream-routing-policy.ts +301 -0
- package/src/runtime/workstream-state.ts +261 -0
- package/src/services/attachment.service.ts +159 -0
- package/src/services/chat-attachments.service.ts +17 -0
- package/src/services/chat-run-registry.service.ts +3 -0
- package/src/services/context-compaction-runtime.ts +13 -0
- package/src/services/context-compaction.service.ts +115 -0
- package/src/services/document-chunk.service.ts +141 -0
- package/src/services/execution-plan.service.ts +890 -0
- package/src/services/learned-skill.service.ts +328 -0
- package/src/services/memory-assessment.service.ts +43 -0
- package/src/services/memory.service.ts +807 -0
- package/src/services/memory.utils.ts +84 -0
- package/src/services/mutating-approval.service.ts +110 -0
- package/src/services/recent-activity-title.service.ts +74 -0
- package/src/services/recent-activity.service.ts +397 -0
- package/src/services/workstream-change-tracker.service.ts +313 -0
- package/src/services/workstream-message.service.ts +283 -0
- package/src/services/workstream-title.service.ts +58 -0
- package/src/services/workstream-turn-preparation.ts +1340 -0
- package/src/services/workstream-turn.ts +37 -0
- package/src/services/workstream.service.ts +854 -0
- package/src/services/workstream.types.ts +118 -0
- package/src/storage/attachment-parser.ts +101 -0
- package/src/storage/attachment-storage.service.ts +391 -0
- package/src/storage/attachments.types.ts +11 -0
- package/src/storage/attachments.utils.ts +58 -0
- package/src/storage/generated-document-storage.service.ts +55 -0
- package/src/system-agents/agent-result.ts +27 -0
- package/src/system-agents/context-compacter.agent.ts +46 -0
- package/src/system-agents/delegated-agent-factory.ts +177 -0
- package/src/system-agents/helper-agent-options.ts +20 -0
- package/src/system-agents/memory-reranker.agent.ts +38 -0
- package/src/system-agents/memory.agent.ts +58 -0
- package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
- package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
- package/src/system-agents/researcher.agent.ts +34 -0
- package/src/system-agents/skill-extractor.agent.ts +88 -0
- package/src/system-agents/skill-manager.agent.ts +80 -0
- package/src/system-agents/title-generator.agent.ts +42 -0
- package/src/system-agents/workstream-tracker.agent.ts +58 -0
- package/src/tools/execution-plan.tool.ts +163 -0
- package/src/tools/fetch-webpage.tool.ts +132 -0
- package/src/tools/firecrawl-client.ts +12 -0
- package/src/tools/memory-block.tool.ts +55 -0
- package/src/tools/read-file-parts.tool.ts +80 -0
- package/src/tools/remember-memory.tool.ts +85 -0
- package/src/tools/research-topic.tool.ts +15 -0
- package/src/tools/search-tools.ts +55 -0
- package/src/tools/search-web.tool.ts +175 -0
- package/src/tools/team-think.tool.ts +125 -0
- package/src/tools/tool-contract.ts +21 -0
- package/src/tools/user-questions.tool.ts +18 -0
- package/src/utils/async.ts +50 -0
- package/src/utils/date-time.ts +34 -0
- package/src/utils/error.ts +10 -0
- package/src/utils/errors.ts +28 -0
- package/src/utils/hono-error-handler.ts +71 -0
- package/src/utils/string.ts +51 -0
- package/src/workers/bootstrap.ts +44 -0
- package/src/workers/memory-consolidation.worker.ts +318 -0
- package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
- package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
- package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
- package/src/workers/skill-extraction.runner.ts +331 -0
- package/src/workers/skill-extraction.worker.ts +22 -0
- package/src/workers/utils/repo-indexer-chunker.ts +331 -0
- package/src/workers/utils/repo-structure-extractor.ts +645 -0
- package/src/workers/utils/repomix-process-concurrency.ts +65 -0
- package/src/workers/utils/sandbox-error.ts +5 -0
- 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
|