@lota-sdk/core 0.4.8 → 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/thread-defaults.ts +33 -21
  9. package/src/create-runtime.ts +725 -387
  10. package/src/db/base.service.ts +52 -28
  11. package/src/db/cursor-pagination.ts +71 -30
  12. package/src/db/memory-store.helpers.ts +4 -7
  13. package/src/db/memory-store.ts +856 -598
  14. package/src/db/memory.ts +398 -275
  15. package/src/db/record-id.ts +32 -10
  16. package/src/db/schema-fingerprint.ts +30 -12
  17. package/src/db/service-normalization.ts +255 -0
  18. package/src/db/service.ts +726 -761
  19. package/src/db/startup.ts +140 -66
  20. package/src/db/transaction-conflict.ts +15 -0
  21. package/src/effect/awaitable-effect.ts +87 -0
  22. package/src/effect/errors.ts +121 -0
  23. package/src/effect/helpers.ts +98 -0
  24. package/src/effect/index.ts +22 -0
  25. package/src/effect/layers.ts +228 -0
  26. package/src/effect/runtime-ref.ts +25 -0
  27. package/src/effect/runtime.ts +31 -0
  28. package/src/effect/services.ts +57 -0
  29. package/src/effect/zod.ts +43 -0
  30. package/src/embeddings/provider.ts +122 -76
  31. package/src/index.ts +46 -1
  32. package/src/openrouter/direct-provider.ts +11 -35
  33. package/src/queues/autonomous-job.queue.ts +130 -74
  34. package/src/queues/context-compaction.queue.ts +60 -15
  35. package/src/queues/delayed-node-promotion.queue.ts +52 -15
  36. package/src/queues/document-processor.queue.ts +52 -77
  37. package/src/queues/memory-consolidation.queue.ts +47 -32
  38. package/src/queues/organization-learning.queue.ts +13 -4
  39. package/src/queues/plan-agent-heartbeat.queue.ts +65 -21
  40. package/src/queues/plan-scheduler.queue.ts +107 -31
  41. package/src/queues/post-chat-memory.queue.ts +66 -24
  42. package/src/queues/queue-factory.ts +142 -52
  43. package/src/queues/standalone-worker.ts +39 -0
  44. package/src/queues/title-generation.queue.ts +54 -9
  45. package/src/redis/connection.ts +84 -32
  46. package/src/redis/index.ts +6 -8
  47. package/src/redis/org-memory-lock.ts +60 -27
  48. package/src/redis/redis-lease-lock.ts +200 -121
  49. package/src/redis/runtime-connection.ts +10 -0
  50. package/src/redis/stream-context.ts +84 -46
  51. package/src/runtime/agent-identity-overrides.ts +2 -2
  52. package/src/runtime/agent-runtime-policy.ts +4 -1
  53. package/src/runtime/agent-stream-helpers.ts +20 -9
  54. package/src/runtime/chat-run-orchestration.ts +102 -19
  55. package/src/runtime/chat-run-registry.ts +36 -2
  56. package/src/runtime/context-compaction/context-compaction-runtime.ts +107 -0
  57. package/src/runtime/{context-compaction.ts → context-compaction/context-compaction.ts} +114 -91
  58. package/src/runtime/execution-plan-visibility.ts +2 -2
  59. package/src/runtime/execution-plan.ts +42 -15
  60. package/src/runtime/graph-designer.ts +11 -7
  61. package/src/runtime/helper-model.ts +135 -48
  62. package/src/runtime/index.ts +7 -7
  63. package/src/runtime/indexed-repositories-policy.ts +3 -3
  64. package/src/runtime/{memory-block.ts → memory/memory-block.ts} +40 -36
  65. package/src/runtime/{memory-digest-policy.ts → memory/memory-digest-policy.ts} +1 -1
  66. package/src/runtime/{memory-pipeline.ts → memory/memory-pipeline.ts} +1 -1
  67. package/src/runtime/{memory-prompts-fact.ts → memory/memory-prompts-fact.ts} +2 -2
  68. package/src/runtime/{memory-scope.ts → memory/memory-scope.ts} +12 -6
  69. package/src/runtime/plugin-resolution.ts +144 -24
  70. package/src/runtime/plugin-types.ts +9 -1
  71. package/src/runtime/post-turn-side-effects.ts +197 -130
  72. package/src/runtime/retrieval-adapters.ts +38 -4
  73. package/src/runtime/runtime-config.ts +150 -61
  74. package/src/runtime/runtime-extensions.ts +21 -34
  75. package/src/runtime/social-chat/social-chat-agent-runner.ts +157 -0
  76. package/src/runtime/{social-chat-history.ts → social-chat/social-chat-history.ts} +42 -20
  77. package/src/runtime/social-chat/social-chat.ts +594 -0
  78. package/src/runtime/specialist-runner.ts +36 -10
  79. package/src/runtime/team-consultation/team-consultation-orchestrator.ts +427 -0
  80. package/src/runtime/{team-consultation-prompts.ts → team-consultation/team-consultation-prompts.ts} +6 -2
  81. package/src/runtime/thread-chat-helpers.ts +2 -2
  82. package/src/runtime/thread-plan-turn.ts +2 -1
  83. package/src/runtime/thread-turn-context.ts +172 -94
  84. package/src/runtime/turn-lifecycle.ts +93 -27
  85. package/src/services/agent-activity.service.ts +287 -203
  86. package/src/services/agent-executor.service.ts +329 -217
  87. package/src/services/artifact.service.ts +225 -148
  88. package/src/services/attachment.service.ts +137 -115
  89. package/src/services/autonomous-job.service.ts +888 -491
  90. package/src/services/chat-run-registry.service.ts +11 -1
  91. package/src/services/context-compaction.service.ts +136 -86
  92. package/src/services/document-chunk.service.ts +162 -90
  93. package/src/services/execution-plan/execution-plan-approval.ts +26 -0
  94. package/src/services/execution-plan/execution-plan-context.ts +29 -0
  95. package/src/services/execution-plan/execution-plan-graph.ts +256 -0
  96. package/src/services/execution-plan/execution-plan-schedule.ts +84 -0
  97. package/src/services/execution-plan/execution-plan-spec.ts +75 -0
  98. package/src/services/execution-plan/execution-plan.service.ts +1041 -0
  99. package/src/services/feedback-loop.service.ts +132 -76
  100. package/src/services/global-orchestrator.service.ts +80 -170
  101. package/src/services/graph-full-routing.ts +182 -0
  102. package/src/services/index.ts +18 -21
  103. package/src/services/institutional-memory.service.ts +220 -123
  104. package/src/services/learned-skill.service.ts +364 -259
  105. package/src/services/memory/memory-conversation.ts +95 -0
  106. package/src/services/memory/memory-org-memory.ts +39 -0
  107. package/src/services/memory/memory-preseeded.ts +80 -0
  108. package/src/services/memory/memory-rerank.ts +297 -0
  109. package/src/services/{memory-utils.ts → memory/memory-utils.ts} +5 -5
  110. package/src/services/memory/memory.service.ts +692 -0
  111. package/src/services/memory/rerank.service.ts +209 -0
  112. package/src/services/monitoring-window.service.ts +92 -70
  113. package/src/services/mutating-approval.service.ts +62 -53
  114. package/src/services/node-workspace.service.ts +141 -98
  115. package/src/services/notification.service.ts +17 -16
  116. package/src/services/organization-member.service.ts +120 -66
  117. package/src/services/organization.service.ts +144 -51
  118. package/src/services/ownership-dispatcher.service.ts +415 -264
  119. package/src/services/plan/plan-agent-heartbeat.service.ts +234 -0
  120. package/src/services/plan/plan-agent-query.service.ts +322 -0
  121. package/src/services/plan/plan-approval.service.ts +102 -0
  122. package/src/services/plan/plan-artifact.service.ts +60 -0
  123. package/src/services/plan/plan-builder.service.ts +76 -0
  124. package/src/services/plan/plan-checkpoint.service.ts +103 -0
  125. package/src/services/{plan-compiler.service.ts → plan/plan-compiler.service.ts} +26 -9
  126. package/src/services/plan/plan-completion-side-effects.ts +175 -0
  127. package/src/services/plan/plan-coordination.service.ts +181 -0
  128. package/src/services/plan/plan-cycle.service.ts +398 -0
  129. package/src/services/plan/plan-deadline.service.ts +547 -0
  130. package/src/services/plan/plan-event-delivery.service.ts +261 -0
  131. package/src/services/plan/plan-executor-context.ts +35 -0
  132. package/src/services/plan/plan-executor-graph.ts +475 -0
  133. package/src/services/plan/plan-executor-helpers.ts +322 -0
  134. package/src/services/plan/plan-executor-persistence.ts +209 -0
  135. package/src/services/plan/plan-executor.service.ts +1654 -0
  136. package/src/services/{plan-helpers.ts → plan/plan-helpers.ts} +1 -1
  137. package/src/services/{plan-run-data.ts → plan/plan-run-data.ts} +4 -4
  138. package/src/services/plan/plan-run-serialization.ts +15 -0
  139. package/src/services/plan/plan-run.service.ts +644 -0
  140. package/src/services/plan/plan-scheduler.service.ts +385 -0
  141. package/src/services/plan/plan-template.service.ts +224 -0
  142. package/src/services/plan/plan-transaction-events.ts +33 -0
  143. package/src/services/plan/plan-validator.service.ts +907 -0
  144. package/src/services/plan/plan-workspace.service.ts +125 -0
  145. package/src/services/plugin-executor.service.ts +97 -68
  146. package/src/services/quality-metrics.service.ts +112 -94
  147. package/src/services/queue-job.service.ts +296 -230
  148. package/src/services/recent-activity-title.service.ts +65 -36
  149. package/src/services/recent-activity.service.ts +274 -259
  150. package/src/services/skill-resolver.service.ts +38 -12
  151. package/src/services/social-chat-history.service.ts +176 -125
  152. package/src/services/system-executor.service.ts +91 -61
  153. package/src/services/thread/thread-active-run.ts +203 -0
  154. package/src/services/thread/thread-bootstrap.ts +369 -0
  155. package/src/services/thread/thread-listing.ts +198 -0
  156. package/src/services/thread/thread-memory-block.ts +117 -0
  157. package/src/services/thread/thread-message.service.ts +363 -0
  158. package/src/services/thread/thread-record-store.ts +155 -0
  159. package/src/services/thread/thread-title.service.ts +74 -0
  160. package/src/services/thread/thread-turn-execution.ts +280 -0
  161. package/src/services/thread/thread-turn-message-context.ts +73 -0
  162. package/src/services/thread/thread-turn-preparation.service.ts +1146 -0
  163. package/src/services/thread/thread-turn-streaming.ts +402 -0
  164. package/src/services/thread/thread-turn-tracing.ts +35 -0
  165. package/src/services/thread/thread-turn.ts +343 -0
  166. package/src/services/thread/thread.service.ts +335 -0
  167. package/src/services/user.service.ts +82 -32
  168. package/src/services/write-intent-validator.service.ts +63 -51
  169. package/src/storage/attachment-parser.ts +69 -27
  170. package/src/storage/attachment-storage.service.ts +331 -275
  171. package/src/storage/generated-document-storage.service.ts +66 -34
  172. package/src/system-agents/agent-result.ts +3 -1
  173. package/src/system-agents/context-compaction.agent.ts +2 -2
  174. package/src/system-agents/delegated-agent-factory.ts +159 -90
  175. package/src/system-agents/memory-reranker.agent.ts +2 -2
  176. package/src/system-agents/memory.agent.ts +2 -2
  177. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  178. package/src/system-agents/regular-chat-memory-digest.agent.ts +2 -2
  179. package/src/system-agents/skill-extractor.agent.ts +2 -2
  180. package/src/system-agents/skill-manager.agent.ts +2 -2
  181. package/src/system-agents/thread-router.agent.ts +157 -113
  182. package/src/system-agents/title-generator.agent.ts +2 -2
  183. package/src/tools/execution-plan.tool.ts +220 -161
  184. package/src/tools/fetch-webpage.tool.ts +21 -17
  185. package/src/tools/firecrawl-client.ts +16 -6
  186. package/src/tools/index.ts +1 -0
  187. package/src/tools/memory-block.tool.ts +14 -6
  188. package/src/tools/plan-approval.tool.ts +49 -47
  189. package/src/tools/read-file-parts.tool.ts +44 -33
  190. package/src/tools/remember-memory.tool.ts +65 -45
  191. package/src/tools/search-web.tool.ts +26 -22
  192. package/src/tools/search.tool.ts +41 -29
  193. package/src/tools/team-think.tool.ts +124 -83
  194. package/src/tools/user-questions.tool.ts +4 -3
  195. package/src/tools/web-tool-shared.ts +6 -0
  196. package/src/utils/async.ts +17 -23
  197. package/src/utils/crypto.ts +21 -0
  198. package/src/utils/date-time.ts +40 -1
  199. package/src/utils/errors.ts +95 -16
  200. package/src/utils/hono-error-handler.ts +24 -39
  201. package/src/utils/index.ts +2 -1
  202. package/src/utils/null-proto-record.ts +41 -0
  203. package/src/utils/sse-keepalive.ts +124 -21
  204. package/src/workers/bootstrap.ts +186 -51
  205. package/src/workers/memory-consolidation.worker.ts +325 -237
  206. package/src/workers/organization-learning.worker.ts +50 -16
  207. package/src/workers/regular-chat-memory-digest.helpers.ts +28 -27
  208. package/src/workers/regular-chat-memory-digest.runner.ts +175 -114
  209. package/src/workers/skill-extraction.runner.ts +176 -93
  210. package/src/workers/utils/file-section-chunker.ts +8 -10
  211. package/src/workers/utils/repo-structure-extractor.ts +349 -260
  212. package/src/workers/utils/repomix-file-sections.ts +2 -2
  213. package/src/workers/utils/thread-message-query.ts +97 -38
  214. package/src/workers/worker-utils.ts +56 -31
  215. package/src/config/debug-logger.ts +0 -47
  216. package/src/redis/connection-accessor.ts +0 -26
  217. package/src/runtime/context-compaction-runtime.ts +0 -87
  218. package/src/runtime/social-chat-agent-runner.ts +0 -118
  219. package/src/runtime/social-chat.ts +0 -516
  220. package/src/runtime/team-consultation-orchestrator.ts +0 -272
  221. package/src/services/adaptive-playbook.service.ts +0 -152
  222. package/src/services/artifact-provenance.service.ts +0 -172
  223. package/src/services/chat-attachments.service.ts +0 -17
  224. package/src/services/context-compaction-runtime.singleton.ts +0 -13
  225. package/src/services/execution-plan.service.ts +0 -1118
  226. package/src/services/memory.service.ts +0 -914
  227. package/src/services/plan-agent-heartbeat.service.ts +0 -136
  228. package/src/services/plan-agent-query.service.ts +0 -267
  229. package/src/services/plan-approval.service.ts +0 -83
  230. package/src/services/plan-artifact.service.ts +0 -50
  231. package/src/services/plan-builder.service.ts +0 -67
  232. package/src/services/plan-checkpoint.service.ts +0 -81
  233. package/src/services/plan-completion-side-effects.ts +0 -80
  234. package/src/services/plan-coordination.service.ts +0 -157
  235. package/src/services/plan-cycle.service.ts +0 -284
  236. package/src/services/plan-deadline.service.ts +0 -430
  237. package/src/services/plan-event-delivery.service.ts +0 -166
  238. package/src/services/plan-executor.service.ts +0 -1950
  239. package/src/services/plan-run.service.ts +0 -515
  240. package/src/services/plan-scheduler.service.ts +0 -240
  241. package/src/services/plan-template.service.ts +0 -177
  242. package/src/services/plan-validator.service.ts +0 -818
  243. package/src/services/plan-workspace.service.ts +0 -83
  244. package/src/services/rerank.service.ts +0 -156
  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
+ }