@lota-sdk/core 0.4.7 → 0.4.9

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