@vertesia/workflow 1.4.0-dev.20260629.130134Z → 1.5.0-dev.20260713.113008Z

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 (72) hide show
  1. package/lib/activities/detectDocumentLanguage.d.ts +28 -0
  2. package/lib/activities/detectDocumentLanguage.d.ts.map +1 -0
  3. package/lib/activities/detectDocumentLanguage.js +58 -0
  4. package/lib/activities/detectDocumentLanguage.js.map +1 -0
  5. package/lib/activities/extractDocumentText.js +12 -1
  6. package/lib/activities/extractDocumentText.js.map +1 -1
  7. package/lib/activities/generateEmbeddings.d.ts +4 -0
  8. package/lib/activities/generateEmbeddings.d.ts.map +1 -1
  9. package/lib/activities/generateEmbeddings.js +43 -39
  10. package/lib/activities/generateEmbeddings.js.map +1 -1
  11. package/lib/activities/generateOrAssignContentType.d.ts +10 -0
  12. package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
  13. package/lib/activities/generateOrAssignContentType.js +40 -3
  14. package/lib/activities/generateOrAssignContentType.js.map +1 -1
  15. package/lib/activities/handleError.d.ts.map +1 -1
  16. package/lib/activities/handleError.js +1 -0
  17. package/lib/activities/handleError.js.map +1 -1
  18. package/lib/activities/index-dsl.d.ts +1 -0
  19. package/lib/activities/index-dsl.d.ts.map +1 -1
  20. package/lib/activities/index-dsl.js +1 -0
  21. package/lib/activities/index-dsl.js.map +1 -1
  22. package/lib/activities/media/prepareAudio.d.ts.map +1 -1
  23. package/lib/activities/media/prepareAudio.js +4 -1
  24. package/lib/activities/media/prepareAudio.js.map +1 -1
  25. package/lib/activities/media/prepareVideo.d.ts.map +1 -1
  26. package/lib/activities/media/prepareVideo.js +4 -1
  27. package/lib/activities/media/prepareVideo.js.map +1 -1
  28. package/lib/activities/notifyWebhook.d.ts +2 -0
  29. package/lib/activities/notifyWebhook.d.ts.map +1 -1
  30. package/lib/activities/notifyWebhook.js +82 -12
  31. package/lib/activities/notifyWebhook.js.map +1 -1
  32. package/lib/bulk-import.d.ts +1 -0
  33. package/lib/bulk-import.d.ts.map +1 -1
  34. package/lib/conversion/TextractProcessor.js +4 -4
  35. package/lib/conversion/TextractProcessor.js.map +1 -1
  36. package/lib/conversion/image.d.ts.map +1 -1
  37. package/lib/conversion/image.js +2 -0
  38. package/lib/conversion/image.js.map +1 -1
  39. package/lib/dsl/setup/fetch/DataProvider.js +3 -3
  40. package/lib/dsl/setup/fetch/DataProvider.js.map +1 -1
  41. package/lib/index.d.ts +1 -0
  42. package/lib/index.d.ts.map +1 -1
  43. package/lib/index.js +1 -0
  44. package/lib/index.js.map +1 -1
  45. package/lib/system/notifyWebhookWorkflow.d.ts +10 -3
  46. package/lib/system/notifyWebhookWorkflow.d.ts.map +1 -1
  47. package/lib/system/notifyWebhookWorkflow.js +19 -1
  48. package/lib/system/notifyWebhookWorkflow.js.map +1 -1
  49. package/lib/system/recalculateEmbeddingsWorkflow.d.ts +4 -0
  50. package/lib/system/recalculateEmbeddingsWorkflow.d.ts.map +1 -1
  51. package/lib/workflows-bundle.js +1925 -935
  52. package/package.json +17 -17
  53. package/src/activities/detectDocumentLanguage.ts +92 -0
  54. package/src/activities/extractDocumentText.ts +13 -1
  55. package/src/activities/generateEmbeddings.ts +44 -42
  56. package/src/activities/generateOrAssignContentType.ts +56 -3
  57. package/src/activities/handleError.ts +1 -0
  58. package/src/activities/index-dsl.ts +1 -0
  59. package/src/activities/media/prepareAudio.ts +4 -1
  60. package/src/activities/media/prepareVideo.ts +4 -1
  61. package/src/activities/media/probeMediaStreams.test.ts +1 -1
  62. package/src/activities/media/saveGladiaTranscription.test.ts +2 -1
  63. package/src/activities/media/transcribeMediaWithGladia.test.ts +1 -1
  64. package/src/activities/notifyWebhook.test.ts +119 -0
  65. package/src/activities/notifyWebhook.ts +86 -12
  66. package/src/bulk-import.ts +1 -0
  67. package/src/conversion/TextractProcessor.ts +4 -4
  68. package/src/conversion/image.test.ts +24 -0
  69. package/src/conversion/image.ts +3 -0
  70. package/src/dsl/setup/fetch/DataProvider.ts +3 -3
  71. package/src/index.ts +1 -0
  72. package/src/system/notifyWebhookWorkflow.ts +31 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertesia/workflow",
