@lota-sdk/core 0.1.19 → 0.1.20
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.
- package/infrastructure/schema/09_queue_job.surql +38 -0
- package/infrastructure/schema/10_autonomous_job.surql +44 -0
- package/package.json +2 -2
- package/src/ai-gateway/ai-gateway.ts +55 -16
- package/src/create-runtime.ts +10 -1
- package/src/db/base.service.ts +6 -1
- package/src/db/tables.ts +4 -0
- package/src/queues/autonomous-job.queue.ts +134 -0
- package/src/queues/document-processor.queue.ts +13 -2
- package/src/queues/index.ts +1 -0
- package/src/queues/memory-consolidation.queue.ts +22 -3
- package/src/queues/queue-factory.ts +33 -4
- package/src/runtime/runtime-worker-registry.ts +3 -0
- package/src/services/autonomous-job.service.ts +692 -0
- package/src/services/index.ts +2 -0
- package/src/services/plan-deadline.service.ts +6 -4
- package/src/services/queue-job.service.ts +356 -0
- package/src/services/workstream-message.service.ts +25 -14
- package/src/services/workstream-turn-preparation.service.ts +18 -5
- package/src/services/workstream-turn.ts +11 -3
- package/src/workers/worker-utils.ts +35 -7
|
@@ -130,14 +130,15 @@ class PlanDeadlineService {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const nextTriggerAt
|
|
133
|
+
const nextTriggerAt =
|
|
134
134
|
sweep.entries
|
|
135
135
|
.map((entry) => entry.evaluation.nextTriggerAt)
|
|
136
136
|
.filter((value): value is Date => {
|
|
137
137
|
if (!value) return false
|
|
138
138
|
return value.getTime() > currentTime.getTime()
|
|
139
139
|
})
|
|
140
|
-
.sort((a, b) => a.getTime() - b.getTime())
|
|
140
|
+
.sort((a, b) => a.getTime() - b.getTime())
|
|
141
|
+
.at(0) ?? null
|
|
141
142
|
|
|
142
143
|
if (nextTriggerAt) {
|
|
143
144
|
await this.enqueueDeadlineCheck(nextTriggerAt)
|
|
@@ -152,14 +153,15 @@ class PlanDeadlineService {
|
|
|
152
153
|
return
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
const nextTriggerAt
|
|
156
|
+
const nextTriggerAt =
|
|
156
157
|
sweep.entries
|
|
157
158
|
.map((entry) => entry.evaluation.nextTriggerAt)
|
|
158
159
|
.filter((value): value is Date => {
|
|
159
160
|
if (!value) return false
|
|
160
161
|
return value.getTime() > now.getTime()
|
|
161
162
|
})
|
|
162
|
-
.sort((a, b) => a.getTime() - b.getTime())
|
|
163
|
+
.sort((a, b) => a.getTime() - b.getTime())
|
|
164
|
+
.at(0) ?? null
|
|
163
165
|
|
|
164
166
|
if (nextTriggerAt) {
|
|
165
167
|
await this.enqueueDeadlineCheck(nextTriggerAt)
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QueueJobAttemptStatusSchema,
|
|
3
|
+
QueueJobErrorSchema,
|
|
4
|
+
QueueJobStatusSchema,
|
|
5
|
+
recordIdSchema,
|
|
6
|
+
} from '@lota-sdk/shared'
|
|
7
|
+
import type { QueueJobError, QueueJobStatus } from '@lota-sdk/shared'
|
|
8
|
+
import { RecordId } from 'surrealdb'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
|
|
11
|
+
import { recordIdToString } from '../db/record-id'
|
|
12
|
+
import { databaseService } from '../db/service'
|
|
13
|
+
import { TABLES } from '../db/tables'
|
|
14
|
+
import { compactRecord, readRecord, readString, readStringField, stringifyUnknown, truncateText } from '../utils/string'
|
|
15
|
+
|
|
16
|
+
const QueueJobRowSchema = z.object({
|
|
17
|
+
id: recordIdSchema,
|
|
18
|
+
queueName: z.string(),
|
|
19
|
+
jobName: z.string(),
|
|
20
|
+
bullmqJobId: z.string(),
|
|
21
|
+
status: QueueJobStatusSchema,
|
|
22
|
+
data: z.object({ value: z.unknown() }).passthrough().optional(),
|
|
23
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
24
|
+
context: z.record(z.string(), z.unknown()).optional(),
|
|
25
|
+
deduplicationId: z.string().optional(),
|
|
26
|
+
schedulerId: z.string().optional(),
|
|
27
|
+
maxAttempts: z.number().int().nonnegative().optional(),
|
|
28
|
+
attemptCount: z.number().int().nonnegative().default(0),
|
|
29
|
+
result: z.object({ value: z.unknown() }).passthrough().optional(),
|
|
30
|
+
lastError: QueueJobErrorSchema.optional(),
|
|
31
|
+
queuedAt: z.coerce.date(),
|
|
32
|
+
startedAt: z.coerce.date().optional(),
|
|
33
|
+
completedAt: z.coerce.date().optional(),
|
|
34
|
+
failedAt: z.coerce.date().optional(),
|
|
35
|
+
createdAt: z.coerce.date(),
|
|
36
|
+
updatedAt: z.coerce.date(),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const QueueJobAttemptRowSchema = z.object({
|
|
40
|
+
id: recordIdSchema,
|
|
41
|
+
queueJobId: recordIdSchema,
|
|
42
|
+
attemptNumber: z.number().int().positive(),
|
|
43
|
+
status: QueueJobAttemptStatusSchema,
|
|
44
|
+
result: z.object({ value: z.unknown() }).passthrough().optional(),
|
|
45
|
+
error: QueueJobErrorSchema.optional(),
|
|
46
|
+
startedAt: z.coerce.date(),
|
|
47
|
+
completedAt: z.coerce.date().optional(),
|
|
48
|
+
durationMs: z.number().int().nonnegative().optional(),
|
|
49
|
+
createdAt: z.coerce.date(),
|
|
50
|
+
updatedAt: z.coerce.date(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export interface TrackedBullJobLike {
|
|
54
|
+
queueName: string
|
|
55
|
+
id?: string | number
|
|
56
|
+
name: string
|
|
57
|
+
data?: unknown
|
|
58
|
+
opts?: unknown
|
|
59
|
+
attemptsMade: number | null | undefined
|
|
60
|
+
timestamp?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildDeterministicRecordId(table: string, key: string): RecordId {
|
|
64
|
+
const digest = new Bun.CryptoHasher('sha256').update(key).digest('hex')
|
|
65
|
+
return new RecordId(table, digest)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sanitizeQueueValue(value: unknown, depth = 0): unknown {
|
|
69
|
+
if (value === null || value === undefined) return value
|
|
70
|
+
if (typeof value === 'string') return truncateText(value, 20_000)
|
|
71
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value
|
|
72
|
+
if (typeof value === 'bigint') return value.toString()
|
|
73
|
+
if (value instanceof Date) return value.toISOString()
|
|
74
|
+
|
|
75
|
+
if (depth >= 6) {
|
|
76
|
+
return stringifyUnknown(value) ?? '[unserializable]'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
return value.slice(0, 200).map((item) => sanitizeQueueValue(item, depth + 1))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const record = readRecord(value)
|
|
84
|
+
if (!record) {
|
|
85
|
+
return stringifyUnknown(value) ?? '[unserializable]'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Object.fromEntries(
|
|
89
|
+
Object.entries(record)
|
|
90
|
+
.slice(0, 200)
|
|
91
|
+
.map(([key, entry]) => [key, sanitizeQueueValue(entry, depth + 1)]),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function wrapFlexibleValue(value: unknown): Record<string, unknown> | undefined {
|
|
96
|
+
if (value === undefined) return undefined
|
|
97
|
+
return { value: sanitizeQueueValue(value) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toQueueJobError(error: unknown): QueueJobError {
|
|
101
|
+
if (error instanceof Error) {
|
|
102
|
+
const maybeCode = readString((error as Error & { code?: unknown }).code)
|
|
103
|
+
return QueueJobErrorSchema.parse(
|
|
104
|
+
compactRecord({
|
|
105
|
+
name: error.name || undefined,
|
|
106
|
+
message: truncateText(error.message || 'Unknown error', 5_000),
|
|
107
|
+
stack: typeof error.stack === 'string' ? truncateText(error.stack, 20_000) : undefined,
|
|
108
|
+
code: maybeCode,
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return QueueJobErrorSchema.parse({ message: truncateText(stringifyUnknown(error) ?? 'Unknown error', 5_000) })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getBullmqJobId(job: TrackedBullJobLike): string {
|
|
117
|
+
const id = job.id
|
|
118
|
+
if (typeof id === 'string' && id.length > 0) return id
|
|
119
|
+
if (typeof id === 'number') return String(id)
|
|
120
|
+
throw new Error(`BullMQ job for queue "${job.queueName}" is missing an id.`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getQueueJobRecordId(job: TrackedBullJobLike): RecordId {
|
|
124
|
+
return buildDeterministicRecordId(TABLES.QUEUE_JOB, `${job.queueName}:${getBullmqJobId(job)}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getQueueJobAttemptRecordId(job: TrackedBullJobLike, attemptNumber: number): RecordId {
|
|
128
|
+
return buildDeterministicRecordId(
|
|
129
|
+
TABLES.QUEUE_JOB_ATTEMPT,
|
|
130
|
+
`${job.queueName}:${getBullmqJobId(job)}:${attemptNumber}`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveAttemptNumber(job: TrackedBullJobLike): number {
|
|
135
|
+
return (job.attemptsMade ?? 0) + 1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractJobContext(data: unknown): Record<string, unknown> | undefined {
|
|
139
|
+
const record = readRecord(data)
|
|
140
|
+
if (!record) return undefined
|
|
141
|
+
|
|
142
|
+
const context = compactRecord({
|
|
143
|
+
organizationId: readStringField(record, 'organizationId') ?? readStringField(record, 'orgId'),
|
|
144
|
+
workstreamId: readStringField(record, 'workstreamId'),
|
|
145
|
+
userId: readStringField(record, 'userId'),
|
|
146
|
+
agentId: readStringField(record, 'agentId'),
|
|
147
|
+
sourceId: readStringField(record, 'sourceId'),
|
|
148
|
+
runId: readStringField(record, 'runId'),
|
|
149
|
+
nodeId: readStringField(record, 'nodeId'),
|
|
150
|
+
scheduleId: readStringField(record, 'scheduleId'),
|
|
151
|
+
scopeId: readStringField(record, 'scopeId'),
|
|
152
|
+
autonomousJobId: readStringField(record, 'autonomousJobId'),
|
|
153
|
+
autonomousJobRunId: readStringField(record, 'autonomousJobRunId'),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return Object.keys(context).length > 0 ? context : undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readDeduplicationId(job: TrackedBullJobLike): string | undefined {
|
|
160
|
+
const opts = readRecord(job.opts)
|
|
161
|
+
const deduplication = opts ? readRecord(opts.deduplication) : null
|
|
162
|
+
return deduplication ? readStringField(deduplication, 'id') : undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readMaxAttempts(job: TrackedBullJobLike): number | undefined {
|
|
166
|
+
const opts = readRecord(job.opts)
|
|
167
|
+
const attempts = opts?.attempts
|
|
168
|
+
return typeof attempts === 'number' && Number.isInteger(attempts) && attempts >= 0 ? attempts : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getQueuedStatus(job: TrackedBullJobLike): QueueJobStatus {
|
|
172
|
+
const opts = readRecord(job.opts)
|
|
173
|
+
const delay = opts?.delay
|
|
174
|
+
return typeof delay === 'number' && delay > 0 ? 'delayed' : 'waiting'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
class QueueJobService {
|
|
178
|
+
getQueueJobId(queueName: string, bullmqJobId: string): string {
|
|
179
|
+
return recordIdToString(
|
|
180
|
+
buildDeterministicRecordId(TABLES.QUEUE_JOB, `${queueName}:${bullmqJobId}`),
|
|
181
|
+
TABLES.QUEUE_JOB,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async recordEnqueued(job: TrackedBullJobLike, context?: Record<string, unknown>): Promise<string> {
|
|
186
|
+
await databaseService.connect()
|
|
187
|
+
|
|
188
|
+
const queueJobId = getQueueJobRecordId(job)
|
|
189
|
+
const queuedAt = typeof job.timestamp === 'number' ? new Date(job.timestamp) : new Date()
|
|
190
|
+
const mergedContext = compactRecord({ ...extractJobContext(job.data), ...context })
|
|
191
|
+
|
|
192
|
+
await databaseService.upsert(
|
|
193
|
+
TABLES.QUEUE_JOB,
|
|
194
|
+
queueJobId,
|
|
195
|
+
compactRecord({
|
|
196
|
+
queueName: job.queueName,
|
|
197
|
+
jobName: job.name,
|
|
198
|
+
bullmqJobId: getBullmqJobId(job),
|
|
199
|
+
status: getQueuedStatus(job),
|
|
200
|
+
data: wrapFlexibleValue(job.data),
|
|
201
|
+
options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
|
|
202
|
+
context: Object.keys(mergedContext).length > 0 ? sanitizeQueueValue(mergedContext) : undefined,
|
|
203
|
+
deduplicationId: readDeduplicationId(job),
|
|
204
|
+
maxAttempts: readMaxAttempts(job),
|
|
205
|
+
queuedAt,
|
|
206
|
+
}),
|
|
207
|
+
QueueJobRowSchema,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return recordIdToString(queueJobId, TABLES.QUEUE_JOB)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async markAttemptStarted(job: TrackedBullJobLike): Promise<string> {
|
|
214
|
+
await databaseService.connect()
|
|
215
|
+
|
|
216
|
+
const attemptNumber = resolveAttemptNumber(job)
|
|
217
|
+
const queueJobId = getQueueJobRecordId(job)
|
|
218
|
+
const startedAt = new Date()
|
|
219
|
+
|
|
220
|
+
await databaseService.upsert(
|
|
221
|
+
TABLES.QUEUE_JOB,
|
|
222
|
+
queueJobId,
|
|
223
|
+
compactRecord({
|
|
224
|
+
queueName: job.queueName,
|
|
225
|
+
jobName: job.name,
|
|
226
|
+
bullmqJobId: getBullmqJobId(job),
|
|
227
|
+
status: 'active',
|
|
228
|
+
data: wrapFlexibleValue(job.data),
|
|
229
|
+
options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
|
|
230
|
+
context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
|
|
231
|
+
deduplicationId: readDeduplicationId(job),
|
|
232
|
+
maxAttempts: readMaxAttempts(job),
|
|
233
|
+
attemptCount: attemptNumber,
|
|
234
|
+
queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : startedAt,
|
|
235
|
+
startedAt,
|
|
236
|
+
}),
|
|
237
|
+
QueueJobRowSchema,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
|
|
241
|
+
await databaseService.upsert(
|
|
242
|
+
TABLES.QUEUE_JOB_ATTEMPT,
|
|
243
|
+
attemptId,
|
|
244
|
+
{ queueJobId, attemptNumber, status: 'active', startedAt },
|
|
245
|
+
QueueJobAttemptRowSchema,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return recordIdToString(queueJobId, TABLES.QUEUE_JOB)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async markAttemptCompleted(job: TrackedBullJobLike, result: unknown): Promise<void> {
|
|
252
|
+
await databaseService.connect()
|
|
253
|
+
|
|
254
|
+
const attemptNumber = resolveAttemptNumber(job)
|
|
255
|
+
const queueJobId = getQueueJobRecordId(job)
|
|
256
|
+
const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
|
|
257
|
+
const completedAt = new Date()
|
|
258
|
+
const existingAttempt = await databaseService.findOne(
|
|
259
|
+
TABLES.QUEUE_JOB_ATTEMPT,
|
|
260
|
+
{ id: attemptId },
|
|
261
|
+
QueueJobAttemptRowSchema,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
await databaseService.upsert(
|
|
265
|
+
TABLES.QUEUE_JOB_ATTEMPT,
|
|
266
|
+
attemptId,
|
|
267
|
+
compactRecord({
|
|
268
|
+
queueJobId,
|
|
269
|
+
attemptNumber,
|
|
270
|
+
status: 'completed',
|
|
271
|
+
result: wrapFlexibleValue(result),
|
|
272
|
+
startedAt: existingAttempt?.startedAt ?? completedAt,
|
|
273
|
+
completedAt,
|
|
274
|
+
durationMs: existingAttempt ? Math.max(0, completedAt.getTime() - existingAttempt.startedAt.getTime()) : 0,
|
|
275
|
+
}),
|
|
276
|
+
QueueJobAttemptRowSchema,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
await databaseService.upsert(
|
|
280
|
+
TABLES.QUEUE_JOB,
|
|
281
|
+
queueJobId,
|
|
282
|
+
compactRecord({
|
|
283
|
+
queueName: job.queueName,
|
|
284
|
+
jobName: job.name,
|
|
285
|
+
bullmqJobId: getBullmqJobId(job),
|
|
286
|
+
status: 'completed',
|
|
287
|
+
data: wrapFlexibleValue(job.data),
|
|
288
|
+
options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
|
|
289
|
+
context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
|
|
290
|
+
deduplicationId: readDeduplicationId(job),
|
|
291
|
+
maxAttempts: readMaxAttempts(job),
|
|
292
|
+
attemptCount: attemptNumber,
|
|
293
|
+
queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : completedAt,
|
|
294
|
+
completedAt,
|
|
295
|
+
result: wrapFlexibleValue(result),
|
|
296
|
+
lastError: undefined,
|
|
297
|
+
}),
|
|
298
|
+
QueueJobRowSchema,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async markAttemptFailed(job: TrackedBullJobLike, error: unknown): Promise<void> {
|
|
303
|
+
await databaseService.connect()
|
|
304
|
+
|
|
305
|
+
const attemptNumber = resolveAttemptNumber(job)
|
|
306
|
+
const queueJobId = getQueueJobRecordId(job)
|
|
307
|
+
const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
|
|
308
|
+
const failedAt = new Date()
|
|
309
|
+
const existingAttempt = await databaseService.findOne(
|
|
310
|
+
TABLES.QUEUE_JOB_ATTEMPT,
|
|
311
|
+
{ id: attemptId },
|
|
312
|
+
QueueJobAttemptRowSchema,
|
|
313
|
+
)
|
|
314
|
+
const normalizedError = toQueueJobError(error)
|
|
315
|
+
const maxAttempts = readMaxAttempts(job)
|
|
316
|
+
const terminal = typeof maxAttempts === 'number' ? attemptNumber >= maxAttempts : true
|
|
317
|
+
|
|
318
|
+
await databaseService.upsert(
|
|
319
|
+
TABLES.QUEUE_JOB_ATTEMPT,
|
|
320
|
+
attemptId,
|
|
321
|
+
compactRecord({
|
|
322
|
+
queueJobId,
|
|
323
|
+
attemptNumber,
|
|
324
|
+
status: 'failed',
|
|
325
|
+
error: normalizedError,
|
|
326
|
+
startedAt: existingAttempt?.startedAt ?? failedAt,
|
|
327
|
+
completedAt: failedAt,
|
|
328
|
+
durationMs: existingAttempt ? Math.max(0, failedAt.getTime() - existingAttempt.startedAt.getTime()) : 0,
|
|
329
|
+
}),
|
|
330
|
+
QueueJobAttemptRowSchema,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
await databaseService.upsert(
|
|
334
|
+
TABLES.QUEUE_JOB,
|
|
335
|
+
queueJobId,
|
|
336
|
+
compactRecord({
|
|
337
|
+
queueName: job.queueName,
|
|
338
|
+
jobName: job.name,
|
|
339
|
+
bullmqJobId: getBullmqJobId(job),
|
|
340
|
+
status: terminal ? 'failed' : 'waiting',
|
|
341
|
+
data: wrapFlexibleValue(job.data),
|
|
342
|
+
options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
|
|
343
|
+
context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
|
|
344
|
+
deduplicationId: readDeduplicationId(job),
|
|
345
|
+
maxAttempts,
|
|
346
|
+
attemptCount: attemptNumber,
|
|
347
|
+
queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : failedAt,
|
|
348
|
+
failedAt: terminal ? failedAt : undefined,
|
|
349
|
+
lastError: normalizedError,
|
|
350
|
+
}),
|
|
351
|
+
QueueJobRowSchema,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export const queueJobService = new QueueJobService()
|
|
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
|
|
6
6
|
import { agentDisplayNames } from '../config/agent-defaults'
|
|
7
7
|
import { CursorRowSchema, listMessageHistoryPage } from '../db/cursor-pagination'
|
|
8
8
|
import type { CursorPaginationConfig, MessageHistoryPage } from '../db/cursor-pagination'
|
|
9
|
-
import { recordIdToString } from '../db/record-id'
|
|
9
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
10
10
|
import type { RecordIdRef } from '../db/record-id'
|
|
11
11
|
import { databaseService } from '../db/service'
|
|
12
12
|
import { TABLES } from '../db/tables'
|
|
@@ -32,6 +32,10 @@ function toWorkstreamMessageRowId(workstreamId: RecordIdRef, messageId: string):
|
|
|
32
32
|
return new RecordId(TABLES.WORKSTREAM_MESSAGE, digest)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function toWorkstreamRef(workstreamId: RecordIdRef): RecordId {
|
|
36
|
+
return ensureRecordId(workstreamId, TABLES.WORKSTREAM)
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
function toChatMessage(row: WorkstreamMessageRow): ChatMessage {
|
|
36
40
|
const rowCreatedAt = requireTimestamp(row.createdAt)
|
|
37
41
|
const metadata = withCreatedAtMetadata(parseRowMetadata(row.metadata), rowCreatedAt)
|
|
@@ -65,7 +69,7 @@ const workstreamPaginationConfig: CursorPaginationConfig = {
|
|
|
65
69
|
|
|
66
70
|
class WorkstreamMessageService {
|
|
67
71
|
async upsertMessages(params: { workstreamId: RecordIdRef; messages: ChatMessage[] }): Promise<void> {
|
|
68
|
-
const workstreamId = params.workstreamId
|
|
72
|
+
const workstreamId = toWorkstreamRef(params.workstreamId)
|
|
69
73
|
|
|
70
74
|
const upsertPromises = params.messages.map(async (message) => {
|
|
71
75
|
const messageId = message.id.trim()
|
|
@@ -108,9 +112,10 @@ class WorkstreamMessageService {
|
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
async listMessages(workstreamId: RecordIdRef): Promise<ChatMessage[]> {
|
|
115
|
+
const workstreamRef = toWorkstreamRef(workstreamId)
|
|
111
116
|
const rows = await databaseService.query<unknown>(surql`
|
|
112
117
|
SELECT * FROM workstreamMessage
|
|
113
|
-
WHERE workstreamId = ${
|
|
118
|
+
WHERE workstreamId = ${workstreamRef}
|
|
114
119
|
ORDER BY createdAt ASC, id ASC
|
|
115
120
|
`)
|
|
116
121
|
|
|
@@ -122,22 +127,24 @@ class WorkstreamMessageService {
|
|
|
122
127
|
take: number
|
|
123
128
|
beforeMessageId?: string
|
|
124
129
|
}): Promise<MessageHistoryPage> {
|
|
130
|
+
const workstreamRef = toWorkstreamRef(params.workstreamId)
|
|
125
131
|
return listMessageHistoryPage(workstreamPaginationConfig, {
|
|
126
|
-
parentId:
|
|
132
|
+
parentId: workstreamRef,
|
|
127
133
|
take: params.take,
|
|
128
134
|
beforeMessageId: params.beforeMessageId,
|
|
129
135
|
})
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
async listMessagesAfterCursor(workstreamId: RecordIdRef, afterMessageId?: string): Promise<ChatMessage[]> {
|
|
139
|
+
const workstreamRef = toWorkstreamRef(workstreamId)
|
|
133
140
|
const cursorMessageId = afterMessageId?.trim()
|
|
134
141
|
if (!cursorMessageId) {
|
|
135
|
-
return this.listMessages(
|
|
142
|
+
return this.listMessages(workstreamRef)
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
const cursorRow = await databaseService.findOne(
|
|
139
146
|
TABLES.WORKSTREAM_MESSAGE,
|
|
140
|
-
{ workstreamId, messageId: cursorMessageId },
|
|
147
|
+
{ workstreamId: workstreamRef, messageId: cursorMessageId },
|
|
141
148
|
CursorRowSchema,
|
|
142
149
|
)
|
|
143
150
|
|
|
@@ -146,10 +153,10 @@ class WorkstreamMessageService {
|
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
const cursorCreatedAt = cursorRow.createdAt
|
|
149
|
-
const cursorId = toWorkstreamMessageRowId(
|
|
156
|
+
const cursorId = toWorkstreamMessageRowId(workstreamRef, cursorMessageId)
|
|
150
157
|
const rows = await databaseService.query<unknown>(surql`
|
|
151
158
|
SELECT * FROM workstreamMessage
|
|
152
|
-
WHERE workstreamId = ${
|
|
159
|
+
WHERE workstreamId = ${workstreamRef}
|
|
153
160
|
AND (
|
|
154
161
|
createdAt > ${cursorCreatedAt}
|
|
155
162
|
OR (createdAt = ${cursorCreatedAt} AND id > ${cursorId})
|
|
@@ -161,9 +168,10 @@ class WorkstreamMessageService {
|
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
async listRecentMessages(workstreamId: RecordIdRef, limit: number): Promise<ChatMessage[]> {
|
|
171
|
+
const workstreamRef = toWorkstreamRef(workstreamId)
|
|
164
172
|
const rows = await databaseService.query<unknown>(surql`
|
|
165
173
|
SELECT * FROM workstreamMessage
|
|
166
|
-
WHERE workstreamId = ${
|
|
174
|
+
WHERE workstreamId = ${workstreamRef}
|
|
167
175
|
ORDER BY createdAt DESC, id DESC
|
|
168
176
|
LIMIT ${Math.max(1, limit)}
|
|
169
177
|
`)
|
|
@@ -183,7 +191,7 @@ class WorkstreamMessageService {
|
|
|
183
191
|
const normalizedQuery = params.query.trim().toLowerCase()
|
|
184
192
|
if (!normalizedQuery) return []
|
|
185
193
|
|
|
186
|
-
const messages = await this.listMessages(params.workstreamId)
|
|
194
|
+
const messages = await this.listMessages(toWorkstreamRef(params.workstreamId))
|
|
187
195
|
return messages
|
|
188
196
|
.filter((message) => message.role === params.role)
|
|
189
197
|
.map((message) => ({
|
|
@@ -204,6 +212,7 @@ class WorkstreamMessageService {
|
|
|
204
212
|
workstreamId: RecordIdRef
|
|
205
213
|
content: string
|
|
206
214
|
}): Promise<ChatMessage> {
|
|
215
|
+
const workstreamRef = toWorkstreamRef(params.workstreamId)
|
|
207
216
|
const message: ChatMessage = {
|
|
208
217
|
id: toMessageId(params.messageId),
|
|
209
218
|
role: 'user',
|
|
@@ -211,7 +220,7 @@ class WorkstreamMessageService {
|
|
|
211
220
|
metadata: { createdAt: Date.now() },
|
|
212
221
|
}
|
|
213
222
|
|
|
214
|
-
await this.upsertMessages({ workstreamId:
|
|
223
|
+
await this.upsertMessages({ workstreamId: workstreamRef, messages: [message] })
|
|
215
224
|
return message
|
|
216
225
|
}
|
|
217
226
|
|
|
@@ -221,6 +230,7 @@ class WorkstreamMessageService {
|
|
|
221
230
|
parts: ChatMessage['parts']
|
|
222
231
|
metadata?: ChatMessage['metadata']
|
|
223
232
|
}): Promise<ChatMessage> {
|
|
233
|
+
const workstreamRef = toWorkstreamRef(params.workstreamId)
|
|
224
234
|
const message: ChatMessage = {
|
|
225
235
|
id: toMessageId(params.messageId),
|
|
226
236
|
role: 'assistant',
|
|
@@ -228,7 +238,7 @@ class WorkstreamMessageService {
|
|
|
228
238
|
metadata: withCreatedAtMetadata(params.metadata, Date.now()),
|
|
229
239
|
}
|
|
230
240
|
|
|
231
|
-
await this.upsertMessages({ workstreamId:
|
|
241
|
+
await this.upsertMessages({ workstreamId: workstreamRef, messages: [message] })
|
|
232
242
|
return message
|
|
233
243
|
}
|
|
234
244
|
|
|
@@ -237,9 +247,10 @@ class WorkstreamMessageService {
|
|
|
237
247
|
agentId: string
|
|
238
248
|
text: string
|
|
239
249
|
}): Promise<void> {
|
|
250
|
+
const workstreamRef = toWorkstreamRef(params.workstreamId)
|
|
240
251
|
const existingRow = await databaseService.findOne(
|
|
241
252
|
TABLES.WORKSTREAM_MESSAGE,
|
|
242
|
-
{ workstreamId:
|
|
253
|
+
{ workstreamId: workstreamRef },
|
|
243
254
|
WorkstreamMessageExistingRowSchema,
|
|
244
255
|
)
|
|
245
256
|
if (existingRow) return
|
|
@@ -248,7 +259,7 @@ class WorkstreamMessageService {
|
|
|
248
259
|
if (!messageText) return
|
|
249
260
|
|
|
250
261
|
await this.upsertMessages({
|
|
251
|
-
workstreamId:
|
|
262
|
+
workstreamId: workstreamRef,
|
|
252
263
|
messages: [
|
|
253
264
|
{
|
|
254
265
|
id: Bun.randomUUIDv7(),
|
|
@@ -189,6 +189,7 @@ export interface WorkstreamTurnParams {
|
|
|
189
189
|
orgRef: RecordIdRef
|
|
190
190
|
userRef: RecordIdRef
|
|
191
191
|
userName?: string | null
|
|
192
|
+
agentIdOverride?: string
|
|
192
193
|
inputMessage: ChatMessage
|
|
193
194
|
skipInputMessagePersistence?: boolean
|
|
194
195
|
abortSignal?: AbortSignal
|
|
@@ -212,6 +213,7 @@ type WorkstreamRunCoreParams = {
|
|
|
212
213
|
orgRef: RecordIdRef
|
|
213
214
|
userRef: RecordIdRef
|
|
214
215
|
userName?: string | null
|
|
216
|
+
agentIdOverride?: string
|
|
215
217
|
abortSignal?: AbortSignal
|
|
216
218
|
streamId?: string
|
|
217
219
|
} & (
|
|
@@ -222,7 +224,12 @@ type WorkstreamRunCoreParams = {
|
|
|
222
224
|
|
|
223
225
|
interface PreparedWorkstreamTurn {
|
|
224
226
|
originalMessages: ChatMessage[]
|
|
225
|
-
run: (writer?: UIMessageStreamWriter<ChatMessage>) => Promise<
|
|
227
|
+
run: (writer?: UIMessageStreamWriter<ChatMessage>) => Promise<PreparedWorkstreamTurnResult>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface PreparedWorkstreamTurnResult {
|
|
231
|
+
inputMessageId?: string
|
|
232
|
+
assistantMessages: ChatMessage[]
|
|
226
233
|
}
|
|
227
234
|
|
|
228
235
|
function upsertChatHistoryMessage(messages: ChatMessage[], nextMessage: ChatMessage): ChatMessage[] {
|
|
@@ -733,7 +740,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
733
740
|
workstream.core && workstream.coreType ? getCoreWorkstreamProfile(workstream.coreType) : null
|
|
734
741
|
const defaultLeadAgentId = getLeadAgentId()
|
|
735
742
|
const visibleWorkstreamAgentId =
|
|
736
|
-
|
|
743
|
+
params.agentIdOverride ??
|
|
744
|
+
(workstream.mode === 'direct' ? workstream.agentId : (coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId))
|
|
737
745
|
const coreInstructionSections = coreWorkstreamProfile ? [coreWorkstreamProfile.instructions] : undefined
|
|
738
746
|
const getLinearInstallationByOrgId = getPluginService([
|
|
739
747
|
'linear',
|
|
@@ -939,7 +947,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
939
947
|
return {
|
|
940
948
|
originalMessages,
|
|
941
949
|
run: async (writer?: UIMessageStreamWriter<ChatMessage>) => {
|
|
942
|
-
const executeRun = async () => {
|
|
950
|
+
const executeRun = async (): Promise<PreparedWorkstreamTurnResult | void> => {
|
|
943
951
|
const runTimer = lotaDebugLogger.timer('run')
|
|
944
952
|
const serverRunId = Bun.randomUUIDv7()
|
|
945
953
|
const runAbort = createServerRunAbortController(params.abortSignal)
|
|
@@ -1067,7 +1075,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
1067
1075
|
// Execution-plan approval continuations mutate plan state and persist the approval message,
|
|
1068
1076
|
// but they do not begin a new visible agent turn.
|
|
1069
1077
|
if (params.kind === 'approvalContinuation') {
|
|
1070
|
-
return
|
|
1078
|
+
return { inputMessageId: referenceUserMessage?.id, assistantMessages: [] }
|
|
1071
1079
|
}
|
|
1072
1080
|
|
|
1073
1081
|
const consultSpecialistTool = createTool({
|
|
@@ -1325,7 +1333,12 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
1325
1333
|
}
|
|
1326
1334
|
}
|
|
1327
1335
|
|
|
1328
|
-
await executeRun()
|
|
1336
|
+
const runResult = await executeRun()
|
|
1337
|
+
if (runResult) {
|
|
1338
|
+
return runResult
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return { inputMessageId: referenceUserMessage?.id, assistantMessages: [...allAssistantMessages] }
|
|
1329
1342
|
},
|
|
1330
1343
|
}
|
|
1331
1344
|
}
|
|
@@ -5,10 +5,15 @@ import { lotaDebugLogger } from '../config/debug-logger'
|
|
|
5
5
|
import { hasApprovalRespondedParts, isApprovalContinuationRequest } from '../runtime/approval-continuation'
|
|
6
6
|
import { wrapResponseWithKeepalive } from '../utils/sse-keepalive'
|
|
7
7
|
import { prepareWorkstreamRunCore } from './workstream-turn-preparation.service'
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
PreparedWorkstreamTurnResult,
|
|
10
|
+
WorkstreamApprovalContinuationParams,
|
|
11
|
+
WorkstreamTurnParams,
|
|
12
|
+
} from './workstream-turn-preparation.service'
|
|
9
13
|
|
|
10
14
|
export { hasApprovalRespondedParts, isApprovalContinuationRequest }
|
|
11
15
|
export { wrapResponseWithKeepalive }
|
|
16
|
+
export type { PreparedWorkstreamTurnResult }
|
|
12
17
|
|
|
13
18
|
export async function createWorkstreamApprovalContinuationStream(params: WorkstreamApprovalContinuationParams) {
|
|
14
19
|
const timer = lotaDebugLogger.timer('turn:approval-continuation')
|
|
@@ -55,10 +60,13 @@ export async function createWorkstreamTurnStream(params: WorkstreamTurnParams) {
|
|
|
55
60
|
})
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
export async function runWorkstreamTurnInBackground(
|
|
63
|
+
export async function runWorkstreamTurnInBackground(
|
|
64
|
+
params: WorkstreamTurnParams,
|
|
65
|
+
): Promise<PreparedWorkstreamTurnResult> {
|
|
59
66
|
const timer = lotaDebugLogger.timer('turn:background')
|
|
60
67
|
const prepared = await prepareWorkstreamRunCore({ ...params, kind: 'userTurn' })
|
|
61
68
|
timer.step('prepare')
|
|
62
|
-
await prepared.run()
|
|
69
|
+
const result = await prepared.run()
|
|
63
70
|
timer.step('run')
|
|
71
|
+
return result
|
|
64
72
|
}
|