@lota-sdk/core 0.4.0 → 0.4.2

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.
@@ -90,7 +90,11 @@ function serializeArtifact(artifact: PlanArtifactRecord): SerializablePlanArtifa
90
90
  pointer: artifact.pointer,
91
91
  schemaRef: artifact.schemaRef,
92
92
  description: artifact.description,
93
+ content: artifact.content,
93
94
  payload: artifact.payload,
95
+ publishedArtifactId: artifact.publishedArtifactId
96
+ ? recordIdToString(artifact.publishedArtifactId, TABLES.ARTIFACT)
97
+ : undefined,
94
98
  createdAt: toIsoDateTimeString(artifact.createdAt),
95
99
  }
96
100
  }
@@ -8,6 +8,17 @@ import { TABLES } from '../db/tables'
8
8
  import { executionPlanService } from './execution-plan.service'
9
9
 
10
10
  class PlanTemplateService {
11
+ private resolveSourceIdentity(params: { source?: 'user' | 'playbook' | 'system'; sourceRef?: string }) {
12
+ const source = params.source ?? 'user'
13
+ const sourceRef = params.sourceRef?.trim()
14
+
15
+ if (source !== 'user' && !sourceRef) {
16
+ throw new Error(`sourceRef is required when source is "${source}".`)
17
+ }
18
+
19
+ return { source, sourceRef }
20
+ }
21
+
11
22
  async createTemplate(params: {
12
23
  organizationId: RecordIdInput
13
24
  name: string
@@ -18,6 +29,7 @@ class PlanTemplateService {
18
29
  sourceRef?: string
19
30
  }): Promise<PlanTemplateRecord> {
20
31
  const now = new Date()
32
+ const identity = this.resolveSourceIdentity({ source: params.source, sourceRef: params.sourceRef })
21
33
  return databaseService.create(
22
34
  TABLES.PLAN_TEMPLATE,
23
35
  {
@@ -26,8 +38,8 @@ class PlanTemplateService {
26
38
  ...(params.description ? { description: params.description } : {}),
27
39
  draft: params.draft,
28
40
  tags: params.tags ?? [],
29
- source: params.source ?? 'user',
30
- ...(params.sourceRef ? { sourceRef: params.sourceRef } : {}),
41
+ source: identity.source,
42
+ ...(identity.sourceRef ? { sourceRef: identity.sourceRef } : {}),
31
43
  createdAt: now,
32
44
  },
33
45
  PlanTemplateRecordSchema,
@@ -42,6 +54,22 @@ class PlanTemplateService {
42
54
  )
43
55
  }
44
56
 
57
+ async getTemplateBySourceRef(params: {
58
+ organizationId: RecordIdInput
59
+ source: 'user' | 'playbook' | 'system'
60
+ sourceRef: string
61
+ }): Promise<PlanTemplateRecord | null> {
62
+ return await databaseService.findOne(
63
+ TABLES.PLAN_TEMPLATE,
64
+ {
65
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
66
+ source: params.source,
67
+ sourceRef: params.sourceRef,
68
+ },
69
+ PlanTemplateRecordSchema,
70
+ )
71
+ }
72
+
45
73
  async listTemplates(
46
74
  organizationId: RecordIdInput,
47
75
  params?: { tags?: string[]; source?: string },
@@ -80,6 +108,33 @@ class PlanTemplateService {
80
108
  return updated
81
109
  }
82
110
 
111
+ async upsertTemplateBySourceRef(params: {
112
+ organizationId: RecordIdInput
113
+ name: string
114
+ description?: string
115
+ draft: PlanDraft
116
+ tags?: string[]
117
+ source: 'user' | 'playbook' | 'system'
118
+ sourceRef: string
119
+ }): Promise<PlanTemplateRecord> {
120
+ const existing = await this.getTemplateBySourceRef({
121
+ organizationId: params.organizationId,
122
+ source: params.source,
123
+ sourceRef: params.sourceRef,
124
+ })
125
+
126
+ if (!existing) {
127
+ return await this.createTemplate(params)
128
+ }
129
+
130
+ return await this.updateTemplate(existing.id, {
131
+ name: params.name,
132
+ description: params.description,
133
+ draft: params.draft,
134
+ tags: params.tags ?? [],
135
+ })
136
+ }
137
+
83
138
  async deleteTemplate(templateId: RecordIdInput): Promise<void> {
84
139
  await databaseService.deleteById(TABLES.PLAN_TEMPLATE, ensureRecordId(templateId, TABLES.PLAN_TEMPLATE))
85
140
  }
@@ -510,7 +510,7 @@ class PlanValidatorService {
510
510
  // Validate cross-plan dependency cycles
511
511
  if (draft.dependencies && draft.dependencies.length > 0) {
512
512
  const cycleIssues = planCoordinationService.validateNoCycles([
513
- { title: draft.title, dependencies: draft.dependencies },
513
+ { id: draft.title, title: draft.title, dependencies: draft.dependencies },
514
514
  ])
515
515
  blocking.push(...cycleIssues)
516
516
  }
@@ -50,6 +50,10 @@ const QueueJobAttemptRowSchema = z.object({
50
50
  updatedAt: z.coerce.date(),
51
51
  })
52
52
 
53
+ const PERSISTENCE_MAX_ATTEMPTS = 4
54
+ const PERSISTENCE_RETRY_BASE_DELAY_MS = 25
55
+ const PERSISTENCE_RETRY_JITTER_MS = 25
56
+
53
57
  export interface TrackedBullJobLike {
54
58
  queueName: string
55
59
  id?: string | number
@@ -174,7 +178,43 @@ function getQueuedStatus(job: TrackedBullJobLike): QueueJobStatus {
174
178
  return typeof delay === 'number' && delay > 0 ? 'delayed' : 'waiting'
175
179
  }
176
180
 
181
+ function isRetriablePersistenceConflict(error: unknown): boolean {
182
+ if (!(error instanceof Error)) return false
183
+
184
+ const message = error.message.toLowerCase()
185
+ return (
186
+ message.includes('transaction conflict') ||
187
+ message.includes('transaction read conflict') ||
188
+ message.includes('read or write conflict') ||
189
+ message.includes('write conflict') ||
190
+ message.includes('resource busy') ||
191
+ message.includes('this transaction can be retried')
192
+ )
193
+ }
194
+
177
195
  class QueueJobService {
196
+ private async withPersistenceRetry<T>(work: () => Promise<T>): Promise<T> {
197
+ let lastError: unknown = null
198
+
199
+ for (let attempt = 1; attempt <= PERSISTENCE_MAX_ATTEMPTS; attempt += 1) {
200
+ try {
201
+ return await work()
202
+ } catch (error) {
203
+ lastError = error
204
+ const hasMoreAttempts = attempt < PERSISTENCE_MAX_ATTEMPTS
205
+ if (!isRetriablePersistenceConflict(error) || !hasMoreAttempts) {
206
+ throw error
207
+ }
208
+
209
+ const backoffMs =
210
+ PERSISTENCE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * PERSISTENCE_RETRY_JITTER_MS)
211
+ await Bun.sleep(backoffMs)
212
+ }
213
+ }
214
+
215
+ throw lastError instanceof Error ? lastError : new Error('Queue job persistence retry exhausted')
216
+ }
217
+
178
218
  getQueueJobId(queueName: string, bullmqJobId: string): string {
179
219
  return recordIdToString(
180
220
  buildDeterministicRecordId(TABLES.QUEUE_JOB, `${queueName}:${bullmqJobId}`),
@@ -184,172 +224,178 @@ class QueueJobService {
184
224
 
185
225
  async recordEnqueued(job: TrackedBullJobLike, context?: Record<string, unknown>): Promise<string> {
186
226
  await databaseService.connect()
227
+ return this.withPersistenceRetry(async () => {
228
+ const queueJobId = getQueueJobRecordId(job)
229
+ const queuedAt = typeof job.timestamp === 'number' ? new Date(job.timestamp) : new Date()
230
+ const mergedContext = compactRecord({ ...extractJobContext(job.data), ...context })
187
231
 
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)
232
+ await databaseService.upsert(
233
+ TABLES.QUEUE_JOB,
234
+ queueJobId,
235
+ compactRecord({
236
+ queueName: job.queueName,
237
+ jobName: job.name,
238
+ bullmqJobId: getBullmqJobId(job),
239
+ status: getQueuedStatus(job),
240
+ data: wrapFlexibleValue(job.data),
241
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
242
+ context: Object.keys(mergedContext).length > 0 ? sanitizeQueueValue(mergedContext) : undefined,
243
+ deduplicationId: readDeduplicationId(job),
244
+ maxAttempts: readMaxAttempts(job),
245
+ queuedAt,
246
+ }),
247
+ QueueJobRowSchema,
248
+ )
249
+
250
+ return recordIdToString(queueJobId, TABLES.QUEUE_JOB)
251
+ })
211
252
  }
212
253
 
213
254
  async markAttemptStarted(job: TrackedBullJobLike): Promise<string> {
214
255
  await databaseService.connect()
215
-
216
256
  const attemptNumber = resolveAttemptNumber(job)
217
257
  const queueJobId = getQueueJobRecordId(job)
218
258
  const startedAt = new Date()
219
259
 
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)
260
+ return this.withPersistenceRetry(async () => {
261
+ await databaseService.upsert(
262
+ TABLES.QUEUE_JOB,
263
+ queueJobId,
264
+ compactRecord({
265
+ queueName: job.queueName,
266
+ jobName: job.name,
267
+ bullmqJobId: getBullmqJobId(job),
268
+ status: 'active',
269
+ data: wrapFlexibleValue(job.data),
270
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
271
+ context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
272
+ deduplicationId: readDeduplicationId(job),
273
+ maxAttempts: readMaxAttempts(job),
274
+ attemptCount: attemptNumber,
275
+ queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : startedAt,
276
+ startedAt,
277
+ }),
278
+ QueueJobRowSchema,
279
+ )
280
+
281
+ const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
282
+ await databaseService.upsert(
283
+ TABLES.QUEUE_JOB_ATTEMPT,
284
+ attemptId,
285
+ { queueJobId, attemptNumber, status: 'active', startedAt },
286
+ QueueJobAttemptRowSchema,
287
+ )
288
+
289
+ return recordIdToString(queueJobId, TABLES.QUEUE_JOB)
290
+ })
249
291
  }
250
292
 
251
293
  async markAttemptCompleted(job: TrackedBullJobLike, result: unknown): Promise<void> {
252
294
  await databaseService.connect()
253
-
254
295
  const attemptNumber = resolveAttemptNumber(job)
255
296
  const queueJobId = getQueueJobRecordId(job)
256
297
  const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
257
298
  const completedAt = new Date()
258
- const existingAttempt = await databaseService.findOne(
259
- TABLES.QUEUE_JOB_ATTEMPT,
260
- { id: attemptId },
261
- QueueJobAttemptRowSchema,
262
- )
263
299
 
264
- await databaseService.upsert(
265
- TABLES.QUEUE_JOB_ATTEMPT,
266
- attemptId,
267
- compactRecord({
300
+ await this.withPersistenceRetry(async () => {
301
+ const existingAttempt = await databaseService.findOne(
302
+ TABLES.QUEUE_JOB_ATTEMPT,
303
+ { id: attemptId },
304
+ QueueJobAttemptRowSchema,
305
+ )
306
+
307
+ await databaseService.upsert(
308
+ TABLES.QUEUE_JOB_ATTEMPT,
309
+ attemptId,
310
+ compactRecord({
311
+ queueJobId,
312
+ attemptNumber,
313
+ status: 'completed',
314
+ result: wrapFlexibleValue(result),
315
+ startedAt: existingAttempt?.startedAt ?? completedAt,
316
+ completedAt,
317
+ durationMs: existingAttempt ? Math.max(0, completedAt.getTime() - existingAttempt.startedAt.getTime()) : 0,
318
+ }),
319
+ QueueJobAttemptRowSchema,
320
+ )
321
+
322
+ await databaseService.upsert(
323
+ TABLES.QUEUE_JOB,
268
324
  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
- )
325
+ compactRecord({
326
+ queueName: job.queueName,
327
+ jobName: job.name,
328
+ bullmqJobId: getBullmqJobId(job),
329
+ status: 'completed',
330
+ data: wrapFlexibleValue(job.data),
331
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
332
+ context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
333
+ deduplicationId: readDeduplicationId(job),
334
+ maxAttempts: readMaxAttempts(job),
335
+ attemptCount: attemptNumber,
336
+ queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : completedAt,
337
+ completedAt,
338
+ result: wrapFlexibleValue(result),
339
+ lastError: undefined,
340
+ }),
341
+ QueueJobRowSchema,
342
+ )
343
+ })
300
344
  }
301
345
 
302
346
  async markAttemptFailed(job: TrackedBullJobLike, error: unknown): Promise<void> {
303
347
  await databaseService.connect()
304
-
305
348
  const attemptNumber = resolveAttemptNumber(job)
306
349
  const queueJobId = getQueueJobRecordId(job)
307
350
  const attemptId = getQueueJobAttemptRecordId(job, attemptNumber)
308
351
  const failedAt = new Date()
309
- const existingAttempt = await databaseService.findOne(
310
- TABLES.QUEUE_JOB_ATTEMPT,
311
- { id: attemptId },
312
- QueueJobAttemptRowSchema,
313
- )
314
352
  const normalizedError = toQueueJobError(error)
315
353
  const maxAttempts = readMaxAttempts(job)
316
354
  const terminal = typeof maxAttempts === 'number' ? attemptNumber >= maxAttempts : true
317
355
 
318
- await databaseService.upsert(
319
- TABLES.QUEUE_JOB_ATTEMPT,
320
- attemptId,
321
- compactRecord({
356
+ await this.withPersistenceRetry(async () => {
357
+ const existingAttempt = await databaseService.findOne(
358
+ TABLES.QUEUE_JOB_ATTEMPT,
359
+ { id: attemptId },
360
+ QueueJobAttemptRowSchema,
361
+ )
362
+
363
+ await databaseService.upsert(
364
+ TABLES.QUEUE_JOB_ATTEMPT,
365
+ attemptId,
366
+ compactRecord({
367
+ queueJobId,
368
+ attemptNumber,
369
+ status: 'failed',
370
+ error: normalizedError,
371
+ startedAt: existingAttempt?.startedAt ?? failedAt,
372
+ completedAt: failedAt,
373
+ durationMs: existingAttempt ? Math.max(0, failedAt.getTime() - existingAttempt.startedAt.getTime()) : 0,
374
+ }),
375
+ QueueJobAttemptRowSchema,
376
+ )
377
+
378
+ await databaseService.upsert(
379
+ TABLES.QUEUE_JOB,
322
380
  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
- )
381
+ compactRecord({
382
+ queueName: job.queueName,
383
+ jobName: job.name,
384
+ bullmqJobId: getBullmqJobId(job),
385
+ status: terminal ? 'failed' : 'waiting',
386
+ data: wrapFlexibleValue(job.data),
387
+ options: sanitizeQueueValue(job.opts) as Record<string, unknown>,
388
+ context: sanitizeQueueValue(extractJobContext(job.data)) as Record<string, unknown> | undefined,
389
+ deduplicationId: readDeduplicationId(job),
390
+ maxAttempts,
391
+ attemptCount: attemptNumber,
392
+ queuedAt: typeof job.timestamp === 'number' ? new Date(job.timestamp) : failedAt,
393
+ failedAt: terminal ? failedAt : undefined,
394
+ lastError: normalizedError,
395
+ }),
396
+ QueueJobRowSchema,
397
+ )
398
+ })
353
399
  }
354
400
  }
355
401
 
@@ -41,6 +41,7 @@ import { runPostTurnSideEffects } from '../runtime/post-turn-side-effects'
41
41
  import { getRuntimeAdapters, getToolProviders, getTurnHooks } from '../runtime/runtime-extensions'
42
42
  import {
43
43
  asRecord,
44
+ collectCompletedConsultTeamMessages,
44
45
  collectToolOutputErrors,
45
46
  extractMessageText,
46
47
  readInstructionSections,
@@ -767,17 +768,44 @@ export async function prepareThreadRunCore(params: ThreadRunCoreParams): Promise
767
768
  ) => {
768
769
  throwIfRunAborted()
769
770
 
770
- const committed = withMessageCreatedAt(
771
- {
772
- ...response,
773
- metadata: { ...response.metadata, ...buildAgentMetadataPatch(agentId, agentName), ...metadataPatch },
771
+ const toCommittedAssistantMessage = (
772
+ message: ChatMessage,
773
+ resolvedAgentId: string,
774
+ resolvedAgentName: string,
775
+ patch?: NonNullable<MessageMetadata>,
776
+ ) =>
777
+ withMessageCreatedAt(
778
+ {
779
+ ...message,
780
+ metadata: {
781
+ ...message.metadata,
782
+ ...buildAgentMetadataPatch(resolvedAgentId, resolvedAgentName),
783
+ ...patch,
784
+ },
785
+ },
786
+ toTimestamp(message.metadata?.createdAt) ?? Date.now(),
787
+ )
788
+
789
+ const committedConsultMessages = collectCompletedConsultTeamMessages({ responseMessage: response }).flatMap(
790
+ (consultMessage) => {
791
+ const consultAgentId = readOptionalString(consultMessage.metadata?.agentId)
792
+ const consultAgentName = readOptionalString(consultMessage.metadata?.agentName)
793
+ if (!consultAgentId || !consultAgentName) {
794
+ return []
795
+ }
796
+
797
+ return [toCommittedAssistantMessage(consultMessage, consultAgentId, consultAgentName)]
774
798
  },
775
- Date.now(),
776
799
  )
777
800
 
778
- await threadMessageService.upsertMessages({ threadId: threadRef, messages: [committed] })
779
- currentMessages = upsertChatHistoryMessage(currentMessages, committed)
780
- allAssistantMessages = upsertChatHistoryMessage(allAssistantMessages, committed)
801
+ const committed = toCommittedAssistantMessage(response, agentId, agentName, metadataPatch)
802
+ const messagesToPersist = [...committedConsultMessages, committed]
803
+
804
+ await threadMessageService.upsertMessages({ threadId: threadRef, messages: messagesToPersist })
805
+ for (const persistedMessage of messagesToPersist) {
806
+ currentMessages = upsertChatHistoryMessage(currentMessages, persistedMessage)
807
+ allAssistantMessages = upsertChatHistoryMessage(allAssistantMessages, persistedMessage)
808
+ }
781
809
  throwIfRunAborted()
782
810
 
783
811
  return committed
@@ -121,7 +121,11 @@ export async function triggerPlanNodeTurn(params: {
121
121
  name: artifact.name,
122
122
  kind: artifact.kind,
123
123
  ...(artifact.description ? { description: artifact.description } : {}),
124
+ ...(artifact.content !== undefined ? { content: artifact.content } : {}),
124
125
  ...(artifact.payload !== undefined ? { payload: artifact.payload } : {}),
126
+ ...(artifact.publishedArtifactId
127
+ ? { publishedArtifactId: recordIdToString(artifact.publishedArtifactId, TABLES.ARTIFACT) }
128
+ : {}),
125
129
  }))
126
130
  const upstreamNodeSpecs = new Map(
127
131
  (await planRunService.listNodeSpecs(spec.id)).map((upstreamNodeSpec) => [
@@ -52,6 +52,14 @@ class GeneratedDocumentStorageService {
52
52
  await this.client.file(storageKey).write(params.content, { type: params.mediaType })
53
53
  return { storageKey, sizeBytes }
54
54
  }
55
+
56
+ async readTextArtifact(storageKey: string): Promise<string> {
57
+ return await this.client.file(storageKey).text()
58
+ }
59
+
60
+ async deleteTextArtifact(storageKey: string): Promise<void> {
61
+ await this.client.file(storageKey).delete()
62
+ }
55
63
  }
56
64
 
57
65
  export const generatedDocumentStorageService = new GeneratedDocumentStorageService()