3
- "version": "1.4.0-dev.20260629.130134Z",
3
+ "version": "1.5.0-dev.20260713.113008Z",
4
4
  "type": "module",
5
5
  "description": "Vertesia workflow DSL",
6
6
  "main": "./lib/index.js",
@@ -14,10 +14,10 @@
14
14
  },
15
15
  "license": "Apache-2.0",
16
16
  "devDependencies": {
17
- "@smithy/types": "^4.14.3",
18
- "@temporalio/proto": "^1.18.1",
19
- "@temporalio/testing": "^1.18.1",
20
- "@temporalio/worker": "^1.18.1",
17
+ "@smithy/types": "^4.15.0",
18
+ "@temporalio/proto": "^1.19.0",
19
+ "@temporalio/testing": "^1.19.0",
20
+ "@temporalio/worker": "^1.19.0",
21
21
  "@types/jsonwebtoken": "^9.0.10",
22
22
  "@types/node": "^24.13.2",
23
23
  "@types/papaparse": "^5.5.2",
@@ -27,29 +27,29 @@
27
27
  "@vertesia/tsconfig": "0.1.0"
28
28
  },
29
29
  "dependencies": {
30
- "@aws-sdk/client-s3": "^3.1068.0",
31
- "@aws-sdk/client-textract": "^3.1068.0",
32
- "@aws-sdk/credential-providers": "^3.1068.0",
33
- "@temporalio/activity": "^1.18.1",
34
- "@temporalio/workflow": "^1.18.1",
30
+ "@aws-sdk/client-s3": "^3.1078.0",
31
+ "@aws-sdk/client-textract": "^3.1078.0",
32
+ "@aws-sdk/credential-providers": "^3.1078.0",
33
+ "@temporalio/activity": "^1.19.0",
34
+ "@temporalio/workflow": "^1.19.0",
35
35
  "fast-deep-equal": "^3.1.3",
36
36
  "jsonwebtoken": "^9.0.3",
37
37
  "mime": "^4.1.0",
38
38
  "ms": "3.0.0-canary.202508261828",
39
39
  "node-web-stream-adapters": "^0.2.1",
40
40
  "p-limit": "^7.3.0",
41
- "papaparse": "^5.5.3",
41
+ "papaparse": "^5.5.4",
42
42
  "seedrandom": "^3.0.5",
43
- "sharp": "^0.34.5",
43
+ "sharp": "^0.35.3",
44
44
  "tiktoken": "^1.0.22",
45
45
  "tmp": "^0.2.7",
46
46
  "tmp-promise": "^3.0.3",
47
- "undici": "^7.27.2",
47
+ "undici": "^7.28.0",
48
48
  "yaml": "^2.9.0",
49
- "@vertesia/client": "1.4.0-dev.20260629.130134Z",
50
- "@llumiverse/common": "1.4.0-dev.20260629.090753Z",
51
- "@vertesia/common": "1.4.0-dev.20260629.130134Z",
52
- "@vertesia/api-fetch-client": "1.4.0-dev.20260629.130134Z"
49
+ "@llumiverse/common": "1.5.0-dev",
50
+ "@vertesia/api-fetch-client": "1.5.0-dev.20260713.113008Z",
51
+ "@vertesia/client": "1.5.0-dev.20260713.113008Z",
52
+ "@vertesia/common": "1.5.0-dev.20260713.113008Z"
53
53
  },
