@vertesia/workflow 1.5.0-dev.20260717.131047Z → 1.5.0-dev.20260722.120446Z

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 (43) hide show
  1. package/lib/activities/generateDocumentProperties.d.ts +54 -13
  2. package/lib/activities/generateDocumentProperties.d.ts.map +1 -1
  3. package/lib/activities/generateDocumentProperties.js +109 -34
  4. package/lib/activities/generateDocumentProperties.js.map +1 -1
  5. package/lib/activities/generateOrAssignContentType.d.ts +6 -16
  6. package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
  7. package/lib/activities/generateOrAssignContentType.js +70 -19
  8. package/lib/activities/generateOrAssignContentType.js.map +1 -1
  9. package/lib/activities/media/exec.d.ts +60 -0
  10. package/lib/activities/media/exec.d.ts.map +1 -0
  11. package/lib/activities/media/exec.js +212 -0
  12. package/lib/activities/media/exec.js.map +1 -0
  13. package/lib/activities/media/prepareAudio.d.ts.map +1 -1
  14. package/lib/activities/media/prepareAudio.js +7 -6
  15. package/lib/activities/media/prepareAudio.js.map +1 -1
  16. package/lib/activities/media/prepareVideo.d.ts +5 -0
  17. package/lib/activities/media/prepareVideo.d.ts.map +1 -1
  18. package/lib/activities/media/prepareVideo.js +37 -13
  19. package/lib/activities/media/prepareVideo.js.map +1 -1
  20. package/lib/activities/media/probeMediaStreams.d.ts.map +1 -1
  21. package/lib/activities/media/probeMediaStreams.js +7 -6
  22. package/lib/activities/media/probeMediaStreams.js.map +1 -1
  23. package/lib/activities/renditions/generateVideoRendition.d.ts.map +1 -1
  24. package/lib/activities/renditions/generateVideoRendition.js +7 -5
  25. package/lib/activities/renditions/generateVideoRendition.js.map +1 -1
  26. package/lib/dsl/dsl-workflow.js +3 -0
  27. package/lib/dsl/dsl-workflow.js.map +1 -1
  28. package/lib/workflows-bundle.js +16277 -6846
  29. package/package.json +9 -9
  30. package/src/activities/generateDocumentProperties.test.ts +642 -0
  31. package/src/activities/generateDocumentProperties.ts +187 -43
  32. package/src/activities/generateOrAssignContentType.test.ts +196 -0
  33. package/src/activities/generateOrAssignContentType.ts +115 -30
  34. package/src/activities/media/exec.test.ts +194 -0
  35. package/src/activities/media/exec.ts +265 -0
  36. package/src/activities/media/prepareAudio.ts +12 -7
  37. package/src/activities/media/prepareVideo.test.ts +27 -0
  38. package/src/activities/media/prepareVideo.ts +54 -18
  39. package/src/activities/media/probeMediaStreams.test.ts +7 -8
  40. package/src/activities/media/probeMediaStreams.ts +11 -7
  41. package/src/activities/renditions/generateVideoRendition.ts +12 -6
  42. package/src/dsl/dsl-workflow.test.ts +4 -0
  43. package/src/dsl/dsl-workflow.ts +3 -0
@@ -1,10 +1,14 @@
1
+ import type { JSONSchema } from '@llumiverse/common';
1
2
  import { ApplicationFailure, log } from '@temporalio/activity';
2
3
  import {
3
4
  type ContentObjectTypeItem,
5
+ type ContentSource,
4
6
  type CreateContentObjectTypePayload,
5
7
  type DSLActivityExecutionPayload,
6
8
  type DSLActivitySpec,
7
9
  ImageRenditionFormat,
10
+ PDF_RENDITION_NAME,
11
+ type Rendition,
8
12
  } from '@vertesia/common';
9
13
  import { type ActivityContext, setupActivity } from '../dsl/setup/ActivityContext.js';
10
14
  import { type TruncateSpec, truncByMaxTokens } from '../utils/tokens.js';
