@lota-sdk/core 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ DEFINE TABLE IF NOT EXISTS queueJob SCHEMAFULL;
2
+ DEFINE FIELD IF NOT EXISTS queueName ON TABLE queueJob TYPE string;
3
+ DEFINE FIELD IF NOT EXISTS jobName ON TABLE queueJob TYPE string;
4
+ DEFINE FIELD IF NOT EXISTS bullmqJobId ON TABLE queueJob TYPE string;
5
+ DEFINE FIELD IF NOT EXISTS status ON TABLE queueJob TYPE string;
6
+ DEFINE FIELD IF NOT EXISTS data ON TABLE queueJob TYPE option<object> FLEXIBLE;
7
+ DEFINE FIELD IF NOT EXISTS options ON TABLE queueJob TYPE option<object> FLEXIBLE;
8
+ DEFINE FIELD IF NOT EXISTS context ON TABLE queueJob TYPE option<object> FLEXIBLE;
9
+ DEFINE FIELD IF NOT EXISTS deduplicationId ON TABLE queueJob TYPE option<string>;
10
+ DEFINE FIELD IF NOT EXISTS schedulerId ON TABLE queueJob TYPE option<string>;
11
+ DEFINE FIELD IF NOT EXISTS maxAttempts ON TABLE queueJob TYPE option<int>;
12
+ DEFINE FIELD IF NOT EXISTS attemptCount ON TABLE queueJob TYPE int DEFAULT 0;
13
+ DEFINE FIELD IF NOT EXISTS result ON TABLE queueJob TYPE option<object> FLEXIBLE;
14
+ DEFINE FIELD IF NOT EXISTS lastError ON TABLE queueJob TYPE option<object> FLEXIBLE;
15
+ DEFINE FIELD IF NOT EXISTS queuedAt ON TABLE queueJob TYPE datetime;
16
+ DEFINE FIELD IF NOT EXISTS startedAt ON TABLE queueJob TYPE option<datetime>;
17
+ DEFINE FIELD IF NOT EXISTS completedAt ON TABLE queueJob TYPE option<datetime>;
18
+ DEFINE FIELD IF NOT EXISTS failedAt ON TABLE queueJob TYPE option<datetime>;
19
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE queueJob TYPE datetime DEFAULT time::now() READONLY;
20
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE queueJob TYPE datetime VALUE time::now();
21
+
22
+ DEFINE INDEX IF NOT EXISTS queueJobQueueStatusIdx ON TABLE queueJob COLUMNS queueName, status;
23
+ DEFINE INDEX IF NOT EXISTS queueJobQueueBullmqIdx ON TABLE queueJob COLUMNS queueName, bullmqJobId UNIQUE;
24
+
25
+ DEFINE TABLE IF NOT EXISTS queueJobAttempt SCHEMAFULL;
26
+ DEFINE FIELD IF NOT EXISTS queueJobId ON TABLE queueJobAttempt TYPE record<queueJob> REFERENCE ON DELETE CASCADE;
27
+ DEFINE FIELD IF NOT EXISTS attemptNumber ON TABLE queueJobAttempt TYPE int;
28
+ DEFINE FIELD IF NOT EXISTS status ON TABLE queueJobAttempt TYPE string;
29
+ DEFINE FIELD IF NOT EXISTS result ON TABLE queueJobAttempt TYPE option<object> FLEXIBLE;
30
+ DEFINE FIELD IF NOT EXISTS error ON TABLE queueJobAttempt TYPE option<object> FLEXIBLE;
31
+ DEFINE FIELD IF NOT EXISTS startedAt ON TABLE queueJobAttempt TYPE datetime;
32
+ DEFINE FIELD IF NOT EXISTS completedAt ON TABLE queueJobAttempt TYPE option<datetime>;
33
+ DEFINE FIELD IF NOT EXISTS durationMs ON TABLE queueJobAttempt TYPE option<int>;
34
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE queueJobAttempt TYPE datetime DEFAULT time::now() READONLY;
35
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE queueJobAttempt TYPE datetime VALUE time::now();
36
+
37
+ DEFINE INDEX IF NOT EXISTS queueJobAttemptQueueIdx ON TABLE queueJobAttempt COLUMNS queueJobId;
38
+ DEFINE INDEX IF NOT EXISTS queueJobAttemptQueueNumberIdx ON TABLE queueJobAttempt COLUMNS queueJobId, attemptNumber UNIQUE;
@@ -0,0 +1,44 @@
1
+ DEFINE TABLE IF NOT EXISTS autonomousJob SCHEMAFULL;
2
+ DEFINE FIELD IF NOT EXISTS organizationId ON TABLE autonomousJob TYPE record<organization>;
3
+ DEFINE FIELD IF NOT EXISTS ownerUserId ON TABLE autonomousJob TYPE record<user>;
4
+ DEFINE FIELD IF NOT EXISTS ownerUserName ON TABLE autonomousJob TYPE option<string>;
5
+ DEFINE FIELD IF NOT EXISTS workstreamId ON TABLE autonomousJob TYPE record<workstream> REFERENCE ON DELETE CASCADE;
6
+ DEFINE FIELD IF NOT EXISTS agentId ON TABLE autonomousJob TYPE string;
7
+ DEFINE FIELD IF NOT EXISTS title ON TABLE autonomousJob TYPE string;
8
+ DEFINE FIELD IF NOT EXISTS prompt ON TABLE autonomousJob TYPE string;
9
+ DEFINE FIELD IF NOT EXISTS schedule ON TABLE autonomousJob TYPE object FLEXIBLE;
10
+ DEFINE FIELD IF NOT EXISTS status ON TABLE autonomousJob TYPE string;
11
+ DEFINE FIELD IF NOT EXISTS autoPauseThreshold ON TABLE autonomousJob TYPE int DEFAULT 3;
12
+ DEFINE FIELD IF NOT EXISTS consecutiveErrorCount ON TABLE autonomousJob TYPE int DEFAULT 0;
13
+ DEFINE FIELD IF NOT EXISTS lastRunStatus ON TABLE autonomousJob TYPE option<string>;
14
+ DEFINE FIELD IF NOT EXISTS lastRunAt ON TABLE autonomousJob TYPE option<datetime>;
15
+ DEFINE FIELD IF NOT EXISTS nextRunAt ON TABLE autonomousJob TYPE option<datetime>;
16
+ DEFINE FIELD IF NOT EXISTS linkedPlanSpecId ON TABLE autonomousJob TYPE option<record<planSpec>>;
17
+ DEFINE FIELD IF NOT EXISTS linkedPlanRunId ON TABLE autonomousJob TYPE option<record<planRun>>;
18
+ DEFINE FIELD IF NOT EXISTS lastError ON TABLE autonomousJob TYPE option<object> FLEXIBLE;
19
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE autonomousJob TYPE datetime DEFAULT time::now() READONLY;
20
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE autonomousJob TYPE datetime VALUE time::now();
21
+
22
+ DEFINE INDEX IF NOT EXISTS autonomousJobOrgStatusIdx ON TABLE autonomousJob COLUMNS organizationId, status;
23
+ DEFINE INDEX IF NOT EXISTS autonomousJobWorkstreamIdx ON TABLE autonomousJob COLUMNS workstreamId;
24
+ DEFINE INDEX IF NOT EXISTS autonomousJobOwnerIdx ON TABLE autonomousJob COLUMNS ownerUserId;
25
+
26
+ DEFINE TABLE IF NOT EXISTS autonomousJobRun SCHEMAFULL;
27
+ DEFINE FIELD IF NOT EXISTS autonomousJobId ON TABLE autonomousJobRun TYPE record<autonomousJob> REFERENCE ON DELETE CASCADE;
28
+ DEFINE FIELD IF NOT EXISTS workstreamId ON TABLE autonomousJobRun TYPE record<workstream> REFERENCE ON DELETE CASCADE;
29
+ DEFINE FIELD IF NOT EXISTS queueJobId ON TABLE autonomousJobRun TYPE option<record<queueJob>>;
30
+ DEFINE FIELD IF NOT EXISTS status ON TABLE autonomousJobRun TYPE string;
31
+ DEFINE FIELD IF NOT EXISTS inputMessageId ON TABLE autonomousJobRun TYPE option<string>;
32
+ DEFINE FIELD IF NOT EXISTS assistantMessageIds ON TABLE autonomousJobRun TYPE array<string> DEFAULT [];
33
+ DEFINE FIELD IF NOT EXISTS summary ON TABLE autonomousJobRun TYPE option<string>;
34
+ DEFINE FIELD IF NOT EXISTS error ON TABLE autonomousJobRun TYPE option<object> FLEXIBLE;
35
+ DEFINE FIELD IF NOT EXISTS linkedPlanSpecId ON TABLE autonomousJobRun TYPE option<record<planSpec>>;
36
+ DEFINE FIELD IF NOT EXISTS linkedPlanRunId ON TABLE autonomousJobRun TYPE option<record<planRun>>;
37
+ DEFINE FIELD IF NOT EXISTS startedAt ON TABLE autonomousJobRun TYPE option<datetime>;
38
+ DEFINE FIELD IF NOT EXISTS completedAt ON TABLE autonomousJobRun TYPE option<datetime>;
39
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE autonomousJobRun TYPE datetime DEFAULT time::now() READONLY;
40
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE autonomousJobRun TYPE datetime VALUE time::now();
41
+
42
+ DEFINE INDEX IF NOT EXISTS autonomousJobRunJobIdx ON TABLE autonomousJobRun COLUMNS autonomousJobId;
43
+ DEFINE INDEX IF NOT EXISTS autonomousJobRunQueueJobIdx ON TABLE autonomousJobRun COLUMNS queueJobId;
44
+ DEFINE INDEX IF NOT EXISTS autonomousJobRunStatusIdx ON TABLE autonomousJobRun COLUMNS status;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,11 +32,11 @@
32
32
  "@chat-adapter/slack": "^4.23.0",
