@lota-sdk/core 0.4.8 → 0.4.10

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 (272) hide show
  1. package/package.json +11 -12
  2. package/src/ai/embedding-cache.ts +96 -22
  3. package/src/ai-gateway/ai-gateway.ts +766 -223
  4. package/src/config/agent-defaults.ts +189 -75
  5. package/src/config/agent-types.ts +54 -4
  6. package/src/config/background-processing.ts +1 -1
  7. package/src/config/constants.ts +8 -2
  8. package/src/config/index.ts +0 -1
  9. package/src/config/logger.ts +299 -19
  10. package/src/config/thread-defaults.ts +40 -20
  11. package/src/create-runtime.ts +200 -449
  12. package/src/db/base.service.ts +52 -28
  13. package/src/db/cursor-pagination.ts +71 -30
  14. package/src/db/memory-query-builder.ts +2 -1
  15. package/src/db/memory-store.helpers.ts +4 -7
  16. package/src/db/memory-store.ts +868 -601
  17. package/src/db/memory.ts +396 -280
  18. package/src/db/record-id.ts +32 -10
  19. package/src/db/schema-fingerprint.ts +30 -12
  20. package/src/db/service-normalization.ts +288 -0
  21. package/src/db/service.ts +912 -779
  22. package/src/db/startup.ts +153 -68
  23. package/src/db/transaction-conflict.ts +15 -0
  24. package/src/effect/awaitable-effect.ts +96 -0
  25. package/src/effect/errors.ts +121 -0
  26. package/src/effect/helpers.ts +123 -0
  27. package/src/effect/index.ts +24 -0
  28. package/src/effect/layers.ts +238 -0
  29. package/src/effect/runtime-ref.ts +25 -0
  30. package/src/effect/runtime.ts +46 -0
  31. package/src/effect/services.ts +61 -0
  32. package/src/effect/zod.ts +43 -0
  33. package/src/embeddings/provider.ts +128 -83
  34. package/src/index.ts +48 -1
  35. package/src/openrouter/direct-provider.ts +11 -35
  36. package/src/queues/autonomous-job.queue.ts +117 -73
  37. package/src/queues/context-compaction.queue.ts +50 -17
  38. package/src/queues/delayed-node-promotion.queue.ts +46 -17
  39. package/src/queues/document-processor.queue.ts +52 -77
  40. package/src/queues/memory-consolidation.queue.ts +47 -32
  41. package/src/queues/organization-learning.queue.ts +26 -4
  42. package/src/queues/plan-agent-heartbeat.queue.ts +71 -24
  43. package/src/queues/plan-scheduler.queue.ts +97 -33
  44. package/src/queues/post-chat-memory.queue.ts +56 -26
  45. package/src/queues/queue-factory.ts +227 -59
  46. package/src/queues/standalone-worker.ts +39 -0
  47. package/src/queues/title-generation.queue.ts +45 -11
  48. package/src/redis/connection.ts +182 -113
  49. package/src/redis/index.ts +6 -8
  50. package/src/redis/org-memory-lock.ts +60 -27
  51. package/src/redis/redis-lease-lock.ts +200 -121
  52. package/src/redis/runtime-connection.ts +20 -0
  53. package/src/redis/stream-context.ts +92 -46
  54. package/src/runtime/agent-identity-overrides.ts +2 -2
  55. package/src/runtime/agent-runtime-policy.ts +5 -2
  56. package/src/runtime/agent-stream-helpers.ts +24 -9
  57. package/src/runtime/chat-run-orchestration.ts +102 -19
  58. package/src/runtime/chat-run-registry.ts +36 -2
  59. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  60. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +161 -94
  61. package/src/runtime/domain-layer.ts +192 -0
  62. package/src/runtime/execution-plan-visibility.ts +2 -2
  63. package/src/runtime/execution-plan.ts +42 -15
  64. package/src/runtime/graph-designer.ts +16 -4
  65. package/src/runtime/helper-model.ts +139 -48
  66. package/src/runtime/index.ts +7 -8
  67. package/src/runtime/indexed-repositories-policy.ts +3 -3
  68. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +50 -36
  69. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  70. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +54 -67
  71. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  72. package/src/runtime/memory/memory-scope.ts +53 -0
  73. package/src/runtime/plugin-resolution.ts +124 -25
  74. package/src/runtime/plugin-types.ts +9 -1
  75. package/src/runtime/post-turn-side-effects.ts +177 -130
  76. package/src/runtime/retrieval-adapters.ts +40 -6
  77. package/src/runtime/runtime-accessors.ts +92 -0
  78. package/src/runtime/runtime-config.ts +150 -61
  79. package/src/runtime/runtime-extensions.ts +23 -25
  80. package/src/runtime/runtime-lifecycle.ts +124 -0
  81. package/src/runtime/runtime-services.ts +386 -0
  82. package/src/runtime/runtime-token.ts +47 -0
  83. package/src/runtime/social-chat/social-chat-agent-runner.ts +159 -0
  84. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +51 -20
  85. package/src/runtime/social-chat/social-chat.ts +630 -0
  86. package/src/runtime/specialist-runner.ts +36 -10
  87. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +433 -0
  88. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  89. package/src/runtime/thread-chat-helpers.ts +2 -2
  90. package/src/runtime/thread-plan-turn.ts +2 -1
  91. package/src/runtime/thread-turn-context.ts +183 -111
  92. package/src/runtime/turn-lifecycle.ts +93 -27
  93. package/src/services/agent-activity.service.ts +287 -203
  94. package/src/services/agent-executor.service.ts +253 -149
  95. package/src/services/artifact.service.ts +231 -149
  96. package/src/services/attachment.service.ts +171 -115
  97. package/src/services/autonomous-job.service.ts +890 -491
  98. package/src/services/background-work.service.ts +54 -0
  99. package/src/services/chat-run-registry.service.ts +13 -1
  100. package/src/services/context-compaction.service.ts +136 -86
  101. package/src/services/document-chunk.service.ts +151 -88
  102. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  103. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  104. package/src/services/execution-plan/execution-plan-graph.ts +278 -0
  105. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  106. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  107. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  108. package/src/services/feedback-loop.service.ts +132 -76
  109. package/src/services/global-orchestrator.service.ts +101 -168
  110. package/src/services/graph-full-routing.ts +193 -0
  111. package/src/services/index.ts +19 -21
  112. package/src/services/institutional-memory.service.ts +213 -125
  113. package/src/services/learned-skill.service.ts +368 -260
  114. package/src/services/memory/memory-conversation.ts +95 -0
  115. package/src/services/memory/memory-errors.ts +27 -0
  116. package/src/services/memory/memory-org-memory.ts +50 -0
  117. package/src/services/memory/memory-preseeded.ts +86 -0
  118. package/src/services/memory/memory-rerank.ts +297 -0
  119. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +6 -5
  120. package/src/services/memory/memory.service.ts +674 -0
  121. package/src/services/memory/rerank.service.ts +201 -0
  122. package/src/services/monitoring-window.service.ts +92 -70
  123. package/src/services/mutating-approval.service.ts +62 -53
  124. package/src/services/node-workspace.service.ts +141 -98
  125. package/src/services/notification.service.ts +29 -16
  126. package/src/services/organization-member.service.ts +120 -66
  127. package/src/services/organization.service.ts +153 -77
  128. package/src/services/ownership-dispatcher.service.ts +456 -263
  129. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  130. package/src/services/plan/plan-agent-query.service.ts +322 -0
  131. package/src/services/{plan-approval.service.ts → plan/plan-approval.service.ts} +45 -22
  132. package/src/services/plan/plan-artifact.service.ts +60 -0
  133. package/src/services/plan/plan-builder.service.ts +76 -0
  134. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  135. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  136. package/src/services/plan/plan-completion-side-effects.ts +169 -0
  137. package/src/services/plan/plan-coordination.service.ts +181 -0
  138. package/src/services/plan/plan-cycle.service.ts +405 -0
  139. package/src/services/plan/plan-deadline.service.ts +533 -0
  140. package/src/services/plan/plan-event-delivery.service.ts +266 -0
  141. package/src/services/plan/plan-executor-context.ts +35 -0
  142. package/src/services/plan/plan-executor-graph.ts +522 -0
  143. package/src/services/plan/plan-executor-helpers.ts +307 -0
  144. package/src/services/plan/plan-executor-persistence.ts +209 -0
  145. package/src/services/plan/plan-executor.service.ts +1737 -0
  146. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  147. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  148. package/src/services/plan/plan-run-serialization.ts +15 -0
  149. package/src/services/plan/plan-run.service.ts +637 -0
  150. package/src/services/plan/plan-scheduler.service.ts +379 -0
  151. package/src/services/plan/plan-template.service.ts +224 -0
  152. package/src/services/plan/plan-transaction-events.ts +36 -0
  153. package/src/services/plan/plan-validator.service.ts +907 -0
  154. package/src/services/plan/plan-workspace.service.ts +131 -0
  155. package/src/services/plugin-executor.service.ts +102 -68
  156. package/src/services/quality-metrics.service.ts +112 -94
  157. package/src/services/queue-job.service.ts +288 -231
  158. package/src/services/recent-activity-title.service.ts +73 -36
  159. package/src/services/recent-activity.service.ts +274 -259
  160. package/src/services/skill-resolver.service.ts +38 -12
  161. package/src/services/social-chat-history.service.ts +190 -122
  162. package/src/services/system-executor.service.ts +96 -61
  163. package/src/services/thread/thread-active-run.ts +203 -0
  164. package/src/services/thread/thread-bootstrap.ts +385 -0
  165. package/src/services/thread/thread-listing.ts +199 -0
  166. package/src/services/thread/thread-memory-block.ts +130 -0
  167. package/src/services/thread/thread-message.service.ts +379 -0
  168. package/src/services/thread/thread-record-store.ts +155 -0
  169. package/src/services/thread/thread-title.service.ts +74 -0
  170. package/src/services/thread/thread-turn-execution.ts +280 -0
  171. package/src/services/thread/thread-turn-message-context.ts +73 -0
  172. package/src/services/thread/thread-turn-preparation.service.ts +1148 -0
  173. package/src/services/thread/thread-turn-streaming.ts +403 -0
  174. package/src/services/thread/thread-turn-tracing.ts +35 -0
  175. package/src/services/thread/thread-turn.ts +376 -0
  176. package/src/services/thread/thread.service.ts +344 -0
  177. package/src/services/user.service.ts +82 -32
  178. package/src/services/write-intent-validator.service.ts +63 -51
  179. package/src/storage/attachment-parser.ts +69 -27
  180. package/src/storage/attachment-storage.service.ts +334 -275
  181. package/src/storage/generated-document-storage.service.ts +66 -34
  182. package/src/system-agents/agent-result.ts +3 -1
  183. package/src/system-agents/context-compaction.agent.ts +3 -3
  184. package/src/system-agents/delegated-agent-factory.ts +159 -90
  185. package/src/system-agents/helper-agent-options.ts +1 -1
  186. package/src/system-agents/memory-reranker.agent.ts +3 -3
  187. package/src/system-agents/memory.agent.ts +3 -3
  188. package/src/system-agents/recent-activity-title-refiner.agent.ts +3 -3
  189. package/src/system-agents/regular-chat-memory-digest.agent.ts +3 -3
  190. package/src/system-agents/skill-extractor.agent.ts +3 -3
  191. package/src/system-agents/skill-manager.agent.ts +3 -3
  192. package/src/system-agents/thread-router.agent.ts +157 -113
  193. package/src/system-agents/title-generator.agent.ts +3 -3
  194. package/src/tools/execution-plan.tool.ts +241 -171
  195. package/src/tools/fetch-webpage.tool.ts +29 -18
  196. package/src/tools/firecrawl-client.ts +26 -6
  197. package/src/tools/index.ts +1 -0
  198. package/src/tools/memory-block.tool.ts +14 -6
  199. package/src/tools/plan-approval.tool.ts +57 -47
  200. package/src/tools/read-file-parts.tool.ts +44 -33
  201. package/src/tools/remember-memory.tool.ts +65 -45
  202. package/src/tools/search-web.tool.ts +33 -22
  203. package/src/tools/search.tool.ts +41 -29
  204. package/src/tools/team-think.tool.ts +125 -84
  205. package/src/tools/user-questions.tool.ts +4 -3
  206. package/src/tools/web-tool-shared.ts +6 -0
  207. package/src/utils/async.ts +25 -22
  208. package/src/utils/crypto.ts +21 -0
  209. package/src/utils/date-time.ts +40 -1
  210. package/src/utils/errors.ts +111 -20
  211. package/src/utils/hono-error-handler.ts +24 -39
  212. package/src/utils/index.ts +2 -1
  213. package/src/utils/null-proto-record.ts +41 -0
  214. package/src/utils/sse-keepalive.ts +124 -21
  215. package/src/workers/bootstrap.ts +164 -52
  216. package/src/workers/memory-consolidation.worker.ts +325 -237
  217. package/src/workers/organization-learning.worker.ts +50 -16
  218. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  219. package/src/workers/regular-chat-memory-digest.runner.ts +185 -114
  220. package/src/workers/skill-extraction.runner.ts +176 -93
  221. package/src/workers/utils/file-section-chunker.ts +8 -10
  222. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  223. package/src/workers/utils/repomix-file-sections.ts +2 -2
  224. package/src/workers/utils/thread-message-query.ts +97 -38
  225. package/src/workers/worker-utils.ts +74 -31
  226. package/src/config/debug-logger.ts +0 -47
  227. package/src/config/search.ts +0 -3
  228. package/src/redis/connection-accessor.ts +0 -26
  229. package/src/runtime/agent-types.ts +0 -1
  230. package/src/runtime/context-compaction-runtime.ts +0 -87
  231. package/src/runtime/memory-scope.ts +0 -43
  232. package/src/runtime/social-chat-agent-runner.ts +0 -118
  233. package/src/runtime/social-chat.ts +0 -516
  234. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  235. package/src/services/adaptive-playbook.service.ts +0 -152
  236. package/src/services/artifact-provenance.service.ts +0 -172
  237. package/src/services/chat-attachments.service.ts +0 -17
  238. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  239. package/src/services/execution-plan.service.ts +0 -1118
  240. package/src/services/memory.service.ts +0 -914
  241. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  242. package/src/services/plan-agent-query.service.ts +0 -267
  243. package/src/services/plan-artifact.service.ts +0 -50
  244. package/src/services/plan-builder.service.ts +0 -67
  245. package/src/services/plan-checkpoint.service.ts +0 -81
  246. package/src/services/plan-completion-side-effects.ts +0 -80
  247. package/src/services/plan-coordination.service.ts +0 -157
  248. package/src/services/plan-cycle.service.ts +0 -284
  249. package/src/services/plan-deadline.service.ts +0 -430
  250. package/src/services/plan-event-delivery.service.ts +0 -166
  251. package/src/services/plan-executor.service.ts +0 -1950
  252. package/src/services/plan-run.service.ts +0 -515
  253. package/src/services/plan-scheduler.service.ts +0 -240
  254. package/src/services/plan-template.service.ts +0 -177
  255. package/src/services/plan-validator.service.ts +0 -818
  256. package/src/services/plan-workspace.service.ts +0 -83
  257. package/src/services/rerank.service.ts +0 -156
  258. package/src/services/thread-message.service.ts +0 -275
  259. package/src/services/thread-plan-registry.service.ts +0 -22
  260. package/src/services/thread-title.service.ts +0 -39
  261. package/src/services/thread-turn-preparation.service.ts +0 -1147
  262. package/src/services/thread-turn.ts +0 -172
  263. package/src/services/thread.service.ts +0 -869
  264. package/src/utils/env.ts +0 -8
  265. /package/src/runtime/{context-compaction-constants.ts → context-compaction/context-compaction-constants.ts} +0 -0
  266. /package/src/runtime/{memory-format.ts → memory/memory-format.ts} +0 -0
  267. /package/src/runtime/{memory-prompts-parse.ts → memory/memory-prompts-parse.ts} +0 -0
  268. /package/src/runtime/{memory-prompts-update.ts → memory/memory-prompts-update.ts} +0 -0
  269. /package/src/runtime/{social-chat-prompts.ts → social-chat/social-chat-prompts.ts} +0 -0
  270. /package/src/services/{plan-node-spec.ts → plan/plan-node-spec.ts} +0 -0
  271. /package/src/services/{thread-constants.ts → thread/thread-constants.ts} +0 -0
  272. /package/src/services/{thread.types.ts → thread/thread.types.ts} +0 -0
