@lota-sdk/core 0.2.2 → 0.3.0

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 (102) hide show
  1. package/infrastructure/schema/00_identity.surql +2 -2
  2. package/infrastructure/schema/00_thread.surql +75 -0
  3. package/infrastructure/schema/02_execution_plan.surql +10 -11
  4. package/infrastructure/schema/10_autonomous_job.surql +3 -3
  5. package/package.json +2 -2
  6. package/src/ai/definitions.ts +1 -1
  7. package/src/config/agent-defaults.ts +5 -5
  8. package/src/config/index.ts +1 -1
  9. package/src/config/thread-defaults.ts +72 -0
  10. package/src/create-runtime.ts +89 -93
  11. package/src/db/tables.ts +3 -3
  12. package/src/db/{workstream-message-row.ts → thread-message-row.ts} +3 -3
  13. package/src/queues/context-compaction.queue.ts +6 -6
  14. package/src/queues/plan-agent-heartbeat.queue.ts +3 -3
  15. package/src/queues/post-chat-memory.queue.ts +1 -1
  16. package/src/queues/title-generation.queue.ts +10 -13
  17. package/src/redis/index.ts +1 -1
  18. package/src/redis/stream-context.ts +1 -1
  19. package/src/runtime/agent-identity-overrides.ts +1 -1
  20. package/src/runtime/agent-runtime-policy.ts +19 -21
  21. package/src/runtime/chat-request-routing.ts +1 -1
  22. package/src/runtime/context-compaction-constants.ts +1 -1
  23. package/src/runtime/context-compaction.ts +1 -1
  24. package/src/runtime/execution-plan.ts +1 -1
  25. package/src/runtime/index.ts +1 -1
  26. package/src/runtime/memory-digest-policy.ts +1 -1
  27. package/src/runtime/plugin-types.ts +1 -1
  28. package/src/runtime/post-turn-side-effects.ts +35 -35
  29. package/src/runtime/runtime-config.ts +12 -12
  30. package/src/runtime/runtime-extensions.ts +11 -11
  31. package/src/runtime/social-chat-agent-runner.ts +3 -3
  32. package/src/runtime/social-chat-history.ts +1 -1
  33. package/src/runtime/social-chat.ts +6 -6
  34. package/src/runtime/team-consultation-orchestrator.ts +1 -1
  35. package/src/runtime/{workstream-chat-helpers.ts → thread-chat-helpers.ts} +7 -7
  36. package/src/runtime/{workstream-plan-turn.ts → thread-plan-turn.ts} +11 -17
  37. package/src/runtime/{workstream-turn-context.ts → thread-turn-context.ts} +10 -10
  38. package/src/services/agent-activity.service.ts +39 -44
  39. package/src/services/agent-executor.service.ts +17 -19
  40. package/src/services/attachment.service.ts +4 -8
  41. package/src/services/autonomous-job.service.ts +29 -28
  42. package/src/services/context-compaction.service.ts +19 -29
  43. package/src/services/execution-plan.service.ts +58 -70
  44. package/src/services/global-orchestrator.service.ts +5 -5
  45. package/src/services/index.ts +6 -6
  46. package/src/services/memory.service.ts +1 -1
  47. package/src/services/monitoring-window.service.ts +2 -2
  48. package/src/services/mutating-approval.service.ts +7 -10
  49. package/src/services/node-workspace.service.ts +8 -7
  50. package/src/services/notification.service.ts +1 -1
  51. package/src/services/organization.service.ts +9 -9
  52. package/src/services/ownership-dispatcher.service.ts +13 -19
  53. package/src/services/plan-agent-heartbeat.service.ts +13 -13
  54. package/src/services/plan-agent-query.service.ts +7 -7
  55. package/src/services/plan-artifact.service.ts +1 -2
  56. package/src/services/plan-coordination.service.ts +4 -4
  57. package/src/services/plan-cycle.service.ts +7 -7
  58. package/src/services/plan-deadline.service.ts +4 -4
  59. package/src/services/plan-event-delivery.service.ts +8 -12
  60. package/src/services/plan-executor.service.ts +16 -37
  61. package/src/services/plan-run-data.ts +27 -8
  62. package/src/services/plan-run.service.ts +7 -9
  63. package/src/services/plan-scheduler.service.ts +4 -4
  64. package/src/services/plan-template.service.ts +2 -2
  65. package/src/services/plan-validator.service.ts +0 -11
  66. package/src/services/plugin-executor.service.ts +1 -1
  67. package/src/services/queue-job.service.ts +1 -1
  68. package/src/services/recent-activity-title.service.ts +1 -1
  69. package/src/services/recent-activity.service.ts +4 -4
  70. package/src/services/system-executor.service.ts +2 -2
  71. package/src/services/{workstream-message.service.ts → thread-message.service.ts} +72 -76
  72. package/src/services/thread-plan-registry.service.ts +22 -0
  73. package/src/services/thread-title.service.ts +39 -0
  74. package/src/services/{workstream-turn-preparation.service.ts → thread-turn-preparation.service.ts} +131 -143
  75. package/src/services/{workstream-turn.ts → thread-turn.ts} +27 -31
  76. package/src/services/thread.service.ts +707 -0
  77. package/src/services/thread.types.ts +17 -0
  78. package/src/storage/attachment-storage.service.ts +4 -4
  79. package/src/system-agents/index.ts +1 -1
  80. package/src/system-agents/memory.agent.ts +1 -1
  81. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -2
  82. package/src/system-agents/regular-chat-memory-digest.agent.ts +1 -1
  83. package/src/system-agents/researcher.agent.ts +3 -3
  84. package/src/system-agents/{workstream-router.agent.ts → thread-router.agent.ts} +21 -21
  85. package/src/system-agents/title-generator.agent.ts +8 -8
  86. package/src/tools/execution-plan.tool.ts +39 -40
  87. package/src/tools/memory-block.tool.ts +4 -4
  88. package/src/tools/research-topic.tool.ts +1 -0
  89. package/src/tools/search-web.tool.ts +1 -1
  90. package/src/tools/search.tool.ts +4 -4
  91. package/src/tools/team-think.tool.ts +9 -9
  92. package/src/workers/regular-chat-memory-digest.helpers.ts +1 -1
  93. package/src/workers/regular-chat-memory-digest.runner.ts +43 -43
  94. package/src/workers/skill-extraction.runner.ts +9 -13
  95. package/src/workers/utils/{workstream-message-query.ts → thread-message-query.ts} +21 -21
  96. package/infrastructure/schema/00_workstream.surql +0 -64
  97. package/src/config/workstream-defaults.ts +0 -72
  98. package/src/services/workstream-plan-registry.service.ts +0 -22
  99. package/src/services/workstream-title.service.ts +0 -42
  100. package/src/services/workstream.service.ts +0 -803
  101. package/src/services/workstream.types.ts +0 -17
  102. /package/src/services/{workstream-constants.ts → thread-constants.ts} +0 -0