33
33
  "@chat-adapter/state-ioredis": "^4.23.0",
34
34
  "@logtape/logtape": "^2.0.5",
35
- "@lota-sdk/shared": "0.1.19",
36
- "@mendable/firecrawl-js": "^4.17.0",
35
+ "@lota-sdk/shared": "0.1.21",
36
+ "@mendable/firecrawl-js": "^4.18.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
- "ai": "^6.0.137",
39
- "bullmq": "^5.71.0",
38
+ "ai": "^6.0.141",
39
+ "bullmq": "^5.71.1",
40
40
  "chat": "^4.23.0",
41
41
  "cron-parser": "^5.5.0",
42
42
  "hono": "^4.12.9",
@@ -24,6 +24,7 @@ const EXPECTED_GATEWAY_KEY_PREFIX = 'sk-bf-'
24
24
  const AI_GATEWAY_VIRTUAL_KEY_HEADER = 'x-bf-vk'
25
25
  const AI_GATEWAY_EXTRA_PARAMS_HEADER = 'x-bf-passthrough-extra-params'
26
26
  const DEFAULT_AI_GATEWAY_URL = 'https://ai-gateway.gobrainy.ai' as const
27
+ const OPENAI_PROMPT_CACHE_RETENTION = '24h' as const
27
28
  const OPENROUTER_RESPONSE_HEALING_EXTRA_PARAMS = {
28
29
  plugins: [{ id: 'response-healing' }],
29
30
  } as const satisfies AiGatewayExtraParams