@@ -0,0 +1,203 @@
1
+ import { Effect } from 'effect'
2
+ import { surql } from 'surrealdb'
3
+
4
+ import { serverLogger } from '../../config/logger'
5
+ import type { RecordIdRef } from '../../db/record-id'
6
+ import { ensureRecordId, recordIdToString } from '../../db/record-id'
7
+ import type { SurrealDBService } from '../../db/service'
8
+ import { TABLES } from '../../db/tables'
9
+ import { ActiveThreadRunConflictError, DatabaseError, RedisError } from '../../effect/errors'
10
+ import type { RedisConnectionManager } from '../../redis/connection'
11
+ import { withLeaseLock } from '../../redis/redis-lease-lock'
12
+ import type { ThreadRecordStore } from './thread-record-store'
13
+
14
+ // Background turns can spend several seconds inside model/tool orchestration even
15
+ // when the process is healthy. A 5s lease was short enough to lose legitimate
16
+ // runs under normal jitter, so keep a wider safety margin while still expiring
17
+ // crashed runs quickly enough for recovery.
18
+ const THREAD_ACTIVE_RUN_LOCK_TTL_MS = 30_000
19
+ const THREAD_ACTIVE_RUN_LOCK_MAX_WAIT_MS = 750
20
+ const THREAD_ACTIVE_RUN_LOCK_RETRY_DELAY_MS = 75
21
+ const THREAD_ACTIVE_RUN_LOCK_REFRESH_INTERVAL_MS = 5_000
22
+
23
+ type ChatRunRegistryLike = {
24
+ has(runId: string): boolean
25
+ stopEffect(runId: string, reason: DOMException): Effect.Effect<boolean>
26
+ }
27
+
28
+ function buildActiveRunLockKey(threadId: RecordIdRef): string {
29
+ return `thread-active-run:${recordIdToString(ensureRecordId(threadId, TABLES.THREAD), TABLES.THREAD)}`
30
+ }
31
+
32
+ function normalizeActiveTurnValue(value: unknown): string | null {
33
+ if (typeof value !== 'string') {
34
+ return null
35
+ }
36
+
37
+ const normalized = value.trim()
38
+ return normalized.length > 0 ? normalized : null
39
+ }
40
+
41
+ export function createThreadActiveRunHelpers(deps: {
42
+ db: SurrealDBService
43
+ threadStore: ThreadRecordStore
44
+ redis: RedisConnectionManager
45
+ chatRunRegistry: ChatRunRegistryLike
46
+ }) {
47
+ const setActiveTurnEffect = (threadId: RecordIdRef, runId: string, streamId?: string | null) =>
48
+ Effect.gen(function* () {
49
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
50
+ if (streamId === null || streamId === undefined) {
51
+ yield* deps.db
52
+ .query(
53
+ surql`
54
+ UPDATE ONLY ${threadRef}
55
+ SET activeRunId = ${runId},
56
+ activeStreamId = NONE
57
+ `,
58
+ )
59
+ .pipe(Effect.mapError((cause) => new DatabaseError({ message: 'Failed to set active run state.', cause })))
60
+ return
61
+ }
62
+
63
+ yield* deps.db
64
+ .query(
65
+ surql`
66
+ UPDATE ONLY ${threadRef}
67
+ SET activeRunId = ${runId},
68
+ activeStreamId = ${streamId}
69
+ `,
70
+ )
71
+ .pipe(Effect.mapError((cause) => new DatabaseError({ message: 'Failed to set active run state.', cause })))
72
+ })
73
+
74
+ const getActiveTurnEffect = (threadId: RecordIdRef) =>
75
+ Effect.gen(function* () {
76
+ const thread = yield* deps.threadStore
77
+ .getById(threadId)
78
+ .pipe(Effect.mapError((cause) => new DatabaseError({ message: 'Failed to load active thread state.', cause })))
79
+ return {
80
+ runId: normalizeActiveTurnValue(thread.activeRunId),
81
+ streamId: normalizeActiveTurnValue(thread.activeStreamId),
82
+ }
83
+ })
84
+
85
+ const getActiveRunIdEffect = (threadId: RecordIdRef) =>
86
+ Effect.gen(function* () {
87
+ const { runId } = yield* getActiveTurnEffect(threadId)
88
+ return runId
89
+ })
90
+
91
+ const hasActiveRunLeaseEffect = (threadId: RecordIdRef) =>
92
+ Effect.gen(function* () {
93
+ const count = yield* Effect.tryPromise({
94
+ try: () => deps.redis.getConnection().exists(buildActiveRunLockKey(threadId)),
95
+ catch: (error) => new RedisError({ message: 'Failed to inspect active run lease.', cause: error }),
96
+ })
97
+ return count > 0
98
+ })
99
+
100
+ const getActiveStreamIdEffect = (threadId: RecordIdRef) =>
101
+ Effect.gen(function* () {
102
+ const { streamId } = yield* getActiveTurnEffect(threadId)
103
+ return streamId
104
+ })
105
+
106
+ const clearActiveTurnEffect = (threadId: RecordIdRef, params: { runId: string; streamId?: string | null }) =>
107
+ Effect.gen(function* () {
108
+ const threadRef = ensureRecordId(threadId, TABLES.THREAD)
109
+ const currentStreamId = params.streamId ?? null
110
+ if (currentStreamId === null) {
111
+ yield* deps.db
112
+ .query(
113
+ surql`UPDATE ONLY ${threadRef} SET activeRunId = NONE, activeStreamId = NONE WHERE activeRunId = ${params.runId}`,
114
+ )
115
+ .pipe(Effect.mapError((cause) => new DatabaseError({ message: 'Failed to clear active run state.', cause })))
116
+ return
117
+ }
118
+
119
+ yield* deps.db
120
+ .query(surql`
121
+ UPDATE ONLY ${threadRef}
122
+ SET activeRunId = NONE,
123
+ activeStreamId = NONE
124
+ WHERE activeRunId = ${params.runId} AND activeStreamId = ${currentStreamId}
125
+ `)
126
+ .pipe(Effect.mapError((cause) => new DatabaseError({ message: 'Failed to clear active run state.', cause })))
127
+ })
128
+
129
+ const clearStaleActiveRunIfMissingFromRegistryEffect = (threadId: RecordIdRef) =>
130
+ Effect.gen(function* () {
131
+ const { runId: activeRunId, streamId: activeStreamId } = yield* getActiveTurnEffect(threadId)
132
+ if (!activeRunId) {
133
+ return false
134
+ }
135
+
136
+ const hasLease = yield* hasActiveRunLeaseEffect(threadId)
137
+ if (hasLease) {
138
+ return false
139
+ }
140
+
141
+ yield* clearActiveTurnEffect(threadId, { runId: activeRunId, streamId: activeStreamId })
142
+ serverLogger.warn`Cleared stale thread run after lease expired: thread=${recordIdToString(ensureRecordId(threadId, TABLES.THREAD), TABLES.THREAD)} run=${activeRunId}`
143
+ return true
144
+ })
145
+
146
+ const stopActiveRunEffect = (threadId: RecordIdRef) =>
147
+ Effect.gen(function* () {
148
+ const { runId: activeRunId } = yield* getActiveTurnEffect(threadId)
149
+ if (!activeRunId) return false
150
+
151
+ const stopped = yield* deps.chatRunRegistry.stopEffect(
152
+ activeRunId,
153
+ new DOMException('Run stopped by user.', 'AbortError'),
154
+ )
155
+ if (stopped) {
156
+ return true
157
+ }
158
+
159
+ return yield* clearStaleActiveRunIfMissingFromRegistryEffect(threadId)
160
+ })
161
+
162
+ function withActiveRunLease<T, E, R>(
163
+ threadId: RecordIdRef,
164
+ fn: (signal: AbortSignal) => Effect.Effect<T, E, R>,
165
+ ): Effect.Effect<T, E | ActiveThreadRunConflictError | RedisError, R> {
166
+ return withLeaseLock(
167
+ {
168
+ redis: deps.redis.getConnection(),
169
+ lockKey: buildActiveRunLockKey(threadId),
170
+ lockTtlMs: THREAD_ACTIVE_RUN_LOCK_TTL_MS,
171
+ refreshIntervalMs: THREAD_ACTIVE_RUN_LOCK_REFRESH_INTERVAL_MS,
172
+ retryDelayMs: THREAD_ACTIVE_RUN_LOCK_RETRY_DELAY_MS,
173
+ maxWaitMs: THREAD_ACTIVE_RUN_LOCK_MAX_WAIT_MS,
174
+ label: 'thread active run',
175
+ logger: serverLogger,
176
+ },
177
+ fn,
178
+ ).pipe(
179
+ Effect.catchTag('LockAcquisitionError', () =>
180
+ Effect.gen(function* () {
181
+ const activeRunId = (yield* Effect.catch(getActiveRunIdEffect(threadId), () => Effect.succeed(null))) ?? ''
182
+ return yield* new ActiveThreadRunConflictError({
183
+ threadId: recordIdToString(ensureRecordId(threadId, TABLES.THREAD), TABLES.THREAD),
184
+ activeRunId,
185
+ message: 'A chat run is already active.',
186
+ })
187
+ }),
188
+ ),
189
+ )
190
+ }
191
+
192
+ return {
193
+ setActiveTurn: setActiveTurnEffect,
194
+ getActiveTurn: getActiveTurnEffect,
195
+ getActiveRunId: getActiveRunIdEffect,
196
+ hasActiveRunLease: hasActiveRunLeaseEffect,
197
+ withActiveRunLease,
198
+ getActiveStreamId: getActiveStreamIdEffect,
199
+ clearActiveTurn: clearActiveTurnEffect,
200
+ clearStaleActiveRunIfMissingFromRegistry: clearStaleActiveRunIfMissingFromRegistryEffect,
201
+ stopActiveRun: stopActiveRunEffect,
202
+ }
203
+ }
@@ -0,0 +1,385 @@
1
+ import { THREAD } from '@lota-sdk/shared'
2
+ import { Effect } from 'effect'
3
+
4
+ import { getAgentDisplayNames, getAgentRoster, getCoreThreadProfile } from '../../config/agent-defaults'
5
+ import { serverLogger } from '../../config/logger'
6
+ import { getThreadBootstrapConfig } from '../../config/thread-defaults'
7
+ import type { RecordIdRef } from '../../db/record-id'
8
+ import { ensureRecordId, recordIdToString } from '../../db/record-id'
9
+ import { TABLES } from '../../db/tables'
10
+ import { BadRequestError, DatabaseError } from '../../effect/errors'
11
+ import type { LockAcquisitionError, LockLostError, NotFoundError, RedisError, ServiceError } from '../../effect/errors'
12
+ import type { RedisConnectionManager } from '../../redis/connection'
13
+ import { withLeaseLock } from '../../redis/redis-lease-lock'
14
+ import type { makeThreadMessageService } from './thread-message.service'
15
+ import type { ThreadRecordStore } from './thread-record-store'
16
+ import type { NormalizedThread, ThreadRecord } from './thread.types'
17
+
18
+ const THREAD_BOOTSTRAP_LOCK_TTL_MS = 15_000
19
+ const THREAD_BOOTSTRAP_LOCK_REFRESH_INTERVAL_MS = 5_000
20
+ const THREAD_BOOTSTRAP_LOCK_MAX_WAIT_MS = 5_000
21
+ const THREAD_BOOTSTRAP_LOCK_RETRY_DELAY_MS = 100
22
+
23
+ function getAgentDisplayName(agentId: string): string {
24
+ return getAgentDisplayNames()[agentId] ?? agentId
25
+ }
26
+
27
+ function buildBootstrapThreadsLockKey(userId: RecordIdRef, orgId: RecordIdRef): string {
28
+ return `thread-bootstrap:${recordIdToString(ensureRecordId(userId, TABLES.USER), TABLES.USER)}:${recordIdToString(ensureRecordId(orgId, TABLES.ORGANIZATION), TABLES.ORGANIZATION)}`
29
+ }
30
+
31
+ function haveSameMembers(left: string[], right: string[]): boolean {
32
+ return left.length === right.length && left.every((value, index) => value === right[index])
33
+ }
34
+
35
+ type ThreadNormalizationError = BadRequestError | ServiceError
36
+ type ThreadBootstrapError =
37
+ | BadRequestError
38
+ | DatabaseError
39
+ | LockAcquisitionError
40
+ | LockLostError
41
+ | NotFoundError
42
+ | RedisError
43
+ | ServiceError
44
+
45
+ type NormalizedThreadFactory = (thread: ThreadRecord) => Effect.Effect<NormalizedThread, ThreadNormalizationError>
46
+ type DuplicateCreateErrorLike = { cause?: unknown; message?: unknown }
47
+
48
+ function isDuplicateCreateErrorLike(value: unknown): value is DuplicateCreateErrorLike {
49
+ return (typeof value === 'object' || typeof value === 'function') && value !== null
50
+ }
51
+
52
+ export function createThreadBootstrapHelpers(deps: {
53
+ threadStore: ThreadRecordStore
54
+ threadMessageService: Pick<ReturnType<typeof makeThreadMessageService>, 'ensureBootstrapWelcomeMessageEffect'>
55
+ redis: RedisConnectionManager
56
+ normalizeThread: NormalizedThreadFactory
57
+ }) {
58
+ const syncThreadConfigEffect = (
59
+ record: ThreadRecord,
60
+ config?: { title?: string; nameGenerated?: boolean; members?: string[] },
61
+ ) => {
62
+ if (!config) {
63
+ return Effect.succeed(record)
64
+ }
65
+
66
+ const updates: Partial<ThreadRecord> = {}
67
+ if (config.title !== undefined && record.title !== config.title) {
68
+ updates.title = config.title
69
+ }
70
+ if (config.nameGenerated !== undefined && record.nameGenerated !== config.nameGenerated) {
71
+ updates.nameGenerated = config.nameGenerated
72
+ }
73
+ if (config.members !== undefined && !haveSameMembers(record.members, config.members)) {
74
+ updates.members = config.members
75
+ }
76
+
77
+ if (Object.keys(updates).length === 0) {
78
+ return Effect.succeed(record)
79
+ }
80
+
81
+ return deps.threadStore.update(record.id, updates)
82
+ }
83
+
84
+ const findThreadByUniqueLookupEffect = (lookup: {
85
+ type: 'default' | 'thread'
86
+ organizationId: RecordIdRef
87
+ userId: RecordIdRef
88
+ agentId?: string
89
+ threadType?: string
90
+ }) => deps.threadStore.findThreadByUniqueLookup(lookup)
91
+
92
+ const waitForExistingThreadEffect = (lookup: {
93
+ type: 'default' | 'thread'
94
+ organizationId: RecordIdRef
95
+ userId: RecordIdRef
96
+ agentId?: string
97
+ threadType?: string
98
+ }) => deps.threadStore.waitForExistingThread(lookup)
99
+
100
+ const isDuplicateCreateError = (error: unknown): boolean => {
101
+ const seen = new Set<unknown>()
102
+ let current = error
103
+
104
+ while (isDuplicateCreateErrorLike(current) && !seen.has(current)) {
105
+ seen.add(current)
106
+ if (typeof current.message === 'string' && current.message.includes('already contains')) {
107
+ return true
108
+ }
109
+ current = current.cause
110
+ }
111
+
112
+ return false
113
+ }
114
+
115
+ const normalizeThreadEffect = (thread: ThreadRecord): Effect.Effect<NormalizedThread, ThreadNormalizationError> =>
116
+ deps.normalizeThread(thread)
117
+
118
+ const getOrCreateDefaultEffect = (
119
+ orgId: RecordIdRef,
120
+ userId: RecordIdRef,
121
+ agentId: string,
122
+ config?: { title?: string; nameGenerated?: boolean },
123
+ ): Effect.Effect<{ created: boolean; record: ThreadRecord }, DatabaseError | NotFoundError> => {
124
+ const lookup = { type: 'default' as const, organizationId: orgId, userId, agentId }
125
+
126
+ return Effect.gen(function* () {
127
+ const existing = yield* findThreadByUniqueLookupEffect(lookup)
128
+ if (existing) {
129
+ const record = yield* syncThreadConfigEffect(existing, config)
130
+ return { record, created: false as const }
131
+ }
132
+
133
+ return yield* Effect.matchEffect(
134
+ deps.threadStore.create({
135
+ type: 'default',
136
+ organizationId: orgId,
137
+ userId,
138
+ agentId,
139
+ members: [agentId],
140
+ title: config?.title ?? getAgentDisplayName(agentId),
141
+ status: 'active',
142
+ nameGenerated: config?.nameGenerated ?? false,
143
+ isCompacting: false,
144
+ turnCount: 0,
145
+ }),
146
+ {
147
+ onFailure: (error) => {
148
+ if (!isDuplicateCreateError(error)) {
149
+ return Effect.fail(error)
150
+ }
151
+
152
+ return Effect.gen(function* () {
153
+ const existingThread = yield* waitForExistingThreadEffect(lookup)
154
+ const record = yield* syncThreadConfigEffect(existingThread, config)
155
+ return { record, created: false as const }
156
+ })
157
+ },
158
+ onSuccess: (record) => Effect.succeed({ record, created: true as const }),
159
+ },
160
+ )
161
+ })
162
+ }
163
+
164
+ const getOrCreateThreadEffect = (
165
+ orgId: RecordIdRef,
166
+ userId: RecordIdRef,
167
+ threadType: string,
168
+ config: { members: string[]; title: string; nameGenerated?: boolean },
169
+ ): Effect.Effect<{ created: boolean; record: ThreadRecord }, DatabaseError | NotFoundError> => {
170
+ const lookup = { type: 'thread' as const, organizationId: orgId, userId, threadType }
171
+
172
+ return Effect.gen(function* () {
173
+ const existing = yield* findThreadByUniqueLookupEffect(lookup)
174
+ if (existing) {
175
+ const record = yield* syncThreadConfigEffect(existing, config)
176
+ return { record, created: false as const }
177
+ }
178
+
179
+ return yield* Effect.matchEffect(
180
+ deps.threadStore.create({
181
+ type: 'thread',
182
+ organizationId: orgId,
183
+ userId,
184
+ threadType,
185
+ members: config.members,
186
+ title: config.title,
187
+ status: 'active',
188
+ nameGenerated: config.nameGenerated ?? false,
189
+ isCompacting: false,
190
+ turnCount: 0,
191
+ }),
192
+ {
193
+ onFailure: (error) => {
194
+ if (!isDuplicateCreateError(error)) {
195
+ return Effect.fail(error)
196
+ }
197
+
198
+ return Effect.gen(function* () {
199
+ const existingThread = yield* waitForExistingThreadEffect(lookup)
200
+ const record = yield* syncThreadConfigEffect(existingThread, config)
201
+ return { record, created: false as const }
202
+ })
203
+ },
204
+ onSuccess: (record) => Effect.succeed({ record, created: true as const }),
205
+ },
206
+ )
207
+ })
208
+ }
209
+
210
+ const createThreadEffect = (input: {
211
+ userId: RecordIdRef
212
+ organizationId: RecordIdRef
213
+ type: string
214
+ agentId?: string
215
+ threadType?: string
216
+ members?: string[]
217
+ title?: string
218
+ }): Effect.Effect<NormalizedThread, ThreadBootstrapError> =>
219
+ Effect.gen(function* () {
220
+ switch (input.type) {
221
+ case 'default':
222
+ if (!input.agentId) return yield* new BadRequestError({ message: 'Default threads require agentId' })
223
+ if (input.threadType) return yield* new BadRequestError({ message: 'Default threads cannot have threadType' })
224
+ break
225
+ case 'topic':
226
+ if (!input.agentId) return yield* new BadRequestError({ message: 'Topic threads require agentId' })
227
+ if (input.threadType) return yield* new BadRequestError({ message: 'Topic threads cannot have threadType' })
228
+ break
229
+ case 'thread':
230
+ if (!input.threadType) return yield* new BadRequestError({ message: 'Thread threads require threadType' })
231
+ if (input.agentId) return yield* new BadRequestError({ message: 'Thread threads cannot have agentId' })
232
+ break
233
+ case 'group':
234
+ if (input.agentId) return yield* new BadRequestError({ message: 'Group threads cannot have agentId' })
235
+ if (input.threadType) return yield* new BadRequestError({ message: 'Group threads cannot have threadType' })
236
+ break
237
+ }
238
+
239
+ const title = input.title ?? THREAD.DEFAULT_TITLE
240
+ const nameGenerated = input.title !== undefined && input.title !== THREAD.DEFAULT_TITLE
241
+
242
+ if (input.type === 'default') {
243
+ const agentId = input.agentId
244
+ if (!agentId) return yield* new BadRequestError({ message: 'Default threads require agentId' })
245
+ const { record } = yield* getOrCreateDefaultEffect(input.organizationId, input.userId, agentId, undefined)
246
+ return yield* normalizeThreadEffect(record)
247
+ }
248
+
249
+ if (input.type === 'thread') {
250
+ const threadType = input.threadType
251
+ if (!threadType) return yield* new BadRequestError({ message: 'Thread threads require threadType' })
252
+ const { record } = yield* getOrCreateThreadEffect(input.organizationId, input.userId, threadType, {
253
+ members: input.members ?? [...getAgentRoster()],
254
+ title,
255
+ nameGenerated,
256
+ })
257
+ return yield* normalizeThreadEffect(record)
258
+ }
259
+
260
+ const thread = yield* deps.threadStore.create({
261
+ userId: input.userId,
262
+ organizationId: input.organizationId,
263
+ type: input.type,
264
+ agentId: input.agentId,
265
+ threadType: input.threadType,
266
+ members: input.members ?? [...getAgentRoster()],
267
+ title,
268
+ status: 'active',
269
+ nameGenerated,
270
+ isCompacting: false,
271
+ turnCount: 0,
272
+ })
273
+
274
+ return yield* normalizeThreadEffect(thread)
275
+ })
276
+
277
+ const ensureBootstrapThreadsEffect = (
278
+ userId: RecordIdRef,
279
+ orgId: RecordIdRef,
280
+ options?: { onboardStatus?: string; userName?: string | null },
281
+ ): Effect.Effect<void, ThreadBootstrapError> => {
282
+ const bootstrapConfig = getThreadBootstrapConfig()
283
+
284
+ return withLeaseLock(
285
+ {
286
+ redis: deps.redis.getConnection(),
287
+ lockKey: buildBootstrapThreadsLockKey(userId, orgId),
288
+ lockTtlMs: THREAD_BOOTSTRAP_LOCK_TTL_MS,
289
+ refreshIntervalMs: THREAD_BOOTSTRAP_LOCK_REFRESH_INTERVAL_MS,
290
+ retryDelayMs: THREAD_BOOTSTRAP_LOCK_RETRY_DELAY_MS,
291
+ maxWaitMs: THREAD_BOOTSTRAP_LOCK_MAX_WAIT_MS,
292
+ label: 'thread bootstrap',
293
+ logger: serverLogger,
294
+ },
295
+ (signal) =>
296
+ Effect.gen(function* () {
297
+ const failIfAborted = () =>
298
+ signal.aborted
299
+ ? Effect.fail(new DatabaseError({ message: 'Thread bootstrap lease was aborted.', cause: signal.reason }))
300
+ : Effect.void
301
+
302
+ yield* failIfAborted()
303
+ const onboardStatus = options?.onboardStatus ?? 'completed'
304
+ const onboardingCompleted = onboardStatus === 'completed'
305
+
306
+ const existingThreads = yield* deps.threadStore.findAll({ userId, organizationId: orgId })
307
+ yield* failIfAborted()
308
+
309
+ const hasGroupThread = existingThreads.some((t) => t.type === 'group')
310
+ const defaultThreadsByAgent = new Map<string, ThreadRecord>()
311
+ const threadThreadsByType = new Map<string, ThreadRecord>()
312
+
313
+ for (const thread of existingThreads) {
314
+ if (thread.type === 'default' && thread.agentId) {
315
+ defaultThreadsByAgent.set(thread.agentId, thread)
316
+ }
317
+ if (thread.type === 'thread' && typeof thread.threadType === 'string') {
318
+ threadThreadsByType.set(thread.threadType, thread)
319
+ }
320
+ }
321
+
322
+ const requiredDefaultAgents = onboardingCompleted
323
+ ? bootstrapConfig.completedDefaultAgents
324
+ : bootstrapConfig.onboardingDefaultAgents
325
+
326
+ for (const agentId of requiredDefaultAgents) {
327
+ if (defaultThreadsByAgent.has(agentId)) continue
328
+ yield* getOrCreateDefaultEffect(orgId, userId, agentId)
329
+ yield* failIfAborted()
330
+ }
331
+
332
+ if (onboardingCompleted && bootstrapConfig.ensureDefaultGroupOnCompleted && !hasGroupThread) {
333
+ const normalized = yield* createThreadEffect({
334
+ userId,
335
+ organizationId: orgId,
336
+ type: 'group',
337
+ title: THREAD.DEFAULT_TITLE,
338
+ })
339
+ yield* deps.threadStore.getById(normalized.id)
340
+ }
341
+
342
+ if (onboardingCompleted) {
343
+ for (const wsType of bootstrapConfig.threadTypesAfterOnboarding) {
344
+ if (threadThreadsByType.has(wsType)) continue
345
+ const profile = getCoreThreadProfile(wsType)
346
+ yield* getOrCreateThreadEffect(orgId, userId, wsType, {
347
+ members: [...profile.members],
348
+ title: profile.config.title,
349
+ })
350
+ yield* failIfAborted()
351
+ }
352
+ }
353
+
354
+ if (!onboardingCompleted && bootstrapConfig.onboardingWelcome) {
355
+ const onboardingWelcome = bootstrapConfig.onboardingWelcome
356
+ const ownerThread = defaultThreadsByAgent.get(onboardingWelcome.defaultAgentId)
357
+ if (ownerThread?.id) {
358
+ yield* failIfAborted()
359
+ const ownerThreadRef = ensureRecordId(ownerThread.id, TABLES.THREAD)
360
+ yield* deps.threadMessageService
361
+ .ensureBootstrapWelcomeMessageEffect({
362
+ threadId: ownerThreadRef,
363
+ agentId: onboardingWelcome.defaultAgentId,
364
+ text: onboardingWelcome.buildMessageText({ userName: options?.userName }),
365
+ })
366
+ .pipe(
367
+ Effect.mapError(
368
+ (error) =>
369
+ new DatabaseError({ message: 'Failed to write onboarding welcome message.', cause: error }),
370
+ ),
371
+ )
372
+ yield* failIfAborted()
373
+ }
374
+ }
375
+ }),
376
+ )
377
+ }
378
+
379
+ return {
380
+ getOrCreateDefault: getOrCreateDefaultEffect,
381
+ getOrCreateThread: getOrCreateThreadEffect,
382
+ createThread: createThreadEffect,
383
+ ensureBootstrapThreads: ensureBootstrapThreadsEffect,
384
+ }
385
+ }