@@ -1,803 +0,0 @@
1
- import { WORKSTREAM, sdkWorkstreamStatusSchema } from '@lota-sdk/shared'
2
- import { BoundQuery, RecordId, StringRecordId, surql } from 'surrealdb'
3
-
4
- import { agentDisplayNames, agentRoster, getCoreWorkstreamProfile, isAgentName } from '../config/agent-defaults'
5
- import { serverLogger } from '../config/logger'
6
- import { getWorkstreamBootstrapConfig } from '../config/workstream-defaults'
7
- import { BaseService } from '../db/base.service'
8
- import { ensureRecordId, recordIdToString } from '../db/record-id'
9
- import type { RecordIdInput, RecordIdRef } from '../db/record-id'
10
- import { databaseService } from '../db/service'
11
- import type { DatabaseTable } from '../db/tables'
12
- import { TABLES } from '../db/tables'
13
- import { getRedisConnection, withRedisLeaseLock } from '../redis'
14
- import {
15
- appendToMemoryBlock,
16
- compactMemoryBlockEntries,
17
- formatPersistedMemoryBlockForPrompt,
18
- parseMemoryBlock,
19
- serializeMemoryBlock,
20
- } from '../runtime/memory-block'
21
- import { toIsoDateTimeString } from '../utils/date-time'
22
- import { chatRunRegistry } from './chat-run-registry.service'
23
- import { contextCompactionService } from './context-compaction.service'
24
- import { MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES, MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES } from './workstream-constants'
25
- import { workstreamMessageService } from './workstream-message.service'
26
- import { NormalizedWorkstreamSchema, PublicWorkstreamSchema, WorkstreamSchema } from './workstream.types'
27
- import type { NormalizedWorkstream, PublicWorkstream, WorkstreamRecord } from './workstream.types'
28
-
29
- // Uses SurrealQL directly to keep pagination/order logic close to queries.
30
-
31
- const WORKSTREAM_ACTIVE_RUN_LOCK_TTL_MS = 90_000
32
- const WORKSTREAM_ACTIVE_RUN_LOCK_MAX_WAIT_MS = 750
33
- const WORKSTREAM_ACTIVE_RUN_LOCK_RETRY_DELAY_MS = 75
34
-
35
- function toSafeDirectIdSegment(value: string): string {
36
- return value.replace(/[^a-zA-Z0-9_-]/g, '_')
37
- }
38
-
39
- function toRecordIdValueString(value: RecordIdRef, fallbackTable: string): string {
40
- const canonical = recordIdToString(ensureRecordId(value, fallbackTable), fallbackTable)
41
- const prefix = `${fallbackTable}:`
42
- const withoutTable = canonical.startsWith(prefix) ? canonical.slice(prefix.length) : canonical
43
- const wrappedMatch = withoutTable.match(/^⟨(.+)⟩$/)
44
- return wrappedMatch ? wrappedMatch[1] : withoutTable
45
- }
46
-
47
- function isRecordIdInput(value: unknown): value is RecordIdInput {
48
- if (typeof value === 'string' || value instanceof RecordId || value instanceof StringRecordId) {
49
- return true
50
- }
51
-
52
- if (!value || typeof value !== 'object') {
53
- return false
54
- }
55
-
56
- const record = value as { tb?: unknown; id?: unknown }
57
- return typeof record.tb === 'string' && record.id !== undefined
58
- }
59
-
60
- function getAgentDisplayName(agentId: string): string {
61
- return agentDisplayNames[agentId] ?? agentId
62
- }
63
-
64
- function requireDirectAgentId(agentId: string | undefined): string {
65
- if (!agentId) {
66
- throw new Error('Direct workstreams require an agentId')
67
- }
68
-
69
- return agentId
70
- }
71
-
72
- function requireString(coreType: string | undefined): string {
73
- if (!coreType) {
74
- throw new Error('Core workstreams require a coreType')
75
- }
76
-
77
- return coreType
78
- }
79
-
80
- function buildActiveRunLockKey(workstreamId: RecordIdRef): string {
81
- return `workstream-active-run:${recordIdToString(ensureRecordId(workstreamId, TABLES.WORKSTREAM), TABLES.WORKSTREAM)}`
82
- }
83
-
84
- function buildListWorkstreamsQuery(options: { includeArchived: boolean; paginate: boolean }): string {
85
- const clauses = [
86
- `SELECT * FROM ${TABLES.WORKSTREAM}`,
87
- 'WHERE userId = $userId',
88
- ' AND organizationId = $orgId',
89
- ' AND mode = $mode',
90
- ' AND core = $core',
91
- ]
92
- if (!options.includeArchived) {
93
- clauses.push(' AND status = "regular"')
94
- }
95
- clauses.push('ORDER BY updatedAt DESC')
96
- if (options.paginate) {
97
- clauses.push('LIMIT $limit START $offset')
98
- }
99
- return clauses.join('\n')
100
- }
101
-
102
- function normalizeActiveTurnValue(value: unknown): string | null {
103
- if (typeof value !== 'string') {
104
- return null
105
- }
106
-
107
- const normalized = value.trim()
108
- return normalized.length > 0 ? normalized : null
109
- }
110
-
111
- export class ActiveWorkstreamRunConflictError extends Error {
112
- constructor() {
113
- super('A chat run is already active.')
114
- this.name = 'ActiveWorkstreamRunConflictError'
115
- }
116
- }
117
-
118
- function buildDirectWorkstreamId({
119
- userId,
120
- orgId,
121
- agentId,
122
- }: {
123
- userId: RecordIdRef
124
- orgId: RecordIdRef
125
- agentId: string
126
- }): RecordId {
127
- const userValue = toSafeDirectIdSegment(toRecordIdValueString(userId, TABLES.USER))
128
- const orgValue = toSafeDirectIdSegment(toRecordIdValueString(orgId, TABLES.ORGANIZATION))
129
- return new RecordId(TABLES.WORKSTREAM, `direct_${agentId}_user_${userValue}_organization_${orgValue}`)
130
- }
131
-
132
- function buildCoreWorkstreamId({
133
- userId,
134
- orgId,
135
- coreType,
136
- }: {
137
- userId: RecordIdRef
138
- orgId: RecordIdRef
139
- coreType: string
140
- }): RecordId {
141
- const userValue = toSafeDirectIdSegment(toRecordIdValueString(userId, TABLES.USER))
142
- const orgValue = toSafeDirectIdSegment(toRecordIdValueString(orgId, TABLES.ORGANIZATION))
143
- const typeValue = toSafeDirectIdSegment(coreType)
144
- return new RecordId(TABLES.WORKSTREAM, `core_${typeValue}_user_${userValue}_organization_${orgValue}`)
145
- }
146
-
147
- class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
148
- constructor() {
149
- super(TABLES.WORKSTREAM, WorkstreamSchema)
150
- }
151
-
152
- async createWorkstream(
153
- userId: RecordIdRef,
154
- orgId: RecordIdRef,
155
- options?: {
156
- title?: string
157
- mode?: string
158
- agentId?: string
159
- core?: boolean
160
- coreType?: string
161
- members?: string[]
162
- },
163
- ): Promise<NormalizedWorkstream> {
164
- const mode = options?.mode ?? 'group'
165
- const directAgentId = options?.agentId
166
- const core = options?.core === true
167
- const coreType = options?.coreType
168
-
169
- if (mode === 'direct' && !directAgentId) {
170
- throw new Error('Direct workstreams require an agentId')
171
- }
172
- if (mode === 'group' && directAgentId) {
173
- throw new Error('Group workstreams cannot set agentId')
174
- }
175
- if (mode === 'direct' && core) {
176
- throw new Error('Direct workstreams cannot be core workstreams')
177
- }
178
- if (core && mode !== 'group') {
179
- throw new Error('Core workstreams must use group mode')
180
- }
181
- if (core && !coreType) {
182
- throw new Error('Core workstreams require a coreType')
183
- }
184
- if (!core && coreType) {
185
- throw new Error('Only core workstreams can set a coreType')
186
- }
187
- const title = (() => {
188
- if (options?.title) {
189
- return options.title
190
- }
191
- if (core) {
192
- return getCoreWorkstreamProfile(requireString(coreType)).config.title
193
- }
194
- if (mode === 'direct') {
195
- return getAgentDisplayName(requireDirectAgentId(directAgentId))
196
- }
197
- return WORKSTREAM.DEFAULT_TITLE
198
- })()
199
-
200
- if (mode === 'direct') {
201
- const agentId = requireDirectAgentId(directAgentId)
202
- const directWorkstreamId = buildDirectWorkstreamId({ userId, orgId, agentId })
203
- const workstream = await this.upsertDeterministicWorkstream(directWorkstreamId, {
204
- userId,
205
- organizationId: orgId,
206
- mode,
207
- core: false,
208
- agentId,
209
- members: [agentId],
210
- title,
211
- status: 'regular',
212
- nameGenerated: true,
213
- })
214
- return await this.toNormalizedWorkstream(workstream)
215
- }
216
-
217
- if (core) {
218
- const resolvedCoreType = requireString(coreType)
219
- const coreProfile = getCoreWorkstreamProfile(resolvedCoreType)
220
- const coreWorkstreamId = buildCoreWorkstreamId({ userId, orgId, coreType: resolvedCoreType })
221
- const workstream = await this.upsertDeterministicWorkstream(coreWorkstreamId, {
222
- userId,
223
- organizationId: orgId,
224
- mode,
225
- core: true,
226
- coreType: resolvedCoreType,
227
- agentId: coreProfile.config.agentId,
228
- members: [...coreProfile.members],
229
- title,
230
- status: 'regular',
231
- nameGenerated: true,
232
- })
233
- return await this.toNormalizedWorkstream(workstream)
234
- }
235
-
236
- const groupWorkstream = await this.create({
237
- userId,
238
- organizationId: orgId,
239
- mode,
240
- core: false,
241
- members: options?.members ?? [...agentRoster],
242
- title,
243
- status: 'regular',
244
- nameGenerated: options?.title !== undefined && options.title !== WORKSTREAM.DEFAULT_TITLE,
245
- })
246
-
247
- return await this.toNormalizedWorkstream(groupWorkstream)
248
- }
249
-
250
- async ensureBootstrapWorkstreams(
251
- userId: RecordIdRef,
252
- orgId: RecordIdRef,
253
- options?: { onboardStatus?: string; userName?: string | null },
254
- ): Promise<void> {
255
- const onboardStatus = options?.onboardStatus ?? 'completed'
256
- const onboardingCompleted = onboardStatus === 'completed'
257
- const bootstrapConfig = getWorkstreamBootstrapConfig()
258
-
259
- const existingWorkstreams = await databaseService.findMany(
260
- TABLES.WORKSTREAM,
261
- { userId, organizationId: orgId },
262
- WorkstreamSchema,
263
- )
264
-
265
- const hasStandardGroupWorkstream = existingWorkstreams.some(
266
- (workstream) => workstream.mode === 'group' && !workstream.core,
267
- )
268
- const directWorkstreamsByAgent = new Map<string, WorkstreamRecord>()
269
- const coreWorkstreamsByType = new Map<string, WorkstreamRecord>()
270
- for (const workstream of existingWorkstreams) {
271
- if (workstream.mode !== 'direct' || !workstream.agentId) continue
272
- directWorkstreamsByAgent.set(workstream.agentId, workstream)
273
- }
274
- for (const workstream of existingWorkstreams) {
275
- if (workstream.mode !== 'group' || !workstream.core) continue
276
- if (typeof workstream.coreType !== 'string') continue
277
- coreWorkstreamsByType.set(workstream.coreType, workstream)
278
- }
279
-
280
- const requiredDirectAgents = onboardingCompleted
281
- ? bootstrapConfig.completedDirectAgents
282
- : bootstrapConfig.onboardingDirectAgents
283
- const creations: Promise<NormalizedWorkstream>[] = []
284
- for (const agentId of requiredDirectAgents) {
285
- if (directWorkstreamsByAgent.has(agentId)) continue
286
- creations.push(
287
- this.createWorkstream(userId, orgId, { mode: 'direct', agentId, title: getAgentDisplayName(agentId) }),
288
- )
289
- }
290
-
291
- if (onboardingCompleted && bootstrapConfig.ensureDefaultGroupOnCompleted && !hasStandardGroupWorkstream) {
292
- creations.push(
293
- this.createWorkstream(userId, orgId, { mode: 'group', core: false, title: WORKSTREAM.DEFAULT_TITLE }),
294
- )
295
- }
296
-
297
- if (onboardingCompleted) {
298
- for (const coreType of bootstrapConfig.coreTypesAfterOnboarding) {
299
- if (coreWorkstreamsByType.has(coreType)) continue
300
- creations.push(
301
- this.createWorkstream(userId, orgId, {
302
- mode: 'group',
303
- core: true,
304
- coreType,
305
- title: getCoreWorkstreamProfile(coreType).config.title,
306
- }),
307
- )
308
- }
309
- }
310
-
311
- let createdWorkstreams: NormalizedWorkstream[] = []
312
- if (creations.length > 0) {
313
- createdWorkstreams = await Promise.all(creations)
314
- }
315
-
316
- const onboardingWelcome = bootstrapConfig.onboardingWelcome
317
- if (!onboardingCompleted && onboardingWelcome) {
318
- const createdOnboardingOwnerWorkstream = createdWorkstreams.find(
319
- (workstream) => workstream.mode === 'direct' && workstream.agentId === onboardingWelcome.directAgentId,
320
- )
321
- const existingOnboardingOwnerWorkstream = directWorkstreamsByAgent.get(onboardingWelcome.directAgentId)
322
-
323
- const onboardingOwnerWorkstreamId =
324
- createdOnboardingOwnerWorkstream?.id ??
325
- (existingOnboardingOwnerWorkstream ? this.normalizeWorkstreamId(existingOnboardingOwnerWorkstream.id) : null)
326
-
327
- if (onboardingOwnerWorkstreamId) {
328
- const onboardingOwnerWorkstreamRef = ensureRecordId(onboardingOwnerWorkstreamId, TABLES.WORKSTREAM)
329
- await workstreamMessageService.ensureBootstrapWelcomeMessage({
330
- workstreamId: onboardingOwnerWorkstreamRef,
331
- agentId: onboardingWelcome.directAgentId,
332
- text: onboardingWelcome.buildMessageText({ userName: options?.userName }),
333
- })
334
- }
335
- }
336
- }
337
-
338
- async listWorkstreams(
339
- userId: RecordIdRef,
340
- orgId: RecordIdRef,
341
- options: { mode: string; core?: boolean; take?: number; page?: number; includeArchived?: boolean },
342
- ): Promise<{ workstreams: NormalizedWorkstream[]; hasMore: boolean }> {
343
- const core = options.core === true
344
- const includeArchived = options.includeArchived ?? false
345
- if (options.mode === 'direct' && core) {
346
- throw new Error('Direct workstreams cannot be queried as core workstreams')
347
- }
348
-
349
- if (options.mode === 'direct' || core) {
350
- const workstreams = await databaseService.queryMany<typeof WorkstreamSchema>(
351
- new BoundQuery(buildListWorkstreamsQuery({ includeArchived, paginate: false }), {
352
- userId,
353
- orgId,
354
- mode: options.mode,
355
- core,
356
- }),
357
- WorkstreamSchema,
358
- )
359
-
360
- return { workstreams: await this.toNormalizedWorkstreams(workstreams, { checkLease: false }), hasMore: false }
361
- }
362
-
363
- const take = options.take ?? WORKSTREAM.DEFAULT_PAGE_LIMIT
364
- const page = options.page ?? 1
365
- const workstreams = await databaseService.queryMany<typeof WorkstreamSchema>(
366
- new BoundQuery(buildListWorkstreamsQuery({ includeArchived, paginate: true }), {
367
- userId,
368
- orgId,
369
- mode: options.mode,
370
- core: false,
371
- limit: take + 1,
372
- offset: (page - 1) * take,
373
- }),
374
- WorkstreamSchema,
375
- )
376
-
377
- const hasMore = workstreams.length > take
378
- const sliced = hasMore ? workstreams.slice(0, take) : workstreams
379
-
380
- return { workstreams: await this.toNormalizedWorkstreams(sliced, { checkLease: false }), hasMore }
381
- }
382
-
383
- async listOrganizationWorkstreams(params: {
384
- orgId: RecordIdRef
385
- mode?: 'direct' | 'group'
386
- agentId?: string
387
- core?: boolean
388
- includeArchived?: boolean
389
- }): Promise<NormalizedWorkstream[]> {
390
- const whereClauses = ['organizationId = $orgId']
391
- const variables: Record<string, unknown> = { orgId: params.orgId }
392
-
393
- if (params.mode) {
394
- whereClauses.push('mode = $mode')
395
- variables.mode = params.mode
396
- }
397
-
398
- if (typeof params.core === 'boolean') {
399
- whereClauses.push('core = $core')
400
- variables.core = params.core
401
- }
402
-
403
- if (params.agentId) {
404
- whereClauses.push('agentId = $agentId')
405
- variables.agentId = params.agentId
406
- }
407
-
408
- if (params.includeArchived !== true) {
409
- whereClauses.push('status = "regular"')
410
- }
411
-
412
- const workstreams = await databaseService.queryMany<typeof WorkstreamSchema>(
413
- new BoundQuery(
414
- `SELECT * FROM ${TABLES.WORKSTREAM}
415
- WHERE ${whereClauses.join('\n AND ')}
416
- ORDER BY createdAt ASC, id ASC`,
417
- variables,
418
- ),
419
- WorkstreamSchema,
420
- )
421
-
422
- return await this.toNormalizedWorkstreams(workstreams, { checkLease: false })
423
- }
424
-
425
- async getWorkstream(workstreamId: RecordIdRef): Promise<NormalizedWorkstream> {
426
- const workstream = await this.getById(workstreamId)
427
- return await this.toNormalizedWorkstream(workstream)
428
- }
429
-
430
- async updateTitle(workstreamId: RecordIdRef, title: string): Promise<NormalizedWorkstream> {
431
- const existing = await this.getById(workstreamId)
432
- this.assertMutableWorkstream(existing, 'rename')
433
- const workstream = await this.update(workstreamId, { title, nameGenerated: true })
434
- return await this.toNormalizedWorkstream(workstream)
435
- }
436
-
437
- async updateStatus(workstreamId: RecordIdRef, status: string): Promise<NormalizedWorkstream> {
438
- const validStatus = sdkWorkstreamStatusSchema.parse(status)
439
- const existing = await this.getById(workstreamId)
440
- this.assertMutableWorkstream(existing, validStatus === 'archived' ? 'archive' : 'unarchive')
441
- const workstream = await this.update(workstreamId, { status: validStatus })
442
- return await this.toNormalizedWorkstream(workstream)
443
- }
444
-
445
- async setActiveTurn(workstreamId: RecordIdRef, runId: string, streamId?: string | null): Promise<void> {
446
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
447
- if (streamId === null || streamId === undefined) {
448
- await databaseService.query<unknown>(surql`
449
- UPDATE ONLY ${workstreamRef}
450
- SET activeRunId = ${runId},
451
- activeStreamId = NONE
452
- `)
453
- return
454
- }
455
-
456
- await databaseService.query<unknown>(surql`
457
- UPDATE ONLY ${workstreamRef}
458
- SET activeRunId = ${runId},
459
- activeStreamId = ${streamId}
460
- `)
461
- }
462
-
463
- async getActiveTurn(workstreamId: RecordIdRef): Promise<{ runId: string | null; streamId: string | null }> {
464
- const workstream = await this.getById(workstreamId)
465
- return {
466
- runId: normalizeActiveTurnValue(workstream.activeRunId),
467
- streamId: normalizeActiveTurnValue(workstream.activeStreamId),
468
- }
469
- }
470
-
471
- async getActiveRunId(workstreamId: RecordIdRef): Promise<string | null> {
472
- const { runId } = await this.getActiveTurn(workstreamId)
473
- return runId
474
- }
475
-
476
- async hasActiveRunLease(workstreamId: RecordIdRef): Promise<boolean> {
477
- const count = await getRedisConnection().exists(buildActiveRunLockKey(workstreamId))
478
- return count > 0
479
- }
480
-
481
- async withActiveRunLease<T>(workstreamId: RecordIdRef, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
482
- try {
483
- return await withRedisLeaseLock(
484
- {
485
- redis: getRedisConnection(),
486
- lockKey: buildActiveRunLockKey(workstreamId),
487
- lockTtlMs: WORKSTREAM_ACTIVE_RUN_LOCK_TTL_MS,
488
- retryDelayMs: WORKSTREAM_ACTIVE_RUN_LOCK_RETRY_DELAY_MS,
489
- maxWaitMs: WORKSTREAM_ACTIVE_RUN_LOCK_MAX_WAIT_MS,
490
- label: 'workstream active run',
491
- logger: serverLogger,
492
- },
493
- fn,
494
- )
495
- } catch (error) {
496
- if (error instanceof Error && error.message.startsWith('Timed out waiting for workstream active run')) {
497
- throw new ActiveWorkstreamRunConflictError()
498
- }
499
- throw error
500
- }
501
- }
502
-
503
- async getActiveStreamId(workstreamId: RecordIdRef): Promise<string | null> {
504
- const { streamId } = await this.getActiveTurn(workstreamId)
505
- return streamId
506
- }
507
-
508
- async clearActiveTurn(workstreamId: RecordIdRef, params: { runId: string; streamId?: string | null }): Promise<void> {
509
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
510
- const currentStreamId = params.streamId ?? null
511
- if (currentStreamId === null) {
512
- await databaseService.query(
513
- surql`UPDATE ONLY ${workstreamRef} SET activeRunId = NONE, activeStreamId = NONE WHERE activeRunId = ${params.runId}`,
514
- )
515
- return
516
- }
517
-
518
- await databaseService.query(surql`
519
- UPDATE ONLY ${workstreamRef}
520
- SET activeRunId = NONE,
521
- activeStreamId = NONE
522
- WHERE activeRunId = ${params.runId} AND activeStreamId = ${currentStreamId}
523
- `)
524
- }
525
-
526
- async clearStaleActiveRunIfMissingFromRegistry(workstreamId: RecordIdRef): Promise<boolean> {
527
- const { runId: activeRunId, streamId: activeStreamId } = await this.getActiveTurn(workstreamId)
528
- if (!activeRunId || (await this.hasActiveRunLease(workstreamId))) {
529
- return false
530
- }
531
-
532
- await this.clearActiveTurn(workstreamId, { runId: activeRunId, streamId: activeStreamId })
533
-
534
- serverLogger.warn`Cleared stale workstream run after lease expired: workstream=${recordIdToString(ensureRecordId(workstreamId, TABLES.WORKSTREAM), TABLES.WORKSTREAM)} run=${activeRunId}`
535
- return true
536
- }
537
-
538
- async stopActiveRun(workstreamId: RecordIdRef): Promise<boolean> {
539
- const { runId: activeRunId } = await this.getActiveTurn(workstreamId)
540
- if (!activeRunId) return false
541
-
542
- const stopped = chatRunRegistry.stop(activeRunId, new DOMException('Run stopped by user.', 'AbortError'))
543
- if (stopped) {
544
- return true
545
- }
546
-
547
- await this.clearStaleActiveRunIfMissingFromRegistry(workstreamId)
548
- return false
549
- }
550
-
551
- async setCompacting(workstreamId: RecordIdRef, value: boolean): Promise<void> {
552
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
553
- await databaseService.query<unknown>(surql`
554
- UPDATE ONLY ${workstreamRef}
555
- SET isCompacting = ${value}
556
- `)
557
- }
558
-
559
- async appendMemoryBlock(workstreamId: RecordIdRef, entry: string): Promise<string> {
560
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
561
- const workstream = await this.getById(workstreamRef)
562
- const entries = parseMemoryBlock(workstream.memoryBlock)
563
-
564
- const labelMatch = entry.match(/^(\w+):\s*/i)
565
- const role = labelMatch ? labelMatch[1].toLowerCase() : 'system'
566
- const content = labelMatch ? entry.slice(labelMatch[0].length).trim() : entry.trim()
567
-
568
- const updatedEntries = appendToMemoryBlock(entries, role, content)
569
- const serialized = serializeMemoryBlock(updatedEntries)
570
-
571
- await this.update(workstreamRef, { memoryBlock: serialized })
572
-
573
- if (updatedEntries.length >= MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES) {
574
- void this.compactMemoryBlock(workstreamRef).catch((err: unknown) => {
575
- serverLogger.warn`Memory block compaction failed for ${workstreamRef}: ${err}`
576
- })
577
- }
578
-
579
- return this.formatMemoryBlockForPrompt({
580
- memoryBlock: serialized,
581
- memoryBlockSummary: workstream.memoryBlockSummary,
582
- })
583
- }
584
-
585
- async compactMemoryBlock(workstreamId: RecordIdRef): Promise<boolean> {
586
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
587
- const workstream = await this.getById(workstreamRef)
588
- const result = await compactMemoryBlockEntries({
589
- previousSummary: workstream.memoryBlockSummary,
590
- entries: parseMemoryBlock(workstream.memoryBlock),
591
- triggerEntries: MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES,
592
- chunkEntries: MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES,
593
- compact: (params) => contextCompactionService.compactMemoryBlock(params),
594
- })
595
-
596
- if (!result.compacted) return false
597
-
598
- await this.update(workstreamRef, {
599
- memoryBlockSummary: result.summary || '',
600
- memoryBlock: serializeMemoryBlock(result.entries),
601
- })
602
-
603
- return true
604
- }
605
-
606
- async deleteWorkstream(workstreamId: RecordIdRef): Promise<void> {
607
- const existing = await this.getById(workstreamId)
608
- this.assertMutableWorkstream(existing, 'delete')
609
- await this.delete(workstreamId)
610
- }
611
-
612
- async listRecentWorkstreams({
613
- userId,
614
- orgId,
615
- excludeWorkstreamId,
616
- limit,
617
- }: {
618
- userId: RecordIdRef
619
- orgId: RecordIdRef
620
- excludeWorkstreamId?: RecordIdRef
621
- limit: number
622
- }): Promise<NormalizedWorkstream[]> {
623
- let excludeCondition = ''
624
- const vars: Record<string, unknown> = { userId, orgId, limit }
625
-
626
- if (excludeWorkstreamId) {
627
- excludeCondition = 'AND id != $excludeWorkstreamId'
628
- vars.excludeWorkstreamId = excludeWorkstreamId
629
- }
630
-
631
- const workstreams = await databaseService.queryMany<typeof WorkstreamSchema>(
632
- new BoundQuery(
633
- `SELECT * FROM ${TABLES.WORKSTREAM}
634
- WHERE userId = $userId
635
- AND organizationId = $orgId
636
- ${excludeCondition}
637
- AND status != "archived"
638
- ORDER BY updatedAt DESC
639
- LIMIT $limit`,
640
- vars,
641
- ),
642
- WorkstreamSchema,
643
- )
644
-
645
- return await this.toNormalizedWorkstreams(workstreams, { checkLease: false })
646
- }
647
-
648
- private normalizeWorkstreamId(id: unknown): string {
649
- return this.normalizeRecordIdString(id, TABLES.WORKSTREAM)
650
- }
651
-
652
- private normalizeRecordIdString(id: unknown, table: DatabaseTable): string {
653
- if (!isRecordIdInput(id)) {
654
- throw new Error(`Invalid record id for table ${table}`)
655
- }
656
-
657
- return recordIdToString(id, table)
658
- }
659
-
660
- formatMemoryBlockForPrompt(workstream: Pick<WorkstreamRecord, 'memoryBlock' | 'memoryBlockSummary'>): string {
661
- return formatPersistedMemoryBlockForPrompt({
662
- summary: workstream.memoryBlockSummary,
663
- entries: parseMemoryBlock(workstream.memoryBlock),
664
- })
665
- }
666
-
667
- private getDefaultTitle(workstream: Pick<WorkstreamRecord, 'core' | 'coreType'>): string {
668
- if (workstream.core && typeof workstream.coreType === 'string') {
669
- return getCoreWorkstreamProfile(workstream.coreType).config.title
670
- }
671
-
672
- return WORKSTREAM.DEFAULT_TITLE
673
- }
674
-
675
- private async computeIsRunning(
676
- workstream: Pick<WorkstreamRecord, 'id' | 'activeRunId'>,
677
- options: { checkLease: boolean },
678
- ): Promise<boolean> {
679
- const activeRunId =
680
- typeof workstream.activeRunId === 'string' && workstream.activeRunId.trim().length > 0
681
- ? workstream.activeRunId
682
- : null
683
-
684
- if (activeRunId === null) {
685
- return false
686
- }
687
-
688
- if (chatRunRegistry.has(activeRunId)) {
689
- return true
690
- }
691
-
692
- if (!options.checkLease) {
693
- return true
694
- }
695
-
696
- return await this.hasActiveRunLease(ensureRecordId(workstream.id, TABLES.WORKSTREAM))
697
- }
698
-
699
- private async toNormalizedWorkstream(
700
- workstream: WorkstreamRecord,
701
- options: { checkLease?: boolean } = {},
702
- ): Promise<NormalizedWorkstream> {
703
- const isRunning = await this.computeIsRunning(workstream, { checkLease: options.checkLease ?? true })
704
- const isCompacting = workstream.isCompacting === true
705
- const mode = workstream.mode
706
- const core = workstream.core
707
- const coreType = core && typeof workstream.coreType === 'string' ? workstream.coreType : undefined
708
- const status = workstream.status
709
- return NormalizedWorkstreamSchema.parse({
710
- id: this.normalizeWorkstreamId(workstream.id),
711
- userId: this.normalizeRecordIdString(workstream.userId, TABLES.USER),
712
- organizationId: this.normalizeRecordIdString(workstream.organizationId, TABLES.ORGANIZATION),
713
- mode,
714
- core,
715
- ...(coreType ? { coreType } : {}),
716
- nameGenerated: workstream.nameGenerated,
717
- isRunning,
718
- isCompacting,
719
- ...(isAgentName(workstream.agentId) ? { agentId: workstream.agentId } : {}),
720
- title: workstream.title ?? this.getDefaultTitle(workstream),
721
- status,
722
- memoryBlock: this.formatMemoryBlockForPrompt(workstream),
723
- createdAt: toIsoDateTimeString(workstream.createdAt),
724
- updatedAt: toIsoDateTimeString(workstream.updatedAt),
725
- })
726
- }
727
-
728
- private async toNormalizedWorkstreams(
729
- workstreams: WorkstreamRecord[],
730
- options: { checkLease?: boolean } = {},
731
- ): Promise<NormalizedWorkstream[]> {
732
- return await Promise.all(
733
- workstreams.map(async (workstream) => await this.toNormalizedWorkstream(workstream, options)),
734
- )
735
- }
736
-
737
- toPublicWorkstream(workstream: NormalizedWorkstream): PublicWorkstream {
738
- const {
739
- organizationId: _organizationId,
740
- userId: _userId,
741
- memoryBlock: _memoryBlock,
742
- ...publicWorkstream
743
- } = workstream
744
- return PublicWorkstreamSchema.parse(publicWorkstream)
745
- }
746
-
747
- async incrementTurnCount(workstreamId: RecordIdRef): Promise<number> {
748
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
749
- const result = await databaseService.query<{ turnCount: number }>(surql`
750
- UPDATE ONLY ${workstreamRef}
751
- SET turnCount += 1
752
- RETURN turnCount
753
- `)
754
- return result[0].turnCount
755
- }
756
-
757
- private assertMutableWorkstream(
758
- workstream: WorkstreamRecord,
759
- action: 'rename' | 'archive' | 'unarchive' | 'delete',
760
- ): void {
761
- if (workstream.mode === 'direct') {
762
- throw new Error(`Direct workstreams cannot be ${action}d`)
763
- }
764
- if (workstream.core) {
765
- throw new Error(`Core workstreams cannot be ${action}d`)
766
- }
767
- }
768
-
769
- private async upsertDeterministicWorkstream(
770
- workstreamId: RecordIdRef,
771
- data: Record<string, unknown>,
772
- ): Promise<WorkstreamRecord> {
773
- const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
774
- const existing = await this.findById(workstreamRef)
775
- if (existing) {
776
- return existing
777
- }
778
-
779
- let createError: unknown = null
780
- let workstream = await databaseService
781
- .createWithId(TABLES.WORKSTREAM, workstreamRef, data, WorkstreamSchema)
782
- .catch((error) => {
783
- createError = error
784
- return null
785
- })
786
-
787
- if (!workstream) {
788
- workstream = await this.findById(workstreamRef)
789
- }
790
-
791
- if (workstream) {
792
- return workstream
793
- }
794
-
795
- if (createError instanceof Error) {
796
- throw createError
797
- }
798
-
799
- throw new Error('Failed to create or load deterministic workstream')
800
- }
801
- }
802
-
803
- export const workstreamService = new WorkstreamService()