@@ -50,6 +51,29 @@ function mergeAiGatewayHeaders(
50
51
  return Object.fromEntries(merged.entries())
51
52
  }
52
53
 
54
+ function parseAiGatewayJsonRequestBody(body: BodyInit | null | undefined): Record<string, unknown> | null {
55
+ if (typeof body !== 'string') return null
56
+
57
+ let parsed: unknown
58
+ try {
59
+ parsed = JSON.parse(body)
60
+ } catch {
61
+ return null
62
+ }
63
+
64
+ return isRecord(parsed) ? parsed : null
65
+ }
66
+
67
+ function isAiGatewayOpenAIModelRequest(body: BodyInit | null | undefined): boolean {
68
+ const parsed = parseAiGatewayJsonRequestBody(body)
69
+ return readString(parsed?.model)?.startsWith('openai/') ?? false
70
+ }
71
+
72
+ function hasAiGatewayPromptCacheRetention(body: BodyInit | null | undefined): boolean {
73
+ const parsed = parseAiGatewayJsonRequestBody(body)
74
+ return readString(parsed?.prompt_cache_retention) !== null
75
+ }
76
+
53
77
  function withDefaultAiGatewayCacheHeaders(params: AiGatewayCallOptions, modelId: string): AiGatewayCallOptions {
54
78
  return {
55
79
  ...params,
@@ -296,16 +320,8 @@ export function injectAiGatewayExtraParamsRequestBody(
296
320
  body: BodyInit | null | undefined,
297
321
  extraParams: AiGatewayExtraParams,
298
322
  ): BodyInit | null | undefined {
299
- if (typeof body !== 'string') return body
300
-
301
- let parsed: unknown
302
- try {
303
- parsed = JSON.parse(body)
304
- } catch {
305
- return body
306
- }
307
-
308
- if (!isRecord(parsed)) return body
323
+ const parsed = parseAiGatewayJsonRequestBody(body)
324
+ if (!parsed) return body
309
325
 
310
326
  const mergedExtraParams = isRecord(parsed.extra_params)
311
327
  ? { ...parsed.extra_params, ...extraParams }
@@ -314,11 +330,35 @@ export function injectAiGatewayExtraParamsRequestBody(
314
330
  return JSON.stringify({ ...parsed, extra_params: mergedExtraParams })
315
331
  }
316
332
 
317
- function createAiGatewayFetchWithExtraParams(extraParams: AiGatewayExtraParams): typeof fetch {
318
- const fetchWithExtraParams = (input: RequestInfo | URL, init?: RequestInit | BunFetchRequestInit) =>
319
- globalThis.fetch(input, { ...init, body: injectAiGatewayExtraParamsRequestBody(init?.body, extraParams) })
333
+ export function injectAiGatewayOpenAIPromptCacheRetentionRequestBody(
334
+ body: BodyInit | null | undefined,
335
+ ): BodyInit | null | undefined {
336
+ const parsed = parseAiGatewayJsonRequestBody(body)
337
+ if (!parsed) return body
338
+ if (!readString(parsed.model)?.startsWith('openai/')) return body
339
+ if (readString(parsed.prompt_cache_retention) !== null) return body
340
+
341
+ return JSON.stringify({ ...parsed, prompt_cache_retention: OPENAI_PROMPT_CACHE_RETENTION })
342
+ }
343
+
344
+ function createAiGatewayFetch(extraParams?: AiGatewayExtraParams): typeof fetch {
345
+ const fetchWithMutations = (input: RequestInfo | URL, init?: RequestInit | BunFetchRequestInit) => {
346
+ const bodyWithPromptCacheRetention = injectAiGatewayOpenAIPromptCacheRetentionRequestBody(init?.body)
347
+ const body =
348
+ extraParams !== undefined
349
+ ? injectAiGatewayExtraParamsRequestBody(bodyWithPromptCacheRetention, extraParams)
350
+ : bodyWithPromptCacheRetention
351
+
352
+ const headers = new Headers(init?.headers)
353
+ if (extraParams !== undefined || (isAiGatewayOpenAIModelRequest(body) && hasAiGatewayPromptCacheRetention(body))) {
354
+ // Bifrost only forwards provider-specific extra params when passthrough is enabled.
355
+ headers.set(AI_GATEWAY_EXTRA_PARAMS_HEADER, 'true')
356
+ }
320
357
 
321
- return Object.assign(fetchWithExtraParams, { preconnect: globalThis.fetch.preconnect.bind(globalThis.fetch) })
358
+ return globalThis.fetch(input, { ...init, headers, body })
359
+ }
360
+
361
+ return Object.assign(fetchWithMutations, { preconnect: globalThis.fetch.preconnect.bind(globalThis.fetch) })
322
362
  }
323
363
 
324
364
  function createAiGatewayProvider(extraParams?: AiGatewayExtraParams) {
@@ -330,11 +370,8 @@ function createAiGatewayProvider(extraParams?: AiGatewayExtraParams) {
330
370
  return createOpenAI({
331
371
  baseURL,
332
372
  apiKey,
333
- headers: {
334
- [AI_GATEWAY_VIRTUAL_KEY_HEADER]: apiKey,
335
- ...(extraParams ? { [AI_GATEWAY_EXTRA_PARAMS_HEADER]: 'true' } : {}),
336
- },
337
- ...(extraParams ? { fetch: createAiGatewayFetchWithExtraParams(extraParams) } : {}),
373
+ headers: { [AI_GATEWAY_VIRTUAL_KEY_HEADER]: apiKey },
374
+ fetch: createAiGatewayFetch(extraParams),
338
375
  })
339
376
  }
340
377
 
@@ -29,6 +29,8 @@ import type { LotaRuntimeSocialChat } from './runtime/social-chat'
29
29
  import { createSocialChatRuntime } from './runtime/social-chat'
30
30
  import type { attachmentService } from './services/attachment.service'
31
31
  import { attachmentService as attachmentServiceSingleton } from './services/attachment.service'
32
+ import type { autonomousJobService } from './services/autonomous-job.service'
33
+ import { autonomousJobService as autonomousJobServiceSingleton } from './services/autonomous-job.service'
32
34
  import { coordinationRegistryService as coordinationRegistryServiceSingleton } from './services/coordination-registry.service'
33
35
  import type { documentChunkService } from './services/document-chunk.service'
34
36
  import { documentChunkService as documentChunkServiceSingleton } from './services/document-chunk.service'
@@ -112,6 +114,7 @@ export interface LotaRuntime {
112
114
  redis: RedisConnectionManager
113
115
  closeRedisConnection: () => Promise<void>
114
116
  attachmentService: typeof attachmentService
117
+ autonomousJobService: typeof autonomousJobService
115
118
  documentChunkService: typeof documentChunkService
116
119
  generatedDocumentStorageService: typeof generatedDocumentStorageService
117
120
  memoryService: typeof memoryService
@@ -382,6 +385,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
382
385
  redis: redisManager,
383
386
  closeRedisConnection: async () => await redisManager.closeConnection(),
384
387
  attachmentService: attachmentServiceSingleton,
388
+ autonomousJobService: autonomousJobServiceSingleton,
385
389
  documentChunkService: documentChunkServiceSingleton,
386
390
  generatedDocumentStorageService: generatedDocumentStorageServiceSingleton,
387
391
  memoryService: memoryServiceSingleton,
@@ -458,8 +462,13 @@ function getBuiltInSchemaFiles(): URL[] {
458
462
  new URL('../infrastructure/schema/01_memory.surql', import.meta.url),
459
463
  new URL('../infrastructure/schema/02_execution_plan.surql', import.meta.url),
460
464
  new URL('../infrastructure/schema/03_learned_skill.surql', import.meta.url),
461
- new URL('../infrastructure/schema/05_recent_activity.surql', import.meta.url),
462
465
  new URL('../infrastructure/schema/04_runtime_bootstrap.surql', import.meta.url),
466
+ new URL('../infrastructure/schema/05_recent_activity.surql', import.meta.url),
467
+ new URL('../infrastructure/schema/06_playbook.surql', import.meta.url),
468
+ new URL('../infrastructure/schema/07_institutional_memory.surql', import.meta.url),
469
+ new URL('../infrastructure/schema/08_quality_metrics.surql', import.meta.url),
470
+ new URL('../infrastructure/schema/09_queue_job.surql', import.meta.url),
471
+ new URL('../infrastructure/schema/10_autonomous_job.surql', import.meta.url),
463
472
  ]
464
473
  }
465
474
 
@@ -1,6 +1,7 @@
1
1
  import type { z } from 'zod'
2
2
 
3
3
  import { NotFoundError } from '../utils/errors'
4
+ import { ensureRecordId } from './record-id'
4
5
  import { databaseService as defaultDatabaseService } from './service'
5
6
  import type { SurrealDBService } from './service'
6
7
  import type { DatabaseTable } from './tables'
@@ -16,7 +17,11 @@ export abstract class BaseService<T extends z.ZodType> {
16
17
  }
17
18
 
18
19
  async findById(id: unknown): Promise<z.infer<T> | null> {
19
- return this.databaseService.findOne(this.table, { id }, this.schema)
20
+ return this.databaseService.findOne(
21
+ this.table,
22
+ { id: ensureRecordId(id as Parameters<typeof ensureRecordId>[0], this.table) },
23
+ this.schema,
24
+ )
20
25
  }
21
26
 
22
27
  async getById(id: unknown): Promise<z.infer<T>> {
package/src/db/tables.ts CHANGED
@@ -29,6 +29,10 @@ export const TABLES = {
29
29
  PLAYBOOK_VERSION: 'playbookVersion',
30
30
  INSTITUTIONAL_MEMORY: 'institutionalMemory',
31
31
  QUALITY_METRIC: 'qualityMetric',
32
+ QUEUE_JOB: 'queueJob',
33
+ QUEUE_JOB_ATTEMPT: 'queueJobAttempt',
34
+ AUTONOMOUS_JOB: 'autonomousJob',
35
+ AUTONOMOUS_JOB_RUN: 'autonomousJobRun',
32
36
  } as const
33
37
 
34
38
  export type DatabaseTable = (typeof TABLES)[keyof typeof TABLES] | (string & {})
@@ -0,0 +1,134 @@
1
+ import type { AutonomousJobSchedule } from '@lota-sdk/shared'
2
+ import type { Job } from 'bullmq'
3
+
4
+ import { serverLogger } from '../config/logger'
5
+ import { databaseService } from '../db/service'
6
+ import { autonomousJobService } from '../services/autonomous-job.service'
7
+ import { queueJobService } from '../services/queue-job.service'
8
+ import type { WorkerHandle } from '../workers/worker-utils'
9
+ import { DEFAULT_JOB_RETENTION } from '../workers/worker-utils'
10
+ import { createQueueFactory } from './queue-factory'
11
+
12
+ export interface AutonomousJobQueuePayload {
13
+ autonomousJobId: string
14
+ autonomousJobRunId?: string
15
+ trigger: 'scheduled' | 'manual'
16
+ }
17
+
18
+ export const AUTONOMOUS_JOB_QUEUE = 'autonomous-job'
19
+
20
+ const DEFAULT_AUTONOMOUS_JOB_OPTIONS = {
21
+ ...DEFAULT_JOB_RETENTION,
22
+ attempts: 3,
23
+ backoff: { type: 'exponential', delay: 5_000 },
24
+ } as const
25
+
26
+ async function processAutonomousJob(
27
+ job: Job<AutonomousJobQueuePayload>,
28
+ ): Promise<{ status: string; summary?: string }> {
29
+ await databaseService.connect()
30
+ return autonomousJobService.executeQueuedRun(job)
31
+ }
32
+
33
+ const autonomousJobQueue = createQueueFactory<AutonomousJobQueuePayload>({
34
+ name: AUTONOMOUS_JOB_QUEUE,
35
+ displayName: 'Autonomous job',
36
+ jobName: 'run-autonomous-job',
37
+ concurrency: 2,
38
+ defaultJobOptions: DEFAULT_AUTONOMOUS_JOB_OPTIONS,
39
+ processor: processAutonomousJob,
40
+ })
41
+
42
+ function buildAutonomousSchedulerId(autonomousJobId: string): string {
43
+ return `autonomous:${autonomousJobId}`
44
+ }
45
+
46
+ function encodeBullmqId(raw: string): string {
47
+ return Buffer.from(raw).toString('base64url')
48
+ }
49
+
50
+ export function buildAutonomousAtJobId(autonomousJobId: string): string {
51
+ return `autonomous-at-${encodeBullmqId(autonomousJobId)}`
52
+ }
53
+
54
+ export async function enqueueAutonomousJobRun(params: {
55
+ payload: AutonomousJobQueuePayload
56
+ delayMs?: number
57
+ jobId?: string
58
+ }): Promise<{ bullmqJobId: string; queueJobId: string }> {
59
+ const queuedJob = await autonomousJobQueue
60
+ .getQueue()
61
+ .add('run-autonomous-job', params.payload, {
62
+ ...(typeof params.delayMs === 'number' ? { delay: Math.max(0, params.delayMs) } : {}),
63
+ ...(params.jobId ? { jobId: params.jobId } : {}),
64
+ })
65
+
66
+ const queueJobId = await queueJobService.recordEnqueued({
67
+ queueName: AUTONOMOUS_JOB_QUEUE,
68
+ id: queuedJob.id,
69
+ name: queuedJob.name,
70
+ data: queuedJob.data,
71
+ opts: queuedJob.opts,
72
+ attemptsMade: queuedJob.attemptsMade,
73
+ timestamp: queuedJob.timestamp,
74
+ })
75
+
76
+ return { bullmqJobId: String(queuedJob.id), queueJobId }
77
+ }
78
+
79
+ export async function upsertAutonomousJobScheduler(params: {
80
+ autonomousJobId: string
81
+ schedule: Extract<AutonomousJobSchedule, { kind: 'cron' | 'every' }>
82
+ }): Promise<void> {
83
+ const repeatOpts =
84
+ params.schedule.kind === 'cron' ? { pattern: params.schedule.cron } : { every: params.schedule.intervalMs }
85
+ const queuedJob = await autonomousJobQueue
86
+ .getQueue()
87
+ .upsertJobScheduler(buildAutonomousSchedulerId(params.autonomousJobId), repeatOpts, {
88
+ name: 'run-autonomous-job',
89
+ data: { autonomousJobId: params.autonomousJobId, trigger: 'scheduled' },
90
+ opts: DEFAULT_AUTONOMOUS_JOB_OPTIONS,
91
+ })
92
+
93
+ await queueJobService.recordEnqueued({
94
+ queueName: AUTONOMOUS_JOB_QUEUE,
95
+ id: queuedJob.id,
96
+ name: queuedJob.name,
97
+ data: queuedJob.data,
98
+ opts: queuedJob.opts,
99
+ attemptsMade: queuedJob.attemptsMade,
100
+ timestamp: queuedJob.timestamp,
101
+ })
102
+ }
103
+
104
+ export async function removeAutonomousJobScheduler(autonomousJobId: string): Promise<void> {
105
+ await autonomousJobQueue.getQueue().removeJobScheduler(buildAutonomousSchedulerId(autonomousJobId))
106
+ }
107
+
108
+ export async function removeAutonomousAtJob(autonomousJobId: string): Promise<void> {
109
+ try {
110
+ await autonomousJobQueue.getQueue().remove(buildAutonomousAtJobId(autonomousJobId))
111
+ } catch {
112
+ // The delayed job may have already fired or never existed.
113
+ }
114
+ }
115
+
116
+ type AutonomousJobWorkerOptions = Parameters<typeof autonomousJobQueue.startWorker>[0]
117
+
118
+ export function startAutonomousJobWorker(options: AutonomousJobWorkerOptions = {}): WorkerHandle {
119
+ const handle = autonomousJobQueue.startWorker(options)
120
+
121
+ autonomousJobService.recoverActiveJobs().catch((error: unknown) => {
122
+ serverLogger.error`Autonomous job startup recovery failed: ${error}`
123
+ })
124
+
125
+ return handle
126
+ }
127
+
128
+ export function getAutonomousJobQueueHandle(): WorkerHandle {
129
+ return startAutonomousJobWorker()
130
+ }
131
+
132
+ if (import.meta.main) {
133
+ startAutonomousJobWorker()
134
+ }
@@ -1,7 +1,9 @@
1
1
  import { Queue, Worker } from 'bullmq'
2
+ import type { QueueOptions } from 'bullmq'
2
3
  import type IORedis from 'ioredis'
3
4
 
4
5
  import type { chatLogger } from '../config/logger'
6
+ import { queueJobService } from '../services/queue-job.service'
5
7
  import {
6
8
  attachWorkerEvents,
7
9
  createWorkerShutdown,
@@ -69,22 +71,24 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
69
71
  enqueue: (job: TJob) => Promise<unknown>
70
72
  startWorker: (options?: { registerSignals?: boolean }) => WorkerHandle
71
73
  } {
74
+ type QueueShape = Queue<TJob, unknown, string, TJob, unknown, string>
75
+
72
76
  const queueName = params.queueName ?? DEFAULT_DOCUMENT_PROCESSOR_QUEUE
73
77
  const workerName = params.workerName ?? DEFAULT_WORKER_NAME
74
78
  const concurrency = params.concurrency ?? 10
75
79
  const lockDuration = params.lockDuration ?? 300_000
76
- const jobName = 'process-document' as Parameters<Queue<TJob, unknown, string>['add']>[0]
77
- const toQueueData = (job: TJob): Parameters<Queue<TJob, unknown, string>['add']>[1] =>
78
- job as Parameters<Queue<TJob, unknown, string>['add']>[1]
79
- let queue: Queue<TJob, unknown, string> | null = null
80
+ const jobName = 'process-document' as Parameters<QueueShape['add']>[0]
81
+ const toQueueData = (job: TJob): Parameters<QueueShape['add']>[1] => job
82
+ let queue: QueueShape | null = null
83
+ const getConnection = (): IORedis => params.getConnectionForBullMQ()
80
84
 
81
- const getQueue = (): Queue<TJob, unknown, string> => {
85
+ const getQueue = (): QueueShape => {
82
86
  if (queue) {
83
87
  return queue
84
88
  }
85
89
 
86
- queue = new Queue<TJob, unknown, string>(queueName, {
87
- connection: params.getConnectionForBullMQ(),
90
+ queue = new Queue<TJob, unknown, string, TJob, unknown, string>(queueName, {
91
+ connection: getConnection() as QueueOptions['connection'],
88
92
  defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
89
93
  })
90
94
 
@@ -92,12 +96,22 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
92
96
  }
93
97
 
94
98
  return {
95
- enqueue: async (job) =>
96
- await getQueue().add(jobName, toQueueData(job), { jobId: buildDocumentProcessorJobId(job) }),
99
+ enqueue: async (job) => {
100
+ const queuedJob = await getQueue().add(jobName, toQueueData(job), { jobId: buildDocumentProcessorJobId(job) })
101
+ await queueJobService.recordEnqueued({
102
+ queueName,
103
+ id: queuedJob.id,
104
+ name: queuedJob.name,
105
+ data: queuedJob.data,
106
+ opts: queuedJob.opts,
107
+ attemptsMade: queuedJob.attemptsMade,
108
+ timestamp: queuedJob.timestamp,
109
+ })
110
+ },
97
111
  startWorker: (options = {}) => {
98
112
  const { registerSignals = import.meta.main } = options
99
113
  const worker = new Worker(queueName, params.getWorkerPath(), {
100
- connection: params.getConnectionForBullMQ(),
114
+ connection: getConnection() as QueueOptions['connection'],
101
115
  concurrency,
102
116
  lockDuration,
103
117
  })
@@ -1,4 +1,5 @@
1
1
  export * from './queue-factory'
2
+ export * from './autonomous-job.queue'
2
3
  export * from './context-compaction.queue'
3
4
  export * from './delayed-node-promotion.queue'
4
5
  export * from './document-processor.queue'
@@ -1,3 +1,4 @@
1
+ import { queueJobService } from '../services/queue-job.service'
1
2
  import { getWorkerPath, LONG_JOB_LOCK_DURATION_MS, LOW_JOB_RETENTION } from '../workers/worker-utils'
2
3
  import { createQueueFactory } from './queue-factory'
3
4
 
@@ -6,7 +7,7 @@ export interface MemoryConsolidationJob {
6
7
  }
7
8
 
8
9
  const MEMORY_CONSOLIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000
9
- const MEMORY_CONSOLIDATION_JOB_ID = 'memory-consolidation-recurring'
10
+ const MEMORY_CONSOLIDATION_SCHEDULER_ID = 'memory-consolidation-recurring'
10
11
 
11
12
  const memoryConsolidation = createQueueFactory<MemoryConsolidationJob>({
12
13
  name: 'memory-consolidation',
@@ -23,9 +24,27 @@ export async function enqueueMemoryConsolidation(job: MemoryConsolidationJob = {
23
24
  }
24
25
 
25
26
  export async function scheduleRecurringConsolidation() {
26
- await memoryConsolidation
27
+ const queuedJob = await memoryConsolidation
27
28
  .getQueue()
28
- .add('consolidate', {}, { repeat: { every: MEMORY_CONSOLIDATION_INTERVAL_MS }, jobId: MEMORY_CONSOLIDATION_JOB_ID })
29
+ .upsertJobScheduler(
30
+ MEMORY_CONSOLIDATION_SCHEDULER_ID,
31
+ { every: MEMORY_CONSOLIDATION_INTERVAL_MS },
32
+ {
33
+ name: 'consolidate',
34
+ data: {},
35
+ opts: { ...LOW_JOB_RETENTION, attempts: 2, backoff: { type: 'exponential', delay: 5000 } },
36
+ },
37
+ )
38
+
39
+ await queueJobService.recordEnqueued({
40
+ queueName: 'memory-consolidation',
41
+ id: queuedJob.id,
42
+ name: queuedJob.name,
43
+ data: queuedJob.data,
44
+ opts: queuedJob.opts,
45
+ attemptsMade: queuedJob.attemptsMade,
46
+ timestamp: queuedJob.timestamp,
47
+ })
29
48
  }
30
49
 
31
50
  export const startMemoryConsolidationWorker = memoryConsolidation.startWorker
@@ -1,9 +1,10 @@
1
1
  import { Queue, Worker } from 'bullmq'
2
- import type { Job, JobsOptions, WorkerOptions } from 'bullmq'
2
+ import type { Job, JobsOptions, QueueOptions, WorkerOptions } from 'bullmq'
3
3
  import type IORedis from 'ioredis'
4
4
 
5
5
  import { serverLogger } from '../config/logger'
6
6
  import { getRedisConnectionForBullMQ } from '../redis'
7
+ import { queueJobService } from '../services/queue-job.service'
7
8
  import {
8
9
  attachWorkerEvents,
9
10
  createTracedWorkerProcessor,
@@ -26,7 +27,7 @@ interface QueueFactoryConfigBase {
26
27
  }
27
28
 
28
29
  interface QueueFactoryConfigInline<TJob> extends QueueFactoryConfigBase {
29
- processor: (job: Job<TJob>) => Promise<void>
30
+ processor: (job: Job<TJob>) => Promise<unknown>
30
31
  processorPath?: never
31
32
  }
32
33
 
@@ -38,39 +39,68 @@ interface QueueFactoryConfigFile extends QueueFactoryConfigBase {
38
39
  export type QueueFactoryConfig<TJob> = QueueFactoryConfigInline<TJob> | QueueFactoryConfigFile
39
40
 
40
41
  export interface QueueFactory<TJob> {
41
- getQueue: () => Queue<TJob, unknown, string>
42
+ getQueue: () => Queue<TJob, unknown, string, TJob, unknown, string>
42
43
  enqueue: (job: TJob, options?: JobsOptions) => Promise<void>
43
44
  startWorker: (options?: { registerSignals?: boolean }) => WorkerHandle
44
45
  }
45
46
 
46
47
  export function createQueueFactory<TJob>(config: QueueFactoryConfig<TJob>): QueueFactory<TJob> {
47
- let _queue: Queue<TJob, unknown, string> | null = null
48
-
49
- const getConnection = () => config.connectionProvider?.() ?? getRedisConnectionForBullMQ()
50
-
51
- const getQueue = (): Queue<TJob, unknown, string> => {
52
- if (!_queue) {
53
- _queue = new Queue<TJob, unknown, string>(config.name, {
54
- connection: getConnection(),
48
+ type QueueShape = Queue<TJob, unknown, string, TJob, unknown, string>
49
+
50
+ let _queue: QueueShape | null = null
51
+ let _queueConnection: IORedis | null = null
52
+
53
+ const getConnection = (): IORedis => config.connectionProvider?.() ?? getRedisConnectionForBullMQ()
54
+
55
+ const getQueue = (): QueueShape => {
56
+ const connection = getConnection()
57
+ const shouldRecreateQueue =
58
+ _queue === null ||
59
+ _queueConnection === null ||
60
+ _queueConnection !== connection ||
61
+ _queueConnection.status === 'close' ||
62
+ _queueConnection.status === 'end'
63
+
64
+ if (shouldRecreateQueue) {
65
+ if (_queue) {
66
+ void _queue.close().catch((error: unknown) => {
67
+ serverLogger.warn`Failed to close stale ${config.displayName} queue: ${error}`
68
+ })
69
+ }
70
+
71
+ _queue = new Queue<TJob, unknown, string, TJob, unknown, string>(config.name, {
72
+ connection: connection as QueueOptions['connection'],
55
73
  defaultJobOptions: { ...DEFAULT_JOB_RETENTION, ...config.defaultJobOptions },
56
74
  })
75
+ _queueConnection = connection
76
+ }
77
+ if (_queue === null) {
78
+ throw new Error(`Failed to initialize queue: ${config.name}`)
57
79
  }
58
80
  return _queue
59
81
  }
60
82
 
61
- type QueueAdd = Queue<TJob, unknown, string>['add']
62
- const jobName = config.jobName as Parameters<QueueAdd>[0]
63
- const toData = (job: TJob) => job as Parameters<QueueAdd>[1]
83
+ const jobName = config.jobName
84
+ const toData = (job: TJob) => job
64
85
 
65
86
  const enqueue = async (job: TJob, options?: JobsOptions): Promise<void> => {
66
- await getQueue().add(jobName, toData(job), options)
87
+ const queuedJob = await getQueue().add(jobName, toData(job), options)
88
+ await queueJobService.recordEnqueued({
89
+ queueName: config.name,
90
+ id: queuedJob.id,
91
+ name: queuedJob.name,
92
+ data: queuedJob.data,
93
+ opts: queuedJob.opts,
94
+ attemptsMade: queuedJob.attemptsMade,
95
+ timestamp: queuedJob.timestamp,
96
+ })
67
97
  }
68
98
 
69
99
  const startWorker = (options: { registerSignals?: boolean } = {}): WorkerHandle => {
70
100
  const { registerSignals = import.meta.main } = options
71
101
 
72
102
  const workerOptions: WorkerOptions = {
73
- connection: getConnection(),
103
+ connection: getConnection() as QueueOptions['connection'],
74
104
  concurrency: config.concurrency,
75
105
  ...(config.lockDuration !== undefined ? { lockDuration: config.lockDuration } : {}),
76
106
  ...(config.stalledInterval !== undefined ? { stalledInterval: config.stalledInterval } : {}),