@@ -75,9 +79,39 @@ export interface GenerateOrAssignContentType extends DSLActivitySpec<GenerateOrA
75
79
  name: 'generateOrAssignContentType';
76
80
  }
77
81
 
82
+ export type GenerateOrAssignContentTypeResult =
83
+ | {
84
+ status: 'skipped';
85
+ message: string;
86
+ }
87
+ | {
88
+ status: 'failed';
89
+ error: 'no-text';
90
+ }
91
+ | {
92
+ id: string;
93
+ name: string;
94
+ isNew: boolean;
95
+ };
96
+
97
+ function getPdfRenditionContent(doc: { metadata?: { renditions?: unknown } }): ContentSource | undefined {
98
+ const renditions = doc.metadata?.renditions;
99
+ if (!Array.isArray(renditions)) {
100
+ return undefined;
101
+ }
102
+ const pdfRendition = renditions.find(
103
+ (rendition): rendition is Rendition =>
104
+ typeof rendition === 'object' &&
105
+ rendition !== null &&
106
+ (rendition as { name?: unknown }).name === PDF_RENDITION_NAME &&
107
+ typeof (rendition as { content?: { source?: unknown } }).content?.source === 'string',
108
+ );
109
+ return pdfRendition?.content;
110
+ }
111
+
78
112
  export async function generateOrAssignContentType(
79
113
  payload: DSLActivityExecutionPayload<GenerateOrAssignContentTypeParams>,
80
- ) {
114
+ ): Promise<GenerateOrAssignContentTypeResult> {
81
115
  const context = await setupActivity<GenerateOrAssignContentTypeParams>(payload);
82
116
  const { params, client, objectId } = context;
83
117
 
@@ -85,7 +119,7 @@ export async function generateOrAssignContentType(
85
119
 
86
120
  log.debug(`SelectDocumentType for object: ${objectId}`, { payload });
87
121
 
88
- const object = await client.objects.retrieve(objectId, '+text');
122
+ const object = await client.objects.retrieve(objectId, '+text +metadata +content');
89
123
 
90
124
  //Expects object.type to be null on first ingestion of content
91
125
  //User initiated Content Type change via the Composable UI,
@@ -99,12 +133,13 @@ export async function generateOrAssignContentType(
99
133
  };
100
134
  }
101
135
 
102
- if (
103
- !object ||
104
- (!object.text &&
105
- !object.content?.type?.startsWith('image/') &&
106
- !object.content?.type?.startsWith('application/pdf'))
107
- ) {
136
+ const pdfRendition = getPdfRenditionContent(object);
137
+ const hasVisionSource =
138
+ object.content?.type?.startsWith('image/') ||
139
+ object.content?.type?.startsWith('application/pdf') ||
140
+ !!pdfRendition?.type?.startsWith('application/pdf');
141
+
142
+ if (!object || (!object.text && !hasVisionSource)) {
108
143
  log.info(`Object ${objectId} not found or text is empty and not an image`, {
109
144
  object,
110
145
  });
@@ -116,10 +151,13 @@ export async function generateOrAssignContentType(
116
151
  });
117
152
 
118
153
  //make a list of all existing types, and add hints if any
119
- const existing_types = types.filter((t) => !['DocumentPart', 'Rendition'].includes(t.name));
154
+ const existing_types = types.filter((t) => !['DocumentPart', 'Rendition'].includes(t.name) && t.status !== 'draft');
120
155
  const content = object.text ? truncByMaxTokens(object.text, params.truncate || 30000) : undefined;
121
156
 
122
- const getImage = async () => {
157
+ const getImage = async (): Promise<string | ContentSource | undefined> => {
158
+ if (pdfRendition?.type?.startsWith('application/pdf')) {
159
+ return pdfRendition;
160
+ }
123
161
  if (object.content?.type?.includes('pdf') && object.text?.length && object.text?.length < 100) {
124
162
  return `store:${objectId}`;
125
163
  }
@@ -140,18 +178,48 @@ export async function generateOrAssignContentType(
140
178
 
141
179
  const fileRef = await getImage();
142
180
 
181
+ // Slim catalog for the selection prompt: names + identification guidance only, NO schemas
182
+ // (design intake-v2 §3). The full list (with schemas) is still used for matching and for
183
+ // new-type generation below.
184
+ const selectionCatalog = existing_types.map((t) => ({
185
+ id: t.id,
186
+ name: t.name,
187
+ description: t.description,
188
+ guidance: t.intake?.identification?.guidance,
189
+ distinguish_from: t.intake?.identification?.distinguish_from,
190
+ }));
191
+
192
+ // Closed-world output: constrain the result to the eligible type names + 'other', which
193
+ // removes the exact-string-match fragility on the model's answer.
194
+ const selectionResultSchema: JSONSchema = {
195
+ type: 'object',
196
+ properties: {
197
+ document_type: {
198
+ type: 'string',
199
+ enum: [...existing_types.map((t) => t.name), 'other'],
200
+ description: "Name of the matching document type, or 'other' when none matches.",
201
+ },
202
+ },
203
+ required: ['document_type'],
204
+ };
205
+
143
206
  log.info(
144
- 'Execute SelectDocumentType interaction on content with \nexisting types - passing full types: ' +
145
- existing_types.filter((t) => !t.tags?.includes('system')),
207
+ 'Execute SelectDocumentType interaction on content with \nexisting types - passing slim catalog: ' +
208
+ existing_types.filter((t) => !t.tags?.includes('system')).map((t) => t.name),
146
209
  );
147
210
 
148
211
  let res: Awaited<ReturnType<typeof executeInteractionFromActivity>>;
149
212
  try {
150
- res = await executeInteractionFromActivity(client, interactionName, params, {
151
- existing_types,
152
- content,
153
- image: fileRef,
154
- });
213
+ res = await executeInteractionFromActivity(
214
+ client,
215
+ interactionName,
216
+ { ...params, result_schema: selectionResultSchema },
217
+ {
218
+ existing_types: selectionCatalog,
219
+ content,
220
+ image: fileRef,
221
+ },
222
+ );
155
223
  } catch (error: unknown) {
156
224
  const selectionError = toRetryableError(error);
157
225
  log.error(`Failed to select document type`, { error: selectionError, retryable: selectionError.retryable });
@@ -180,20 +248,31 @@ export async function generateOrAssignContentType(
180
248
  log.debug(`Selected Content Type Result: ${JSON.stringify(jsonResult)}`);
181
249
 
182
250
  //if type is not identified or not present in the database, generate a new type
183
- let selectedType: { id: string; name: string } | undefined;
251
+ let selectedType: { id: string; name: string; isNew: boolean } | undefined;
184
252
 
185
- selectedType = types.find((t) => t.name === jsonResult.document_type);
253
+ const existingMatch = existing_types.find((t) => t.name === jsonResult.document_type);
254
+ if (existingMatch) {
255
+ selectedType = { id: existingMatch.id, name: existingMatch.name, isNew: false };
256
+ }
186
257
 
187
258
  if (!selectedType) {
188
259
  if (params.allowNewContentTypes === false) {
189
260
  // Type generation is disabled (handled separately, e.g. via the Studio Assistant), so
190
261
  // fall back to the project's default type (or sys:GenericDocument) rather than leaving
191
- // the document untyped.
262
+ // the document untyped. 'other' is the constrained schema's first-class no-match
263
+ // answer, not a selection defect — only warn on anything else.
264
+ if (jsonResult.document_type && jsonResult.document_type !== 'other') {
265
+ log.warn('Document type selection returned an ineligible or unknown type; assigning fallback', {
266
+ objectId,
267
+ selectedDocumentType: jsonResult.document_type,
268
+ eligibleTypes: existing_types.map((type) => type.name),
269
+ });
270
+ }
192
271
  selectedType = await resolveFallbackType(context, params.fallbackTypeId, jsonResult.document_type);
193
272
  } else {
194
273
  log.warn('Document type not identified: starting type generation');
195
274
  const newType = await generateNewType(context, existing_types, content, fileRef);
196
- selectedType = { id: newType.id, name: newType.name };
275
+ selectedType = { id: newType.id, name: newType.name, isNew: true };
197
276
  }
198
277
  }
199
278
 
@@ -202,15 +281,21 @@ export async function generateOrAssignContentType(
202
281
  throw new Error(`Type not found: ${jsonResult.document_type}`);
203
282
  }
204
283
 
205
- //update object with selected type
206
- await client.objects.update(objectId, {
207
- type: selectedType.id,
208
- });
284
+ // Update object with selected type. Suppress workflow triggers: this is an intake-internal
285
+ // self-write — type updates now emit dirty.type and match the StandardIntake trigger, so an
286
+ // unsuppressed write here would recursively re-run intake on every type assignment.
287
+ await client.objects.update(
288
+ objectId,
289
+ {
290
+ type: selectedType.id,
291
+ },
292
+ { suppressWorkflows: true },
293
+ );
209
294
 
210
295
  return {
211
296
  id: selectedType.id,
212
297
  name: selectedType.name,
213
- isNew: !types.find((t) => t.name === selectedType.name),
298
+ isNew: selectedType.isNew,
214
299
  };
215
300
  }
216
301
 
@@ -222,7 +307,7 @@ async function resolveFallbackType(
222
307
  context: ActivityContext<GenerateOrAssignContentTypeParams>,
223
308
  fallbackTypeId: string | undefined,
224
309
  selectedDocumentType: unknown,
225
- ): Promise<{ id: string; name: string }> {
310
+ ): Promise<{ id: string; name: string; isNew: false }> {
226
311
  if (fallbackTypeId && fallbackTypeId !== GENERIC_DOCUMENT_TYPE_ID) {
227
312
  try {
228
313
  const resolved = await context.client.types.catalog.resolve(fallbackTypeId);
@@ -230,7 +315,7 @@ async function resolveFallbackType(
230
315
  fallbackTypeId,
231
316
  selectedDocumentType,
232
317
  });
233
- return { id: resolved.id ?? fallbackTypeId, name: resolved.name ?? fallbackTypeId };
318
+ return { id: resolved.id ?? fallbackTypeId, name: resolved.name ?? fallbackTypeId, isNew: false };
234
319
  } catch (error) {
235
320
  log.warn('Configured default content type not resolvable; using GenericDocument', {
236
321
  fallbackTypeId,
@@ -239,14 +324,14 @@ async function resolveFallbackType(
239
324
  }
240
325
  }
241
326
  log.info('Document type not identified; assigning GenericDocument fallback', { selectedDocumentType });
242
- return { id: GENERIC_DOCUMENT_TYPE_ID, name: GENERIC_DOCUMENT_TYPE_NAME };
327
+ return { id: GENERIC_DOCUMENT_TYPE_ID, name: GENERIC_DOCUMENT_TYPE_NAME, isNew: false };
243
328
  }
244
329
 
245
330
  async function generateNewType(
246
331
  context: ActivityContext<GenerateOrAssignContentTypeParams>,
247
332
  existing_types: ContentObjectTypeItem[],
248
333
  content?: string,
249
- fileRef?: string,
334
+ fileRef?: string | ContentSource,
250
335
  ) {
251
336
  const { client, params } = context;
252
337
 
@@ -0,0 +1,194 @@
1
+ import { MockActivityEnvironment } from '@temporalio/testing';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createFfmpegProgressTracker, execActivityFile, execActivityFileWithProgress } from './exec.js';
4
+
5
+ describe('execActivityFile', () => {
6
+ it('should execute a child process inside an Activity context', async () => {
7
+ const testEnv = new MockActivityEnvironment();
8
+
9
+ const result = await testEnv.run(() =>
10
+ execActivityFile(process.execPath, ['-e', "process.stdout.write('completed')"]),
11
+ );
12
+
13
+ expect(result).toEqual({ stdout: 'completed', stderr: '' });
14
+ });
15
+
16
+ it('should terminate the child process when the Activity is cancelled', async () => {
17
+ const testEnv = new MockActivityEnvironment();
18
+ const execution = testEnv.run(() =>
19
+ execActivityFile(process.execPath, ['-e', 'setInterval(() => undefined, 1_000)']),
20
+ );
21
+ const rejection = expect(execution).rejects.toMatchObject({ name: 'AbortError' });
22
+
23
+ await new Promise((resolve) => setTimeout(resolve, 100));
24
+ testEnv.cancel();
25
+
26
+ await rejection;
27
+ });
28
+
29
+ it('should terminate the child process before the Activity StartToClose timeout', async () => {
30
+ const testEnv = new MockActivityEnvironment({
31
+ currentAttemptScheduledTimestampMs: Date.now(),
32
+ startToCloseTimeoutMs: 500,
33
+ });
34
+
35
+ const execution = testEnv.run(() =>
36
+ execActivityFile(process.execPath, ['-e', 'setInterval(() => undefined, 1_000)']),
37
+ );
38
+
39
+ await expect(execution).rejects.toMatchObject({ killed: true });
40
+ });
41
+ });
42
+
43
+ describe('createFfmpegProgressTracker', () => {
44
+ it('reports advancement only when a monotonic field increases', () => {
45
+ const tracker = createFfmpegProgressTracker();
46
+ expect(tracker.observe('frame=1 time=00:00:00.10\n')).toBe(true);
47
+ expect(tracker.observe('frame=2 time=00:00:00.20\n')).toBe(true);
48
+ // A wedged encoder re-emitting an identical status line must not be mistaken for progress (P1).
49
+ expect(tracker.observe('frame=2 time=00:00:00.20\n')).toBe(false);
50
+ expect(tracker.observe('frame=2 time=00:00:00.20\n')).toBe(false);
51
+ });
52
+
53
+ it('advances on time= for audio-only output with no frame= field', () => {
54
+ const tracker = createFfmpegProgressTracker();
55
+ expect(tracker.observe('size=1024kB time=00:00:01.00 bitrate=209.7kbits/s\n')).toBe(true);
56
+ expect(tracker.observe('size=1024kB time=00:00:01.00 bitrate=209.7kbits/s\n')).toBe(false);
57
+ expect(tracker.observe('size=2048kB time=00:00:02.00 bitrate=209.7kbits/s\n')).toBe(true);
58
+ });
59
+
60
+ it('recognizes a marker split across stream chunks (P2)', () => {
61
+ const tracker = createFfmpegProgressTracker();
62
+ // No delimiter yet: the partial record is buffered rather than parsed.
63
+ expect(tracker.observe('fra')).toBe(false);
64
+ // Reassembled to `frame=7` once the record completes.
65
+ expect(tracker.observe('me=7\n')).toBe(true);
66
+ });
67
+
68
+ it('treats carriage-return-delimited snapshots as complete records', () => {
69
+ const tracker = createFfmpegProgressTracker();
70
+ expect(tracker.observe('frame=1\rframe=2\r')).toBe(true);
71
+ expect(tracker.observe('frame=2\r')).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('execActivityFileWithProgress', () => {
76
+ const activityEnv = () =>
77
+ new MockActivityEnvironment({
78
+ currentAttemptScheduledTimestampMs: Date.now(),
79
+ startToCloseTimeoutMs: 60_000,
80
+ });
81
+
82
+ // Timing-based cases run with generous timeouts so shared-CI CPU contention cannot flake them. The stall window is
83
+ // kept far larger than the progress cadence so a delayed-but-alive child is never mistaken for a wedged one.
84
+ const TIMING_TEST_TIMEOUT_MS = 20_000;
85
+
86
+ it(
87
+ 'captures output and resolves when the command completes',
88
+ async () => {
89
+ const result = (await activityEnv().run(() =>
90
+ execActivityFileWithProgress(process.execPath, [
91
+ '-e',
92
+ "process.stderr.write('frame=1\\n'); process.stdout.write('ok')",
93
+ ]),
94
+ )) as { stdout: string; stderr: string };
95
+
96
+ expect(result.stdout).toBe('ok');
97
+ expect(result.stderr).toContain('frame=1');
98
+ },
99
+ TIMING_TEST_TIMEOUT_MS,
100
+ );
101
+
102
+ it(
103
+ 'terminates a child that reports progress and then stops',
104
+ async () => {
105
+ const execution = activityEnv().run(() =>
106
+ execActivityFileWithProgress(
107
+ process.execPath,
108
+ ['-e', "process.stderr.write('frame=1\\n'); setInterval(() => undefined, 1_000)"],
109
+ { stallTimeoutMs: 300 },
110
+ ),
111
+ );
112
+
113
+ await expect(execution).rejects.toMatchObject({ stalled: true, killed: true });
114
+ },
115
+ TIMING_TEST_TIMEOUT_MS,
116
+ );
117
+
118
+ it(
119
+ 'terminates a child that keeps repeating the same progress line',
120
+ async () => {
121
+ // A wedged encoder that keeps flushing an identical status line must still be detected as stalled (P1).
122
+ const execution = activityEnv().run(() =>
123
+ execActivityFileWithProgress(
124
+ process.execPath,
125
+ ['-e', "setInterval(() => process.stderr.write('frame=1 time=00:00:01.00\\n'), 40)"],
126
+ { stallTimeoutMs: 300 },
127
+ ),
128
+ );
129
+
130
+ await expect(execution).rejects.toMatchObject({ stalled: true, killed: true });
131
+ },
132
+ TIMING_TEST_TIMEOUT_MS,
133
+ );
134
+
135
+ it(
136
+ 'keeps running while the child continues to report progress',
137
+ async () => {
138
+ // Emit progress every 100ms for ~500ms against a 2s stall window: a healthy child must never trip it, even
139
+ // if CI scheduling delays some ticks.
140
+ const script = [
141
+ 'let n = 0;',
142
+ 'const timer = setInterval(() => {',
143
+ ' process.stderr.write(`frame=${++n}\\n`);',
144
+ ' if (n >= 5) {',
145
+ ' clearInterval(timer);',
146
+ ' process.exit(0);',
147
+ ' }',
148
+ '}, 100);',
149
+ ].join('\n');
150
+
151
+ const result = (await activityEnv().run(() =>
152
+ execActivityFileWithProgress(process.execPath, ['-e', script], { stallTimeoutMs: 2_000 }),
153
+ )) as { stdout: string; stderr: string };
154
+
155
+ expect(result.stderr).toContain('frame=5');
156
+ },
157
+ TIMING_TEST_TIMEOUT_MS,
158
+ );
159
+
160
+ it(
161
+ 'rejects with the exit code when the command fails',
162
+ async () => {
163
+ const execution = activityEnv().run(() =>
164
+ execActivityFileWithProgress(process.execPath, ['-e', 'process.exit(3)']),
165
+ );
166
+
167
+ await expect(execution).rejects.toMatchObject({ code: 3, killed: false });
168
+ },
169
+ TIMING_TEST_TIMEOUT_MS,
170
+ );
171
+
172
+ it(
173
+ 'terminates a still-progressing child at the Activity start-to-close deadline',
174
+ async () => {
175
+ // The child keeps reporting advancing progress, so the stall watchdog never fires; the Activity deadline
176
+ // must still terminate it. This exercises a different path than execActivityFile — the spawn timeout, not
177
+ // execFile's timeout.
178
+ const deadlineEnv = new MockActivityEnvironment({
179
+ currentAttemptScheduledTimestampMs: Date.now(),
180
+ startToCloseTimeoutMs: 600,
181
+ });
182
+ const execution = deadlineEnv.run(() =>
183
+ execActivityFileWithProgress(
184
+ process.execPath,
185
+ ['-e', 'setInterval(() => process.stderr.write(`frame=${Date.now()}\\n`), 50)'],
186
+ { stallTimeoutMs: 60_000 },
187
+ ),
188
+ );
189
+
190
+ await expect(execution).rejects.toMatchObject({ killed: true });
191
+ },
192
+ TIMING_TEST_TIMEOUT_MS,
193
+ );
194
+ });