54
54
  "exports": {
55
55
  ".": {
@@ -0,0 +1,92 @@
1
+ import { log } from '@temporalio/activity';
2
+ import type { DSLActivityExecutionPayload, DSLActivitySpec } from '@vertesia/common';
3
+ import { setupActivity } from '../dsl/setup/ActivityContext.js';
4
+ import { executeInteractionFromActivity, type InteractionExecutionParams } from './executeInteraction.js';
5
+
6
+ const INT_DETECT_LANGUAGE = 'sys:DetectLanguage';
7
+
8
+ // A short head of the text is enough to identify the language; keep the call tiny.
9
+ const DEFAULT_MAX_CHARS = 500;
10
+
11
+ const LANGUAGE_RESULT_SCHEMA: InteractionExecutionParams['result_schema'] = {
12
+ type: 'object',
13
+ properties: {
14
+ languages: { type: 'array', items: { type: 'string' } },
15
+ },
16
+ required: ['languages'],
17
+ };
18
+
19
+ export interface DetectDocumentLanguageParams extends InteractionExecutionParams {
20
+ interactionName?: string;
21
+ /** Number of leading characters of the document text to inspect (default 500). */
22
+ maxChars?: number;
23
+ }
24
+
25
+ export interface DetectDocumentLanguage extends DSLActivitySpec<DetectDocumentLanguageParams> {
26
+ name: 'detectDocumentLanguage';
27
+ }
28
+
29
+ interface DetectLanguageResult {
30
+ languages?: unknown;
31
+ }
32
+
33
+ /**
34
+ * Detect the document language(s) from its first pages and store them in metadata.languages.
35
+ *
36
+ * A small, focused pass: it runs the sys:DetectLanguage interaction on a short text excerpt rather
37
+ * than folding language into full property extraction (more reliable, and cheap). No-op when the
38
+ * document has no text yet (e.g. media handled by the transcription path, which sets languages
39
+ * itself).
40
+ */
41
+ export async function detectDocumentLanguage(payload: DSLActivityExecutionPayload<DetectDocumentLanguageParams>) {
42
+ const context = await setupActivity<DetectDocumentLanguageParams>(payload);
43
+ const { params, client, objectId } = context;
44
+ const interactionName = params.interactionName ?? INT_DETECT_LANGUAGE;
45
+
46
+ const doc = await client.objects.retrieve(objectId, '+text');
47
+ if (!doc?.text) {
48
+ log.info(`detectDocumentLanguage: object ${objectId} has no text, skipping`);
49
+ return { status: 'skipped', message: 'no-text' };
50
+ }
51
+
52
+ const content = doc.text.slice(0, params.maxChars ?? DEFAULT_MAX_CHARS);
53
+
54
+ let res: Awaited<ReturnType<typeof executeInteractionFromActivity>>;
55
+ try {
56
+ res = await executeInteractionFromActivity(
57
+ client,
58
+ interactionName,
59
+ { ...params, result_schema: LANGUAGE_RESULT_SCHEMA },
60
+ { content },
61
+ payload.debug_mode ?? false,
62
+ );
63
+ } catch (error) {
64
+ // Language detection is best-effort — never fail the whole intake over it.
65
+ log.warn(`detectDocumentLanguage: detection failed for ${objectId}, skipping`, { error: String(error) });
66
+ return { status: 'skipped', message: 'detection-failed' };
67
+ }
68
+
69
+ const result = res.result.object<DetectLanguageResult>();
70
+ const languages = Array.isArray(result.languages)
71
+ ? Array.from(
72
+ new Set(
73
+ result.languages
74
+ .filter((lang): lang is string => typeof lang === 'string')
75
+ .map((lang) => lang.trim().toLowerCase())
76
+ .filter(Boolean),
77
+ ),
78
+ )
79
+ : [];
80
+
81
+ if (languages.length === 0) {
82
+ log.warn(`detectDocumentLanguage: no language detected for ${objectId}`, { runId: res.id });
83
+ return { status: 'completed', languages: [] };
84
+ }
85
+
86
+ // metadata-only update (not source/properties), so it won't re-trigger intake; suppress
87
+ // workflows anyway for consistency with the other intake writes.
88
+ await client.objects.update(objectId, { metadata: { ...doc.metadata, languages } }, { suppressWorkflows: true });
89
+
90
+ log.info(`detectDocumentLanguage: set languages for ${objectId}`, { languages, runId: res.id });
91
+ return { status: 'completed', languages };
92
+ }
@@ -235,13 +235,25 @@ function createResponse(
235
235
  return {
236
236
  status,
237
237
  message,
238
- tokens: doc.tokens,
238
+ tokens: getCompleteTokens(doc),
239
239
  len: text.length,
240
240
  objectId: doc.id,
241
241
  hasText: !!text,
242
242
  };
243
243
  }
244
244
 
245
+ function getCompleteTokens(doc: ContentObject): TextExtractionResult['tokens'] {
246
+ const tokens = doc.tokens;
247
+ if (tokens?.count === undefined || tokens.encoding === undefined || tokens.etag === undefined) {
248
+ return undefined;
249
+ }
250
+ return {
251
+ count: tokens.count,
252
+ encoding: tokens.encoding,
253
+ etag: tokens.etag,
254
+ };
255
+ }
256
+
245
257
  function sniffIfText(buf: Buffer) {
246
258
  // If file is too large, don't even try
247
259
  if (buf.length > 500 * 1024) {
@@ -48,21 +48,33 @@ export interface GenerateEmbeddings extends DSLActivitySpec<GenerateEmbeddingsPa
48
48
  name: 'generateEmbeddings';
49
49
  }
50
50
 
51
+ /**
52
+ * True when an error indicates the embedding environment is missing/unavailable (e.g. the
53
+ * configured environment id no longer exists). Treated as "not configured" so intake skips
54
+ * embeddings gracefully instead of failing.
55
+ */
56
+ function isEnvironmentNotFound(error: unknown): boolean {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ return /environment not found/i.test(message);
59
+ }
60
+
51
61
  export async function generateEmbeddings(payload: DSLActivityExecutionPayload<GenerateEmbeddingsParams>) {
52
62
  const { params, client, objectId, fetchProject } = await setupActivity<GenerateEmbeddingsParams>(payload);
53
63
  const { force, type } = params;
54
64
 
65
+ const skipped = (message: string) => {
66
+ log.info(`Skipping ${type} embeddings for ${objectId}: ${message}`);
67
+ return { id: objectId, status: 'skipped' as const, message };
68
+ };
69
+
55
70
  const projectData = await fetchProject();
56
71
  const config = projectData?.configuration.embeddings[type];
57
72
  if (!projectData) {
58
73
  throw new DocumentNotFoundError('Project not found', [payload.project_id]);
59
74
  }
75
+ // Embeddings not configured for this type -> skip rather than fail intake.
60
76
  if (!config) {
61
- throw new DocumentNotFoundError('Embeddings configuration not found', [objectId]);
62
- }
63
-
64
- if (!projectData) {
65
- throw new DocumentNotFoundError('Project not found', [payload.project_id]);
77
+ return skipped(`embeddings not configured for type ${type}`);
66
78
  }
67
79
 
68
80
  if (!projectData?.configuration.embeddings[type]?.enabled) {
@@ -82,10 +94,9 @@ export async function generateEmbeddings(payload: DSLActivityExecutionPayload<Ge
82
94
  config,
83
95
  });
84
96
 
97
+ // No embedding environment configured for this type -> skip rather than fail intake.
85
98
  if (!config.environment) {
86
- throw new Error(
87
- 'No environment found in project configuration. Set environment in project configuration to generate embeddings.',
88
- );
99
+ return skipped(`no embedding environment configured for type ${type}`);
89
100
  }
90
101
 
91
102
  let document: Awaited<ReturnType<typeof client.objects.retrieve>>;
@@ -111,40 +122,31 @@ export async function generateEmbeddings(payload: DSLActivityExecutionPayload<Ge
111
122
  | Awaited<ReturnType<typeof generateImageEmbeddings>>
112
123
  | { id: string; status: string; message: string };
113
124
 
114
- switch (type) {
115
- case SupportedEmbeddingTypes.text:
116
- res = await generateTextEmbeddings({
117
- client,
118
- config,
119
- document,
120
- type,
121
- force,
122
- });
123
- break;
124
- case SupportedEmbeddingTypes.properties:
125
- res = await generateTextEmbeddings({
126
- client,
127
- config,
128
- document,
129
- type,
130
- force,
131
- });
132
- break;
133
- case SupportedEmbeddingTypes.image:
134
- res = await generateImageEmbeddings({
135
- client,
136
- config,
137
- document,
138
- type,
139
- force,
140
- });
141
- break;
142
- default:
143
- res = {
144
- id: objectId,
145
- status: 'failed',
146
- message: `unsupported embedding type: ${type}`,
147
- };
125
+ try {
126
+ switch (type) {
127
+ case SupportedEmbeddingTypes.text:
128
+ res = await generateTextEmbeddings({ client, config, document, type, force });
129
+ break;
130
+ case SupportedEmbeddingTypes.properties:
131
+ res = await generateTextEmbeddings({ client, config, document, type, force });
132
+ break;
133
+ case SupportedEmbeddingTypes.image:
134
+ res = await generateImageEmbeddings({ client, config, document, type, force });
135
+ break;
136
+ default:
137
+ res = {
138
+ id: objectId,
139
+ status: 'failed',
140
+ message: `unsupported embedding type: ${type}`,
141
+ };
142
+ }
143
+ } catch (error) {
144
+ // Configured environment is missing/unavailable -> skip rather than fail the whole intake
145
+ // (and avoid the long LLM-activity retry storm a throw would trigger).
146
+ if (isEnvironmentNotFound(error)) {
147
+ return skipped(`embedding environment unavailable for type ${type}`);
148
+ }
149
+ throw error;
148
150
  }
149
151
 
150
152
  return res;
@@ -13,6 +13,12 @@ import { executeInteractionFromActivity, type InteractionExecutionParams } from
13
13
  const INT_SELECT_DOCUMENT_TYPE = 'sys:SelectDocumentType';
14
14
  const INT_GENERATE_METADATA_MODEL = 'sys:GenerateMetadataModel';
15
15
 
16
+ // Always-present system fallback type (registered in zeno's SystemContentTypeRegistry). When type
17
+ // selection finds no existing match and new-type generation is disabled, documents are assigned
18
+ // this generic type so they still get a minimal property set + a processing hint for later typing.
19
+ const GENERIC_DOCUMENT_TYPE_ID = 'sys:GenericDocument';
20
+ const GENERIC_DOCUMENT_TYPE_NAME = 'GenericDocument';
21
+
16
22
  interface RetryableError extends Error {
17
23
  retryable?: boolean;
18
24
  }
@@ -51,6 +57,18 @@ export interface GenerateOrAssignContentTypeParams extends InteractionExecutionP
51
57
  selectDocumentType?: string;
52
58
  generateMetadataModel?: string;
53
59
  };
60
+
61
+ /**
62
+ * Whether the activity may create a brand-new content type when no existing type matches.
63
+ * Defaults to true for backward compatibility.
64
+ */
65
+ allowNewContentTypes?: boolean;
66
+
67
+ /**
68
+ * Type id to assign when no existing type matches and generation is disabled. Resolved in this
69
+ * project; falls back to sys:GenericDocument if unset or unresolvable.
70
+ */
71
+ fallbackTypeId?: string;
54
72
  }
55
73
 
56
74
  export interface GenerateOrAssignContentType extends DSLActivitySpec<GenerateOrAssignContentTypeParams> {
@@ -167,9 +185,16 @@ export async function generateOrAssignContentType(
167
185
  selectedType = types.find((t) => t.name === jsonResult.document_type);
168
186
 
169
187
  if (!selectedType) {
170
- log.warn('Document type not identified: starting type generation');
171
- const newType = await generateNewType(context, existing_types, content, fileRef);
172
- selectedType = { id: newType.id, name: newType.name };
188
+ if (params.allowNewContentTypes === false) {
189
+ // Type generation is disabled (handled separately, e.g. via the Studio Assistant), so
190
+ // fall back to the project's default type (or sys:GenericDocument) rather than leaving
191
+ // the document untyped.
192
+ selectedType = await resolveFallbackType(context, params.fallbackTypeId, jsonResult.document_type);
193
+ } else {
194
+ log.warn('Document type not identified: starting type generation');
195
+ const newType = await generateNewType(context, existing_types, content, fileRef);
196
+ selectedType = { id: newType.id, name: newType.name };
197
+ }
173
198
  }
174
199
 
175
200
  if (!selectedType) {
@@ -189,6 +214,34 @@ export async function generateOrAssignContentType(
189
214
  };
190
215
  }
191
216
 
217
+ /**
218
+ * Resolve the fallback type to assign when selection finds no match: the project's configured
219
+ * default content type if set and resolvable, otherwise the platform sys:GenericDocument.
220
+ */
221
+ async function resolveFallbackType(
222
+ context: ActivityContext<GenerateOrAssignContentTypeParams>,
223
+ fallbackTypeId: string | undefined,
224
+ selectedDocumentType: unknown,
225
+ ): Promise<{ id: string; name: string }> {
226
+ if (fallbackTypeId && fallbackTypeId !== GENERIC_DOCUMENT_TYPE_ID) {
227
+ try {
228
+ const resolved = await context.client.types.catalog.resolve(fallbackTypeId);
229
+ log.info('Document type not identified; assigning project default content type', {
230
+ fallbackTypeId,
231
+ selectedDocumentType,
232
+ });
233
+ return { id: resolved.id ?? fallbackTypeId, name: resolved.name ?? fallbackTypeId };
234
+ } catch (error) {
235
+ log.warn('Configured default content type not resolvable; using GenericDocument', {
236
+ fallbackTypeId,
237
+ error: error instanceof Error ? error.message : String(error),
238
+ });
239
+ }
240
+ }
241
+ log.info('Document type not identified; assigning GenericDocument fallback', { selectedDocumentType });
242
+ return { id: GENERIC_DOCUMENT_TYPE_ID, name: GENERIC_DOCUMENT_TYPE_NAME };
243
+ }
244
+
192
245
  async function generateNewType(
193
246
  context: ActivityContext<GenerateOrAssignContentTypeParams>,
194
247
  existing_types: ContentObjectTypeItem[],
@@ -10,6 +10,7 @@ export async function handleDslError(payload: DSLActivityExecutionPayload<Handle
10
10
  const { client, params, objectId } = await setupActivity<HandleDslErrorParams>(payload);
11
11
 
12
12
  const isIntake = [
13
+ 'StandardIntake',
13
14
  'StandardDocumentIntake',
14
15
  'StandardImageIntake',
15
16
  'StandardMediaContainerIntake',
@@ -5,6 +5,7 @@ export { createOrUpdateDocumentFromInteractionRun } from './advanced/createOrUpd
5
5
  export { updateDocumentFromInteractionRun } from './advanced/updateDocumentFromInteractionRun.js';
6
6
  export { chunkDocument } from './chunkDocument.js';
7
7
  export { createPdfDocumentFromSource } from './createDocumentFromOther.js';
8
+ export { detectDocumentLanguage } from './detectDocumentLanguage.js';
8
9
  export { executeInteraction } from './executeInteraction.js';
9
10
  export { executeRemoteActivity } from './executeRemoteActivity.js';
10
11
  export { extractDocumentText } from './extractDocumentText.js';
@@ -258,11 +258,14 @@ export async function prepareAudio(
258
258
  }
259
259
 
260
260
  // Step 4: Update content object with metadata and renditions
261
+ const generationRuns = Array.isArray(inputObject.metadata?.generation_runs)
262
+ ? inputObject.metadata.generation_runs
263
+ : [];
261
264
  const audioMetadata: AudioMetadata = {
262
265
  type: ContentNature.Audio,
263
266
  duration: metadata.duration,
264
267
  renditions,
265
- generation_runs: inputObject.metadata?.generation_runs || [],
268
+ generation_runs: generationRuns,
266
269
  };
267
270
 
268
271
  await client.objects.update(objectId, {
@@ -534,6 +534,9 @@ export async function prepareVideo(
534
534
  }
535
535
 
536
536
  // Step 7: Update content object with metadata and renditions
537
+ const generationRuns = Array.isArray(inputObject.metadata?.generation_runs)
538
+ ? inputObject.metadata.generation_runs
539
+ : [];
537
540
  const videoMetadata: VideoMetadata = {
538
541
  type: ContentNature.Video,
539
542
  duration: metadata.duration,
@@ -543,7 +546,7 @@ export async function prepareVideo(
543
546
  },
544
547
  renditions,
545
548
  hasAudio: metadata.hasAudio,
546
- generation_runs: inputObject.metadata?.generation_runs || [],
549
+ generation_runs: generationRuns,
547
550
  };
548
551
 
549
552
  await client.objects.update(objectId, {
@@ -1,8 +1,8 @@
1
1
  import { MockActivityEnvironment } from '@temporalio/testing';
2
2
  import type { VertesiaClient } from '@vertesia/client';
3
3
  import { ContentEventName, type DSLActivityExecutionPayload } from '@vertesia/common';
4
- import type { ActivityContext } from '@vertesia/workflow';
5
4
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import type { ActivityContext } from '../../dsl/setup/ActivityContext.js';
6
6
  import { type ProbeMediaStreamsParams, type ProbeMediaStreamsResult, probeMediaStreams } from './probeMediaStreams.js';
7
7
 
8
8
  vi.mock('../../dsl/setup/ActivityContext.js', async (importOriginal) => {
@@ -1,8 +1,9 @@
1
1
  import { MockActivityEnvironment } from '@temporalio/testing';
2
2
  import type { VertesiaClient } from '@vertesia/client';
3
3
  import { ContentEventName, type DSLActivityExecutionPayload } from '@vertesia/common';
4
- import type { ActivityContext, TextExtractionResult } from '@vertesia/workflow';
5
4
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import type { ActivityContext } from '../../dsl/setup/ActivityContext.js';
6
+ import type { TextExtractionResult } from '../../result-types.js';
6
7
  import { TextExtractionStatus } from '../../result-types.js';
7
8
  import { type SaveGladiaTranscriptionParams, saveGladiaTranscription } from './saveGladiaTranscription.js';
8
9
 
@@ -7,8 +7,8 @@ import {
7
7
  ContentNature,
8
8
  type DSLActivityExecutionPayload,
9
9
  } from '@vertesia/common';
10
- import type { ActivityContext } from '@vertesia/workflow';
11
10
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import type { ActivityContext } from '../../dsl/setup/ActivityContext.js';
12
12
  import { TextExtractionStatus } from '../../result-types.js';
13
13
  import {
14
14
  type TranscriptMediaParams,
@@ -1,4 +1,5 @@
1
1
  import { MockActivityEnvironment } from '@temporalio/testing';
2
+ import type { VertesiaClient } from '@vertesia/client';
2
3
  import { ApiVersions, ContentEventName, type DSLActivityExecutionPayload, type WebHookSpec } from '@vertesia/common';
3
4
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import {
@@ -8,6 +9,23 @@ import {
8
9
  type WebhookNotificationPayload,
9
10
  } from './notifyWebhook.js';
10
11
 
12
+ const mockValidateUrl = vi.hoisted(() => vi.fn());
13
+
14
+ vi.mock('../utils/client.js', async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import('../utils/client.js')>();
16
+ return {
17
+ ...actual,
18
+ getVertesiaClient: vi.fn(
19
+ () =>
20
+ ({
21
+ apps: {
22
+ validateUrl: mockValidateUrl,
23
+ },
24
+ }) as unknown as VertesiaClient,
25
+ ),
26
+ };
27
+ });
28
+
11
29
  // Mock fetch globally
12
30
  vi.stubGlobal('fetch', vi.fn());
13
31
 
@@ -20,6 +38,7 @@ beforeAll(async () => {
20
38
 
21
39
  beforeEach(() => {
22
40
  vi.clearAllMocks();
41
+ mockValidateUrl.mockResolvedValue({ valid: true });
23
42
  });
24
43
 
25
44
  const defaultParams = {
@@ -64,6 +83,7 @@ describe('Webhook should be notified', () => {
64
83
  status: 200,
65
84
  statusText: 'OK',
66
85
  url: defaultParams.webhook,
86
+ headers: new Headers(),
67
87
  };
68
88
  mockFetch.mockResolvedValueOnce(mockResponse as Response);
69
89
 
@@ -82,6 +102,7 @@ describe('Webhook should be notified', () => {
82
102
  headers: {
83
103
  'Content-Type': 'application/json',
84
104
  },
105
+ redirect: 'manual',
85
106
  });
86
107
 
87
108
  // Verify response
@@ -99,6 +120,7 @@ describe('Webhook should be notified', () => {
99
120
  status: 500,
100
121
  statusText: 'Internal Server Error',
101
122
  url: defaultParams.webhook,
123
+ headers: new Headers(),
102
124
  text: vi.fn().mockResolvedValue('{"error": "Database connection failed", "code": "DB_ERROR"}'),
103
125
  } as unknown as Response;
104
126
  mockFetch.mockResolvedValueOnce(mockResponse);
@@ -122,6 +144,7 @@ describe('Webhook should be notified', () => {
122
144
  headers: {
123
145
  'Content-Type': 'application/json',
124
146
  },
147
+ redirect: 'manual',
125
148
  });
126
149
 
127
150
  // Verify that text() was called to read the response
@@ -150,6 +173,100 @@ describe('Webhook should be notified', () => {
150
173
  headers: {
151
174
  'Content-Type': 'application/json',
152
175
  },
176
+ redirect: 'manual',
177
+ });
178
+ });
179
+
180
+ it('should block URLs rejected by central URL validation', async () => {
181
+ mockValidateUrl.mockRejectedValueOnce(new Error('Access to internal hosts is not allowed'));
182
+
183
+ const payload = createTestPayload({
184
+ webhook: 'https://metadata.google.internal/computeMetadata/v1/',
185
+ });
186
+
187
+ await expect(testEnv.run(notifyWebhook, payload)).rejects.toThrow(
188
+ 'Webhook endpoint blocked: Access to internal hosts is not allowed',
189
+ );
190
+ expect(mockFetch).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it('should retry transient central URL validation failures', async () => {
194
+ const transientError = Object.assign(new Error('studio-server unavailable'), { statusCode: 503 });
195
+ mockValidateUrl.mockRejectedValueOnce(transientError);
196
+
197
+ const payload = createTestPayload();
198
+
199
+ await expect(testEnv.run(notifyWebhook, payload)).rejects.toThrow('studio-server unavailable');
200
+ expect(mockFetch).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it('should block webhook redirects', async () => {
204
+ mockFetch.mockResolvedValueOnce(
205
+ new Response(null, {
206
+ status: 302,
207
+ statusText: 'Found',
208
+ headers: {
209
+ Location: 'https://example.com/redirected',
210
+ },
211
+ }),
212
+ );
213
+
214
+ const payload = createTestPayload();
215
+
216
+ await expect(testEnv.run(notifyWebhook, payload)).rejects.toThrow('Webhook endpoint blocked: Request to');
217
+ expect(mockFetch).toHaveBeenCalledWith(defaultParams.webhook, {
218
+ method: 'POST',
219
+ body: JSON.stringify({
220
+ workflowId: 'wf_id',
221
+ runId: 'wf_run_id',
222
+ status: 'completed',
223
+ result: { message: 'Hello World' },
224
+ }),
225
+ headers: {
226
+ 'Content-Type': 'application/json',
227
+ },
228
+ redirect: 'manual',
229
+ });
230
+ });
231
+
232
+ it('should send a raw body when provided', async () => {
233
+ const mockResponse = {
234
+ ok: true,
235
+ status: 202,
236
+ statusText: 'Accepted',
237
+ url: defaultParams.webhook,
238
+ headers: new Headers(),
239
+ };
240
+ mockFetch.mockResolvedValueOnce(mockResponse as Response);
241
+
242
+ const body = JSON.stringify({ event: { event_id: 'evt-1' } });
243
+ const payload = createTestPayload({
244
+ body,
245
+ headers: {
246
+ 'content-type': 'application/json',
247
+ 'X-Vertesia-Event-Id': 'evt-1',
248
+ },
249
+ timeout_ms: 5000,
250
+ });
251
+
252
+ const res: NotifyWebhookResult = await testEnv.run(notifyWebhook, payload);
253
+
254
+ expect(mockFetch).toHaveBeenCalledTimes(1);
255
+ const [url, options] = mockFetch.mock.calls[0];
256
+ expect(url).toBe(defaultParams.webhook);
257
+ expect(options).toMatchObject({
258
+ method: 'POST',
259
+ body,
260
+ headers: {
261
+ 'content-type': 'application/json',
262
+ 'X-Vertesia-Event-Id': 'evt-1',
263
+ },
264
+ });
265
+ expect(options?.signal).toBeInstanceOf(AbortSignal);
266
+ expect(res).toEqual({
267
+ status: 202,
268
+ message: 'Accepted',
269
+ url: defaultParams.webhook,
153
270
  });
154
271
  });
155
272
 
@@ -160,6 +277,7 @@ describe('Webhook should be notified', () => {
160
277
  status: 200,
161
278
  statusText: 'OK',
162
279
  url: 'https://vertesia.test',
280
+ headers: new Headers(),
163
281
  };
164
282
  mockFetch.mockResolvedValueOnce(mockResponse as Response);
165
283
 
@@ -213,6 +331,7 @@ describe('Webhook should be notified', () => {
213
331
  status: 200,
214
332
  statusText: 'OK',
215
333
  url: 'https://vertesia.test',
334
+ headers: new Headers(),
216
335
  };
217
336
  mockFetch.mockResolvedValueOnce(mockResponse as Response);
218
337