@vertesia/workflow 1.5.0-dev.20260714.072725Z → 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.
- package/lib/activities/generateDocumentProperties.d.ts +54 -13
- package/lib/activities/generateDocumentProperties.d.ts.map +1 -1
- package/lib/activities/generateDocumentProperties.js +109 -34
- package/lib/activities/generateDocumentProperties.js.map +1 -1
- package/lib/activities/generateEmbeddings.d.ts.map +1 -1
- package/lib/activities/generateEmbeddings.js +14 -22
- package/lib/activities/generateEmbeddings.js.map +1 -1
- package/lib/activities/generateOrAssignContentType.d.ts +6 -16
- package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
- package/lib/activities/generateOrAssignContentType.js +70 -19
- package/lib/activities/generateOrAssignContentType.js.map +1 -1
- package/lib/activities/media/exec.d.ts +60 -0
- package/lib/activities/media/exec.d.ts.map +1 -0
- package/lib/activities/media/exec.js +212 -0
- package/lib/activities/media/exec.js.map +1 -0
- package/lib/activities/media/prepareAudio.d.ts.map +1 -1
- package/lib/activities/media/prepareAudio.js +7 -6
- package/lib/activities/media/prepareAudio.js.map +1 -1
- package/lib/activities/media/prepareVideo.d.ts +5 -0
- package/lib/activities/media/prepareVideo.d.ts.map +1 -1
- package/lib/activities/media/prepareVideo.js +37 -13
- package/lib/activities/media/prepareVideo.js.map +1 -1
- package/lib/activities/media/probeMediaStreams.d.ts.map +1 -1
- package/lib/activities/media/probeMediaStreams.js +7 -6
- package/lib/activities/media/probeMediaStreams.js.map +1 -1
- package/lib/activities/renditions/generateVideoRendition.d.ts.map +1 -1
- package/lib/activities/renditions/generateVideoRendition.js +7 -5
- package/lib/activities/renditions/generateVideoRendition.js.map +1 -1
- package/lib/dsl/dsl-workflow.js +3 -0
- package/lib/dsl/dsl-workflow.js.map +1 -1
- package/lib/dsl/dslProxyActivities.d.ts +1 -0
- package/lib/dsl/dslProxyActivities.d.ts.map +1 -1
- package/lib/dsl/dslProxyActivities.js +9 -0
- package/lib/dsl/dslProxyActivities.js.map +1 -1
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +5 -0
- package/lib/errors.js.map +1 -1
- package/lib/workflows-bundle.js +16111 -6668
- package/package.json +17 -16
- package/src/activities/generateDocumentProperties.test.ts +642 -0
- package/src/activities/generateDocumentProperties.ts +187 -43
- package/src/activities/generateEmbeddings.test.ts +114 -0
- package/src/activities/generateEmbeddings.ts +17 -24
- package/src/activities/generateOrAssignContentType.test.ts +196 -0
- package/src/activities/generateOrAssignContentType.ts +115 -30
- package/src/activities/media/exec.test.ts +194 -0
- package/src/activities/media/exec.ts +265 -0
- package/src/activities/media/prepareAudio.ts +12 -7
- package/src/activities/media/prepareVideo.test.ts +27 -0
- package/src/activities/media/prepareVideo.ts +54 -18
- package/src/activities/media/probeMediaStreams.test.ts +7 -8
- package/src/activities/media/probeMediaStreams.ts +11 -7
- package/src/activities/renditions/generateVideoRendition.ts +12 -6
- package/src/dsl/dsl-workflow.test.ts +4 -0
- package/src/dsl/dsl-workflow.ts +3 -0
- package/src/dsl/dslProxyActivities.test.ts +23 -0
- package/src/dsl/dslProxyActivities.ts +11 -0
- package/src/errors.ts +5 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { MockActivityEnvironment } from '@temporalio/testing';
|
|
2
|
+
import type { VertesiaClient } from '@vertesia/client';
|
|
3
|
+
import {
|
|
4
|
+
ContentEventName,
|
|
5
|
+
type ContentObjectTypeItem,
|
|
6
|
+
type DSLActivityExecutionPayload,
|
|
7
|
+
PDF_RENDITION_NAME,
|
|
8
|
+
type Rendition,
|
|
9
|
+
} from '@vertesia/common';
|
|
10
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import type { ActivityContext } from '../dsl/setup/ActivityContext.js';
|
|
12
|
+
import { executeInteractionFromActivity } from './executeInteraction.js';
|
|
13
|
+
import {
|
|
14
|
+
type GenerateOrAssignContentTypeParams,
|
|
15
|
+
type GenerateOrAssignContentTypeResult,
|
|
16
|
+
generateOrAssignContentType,
|
|
17
|
+
} from './generateOrAssignContentType.js';
|
|
18
|
+
|
|
19
|
+
vi.mock('../dsl/setup/ActivityContext.js', async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal<typeof import('../dsl/setup/ActivityContext.js')>();
|
|
21
|
+
return { ...actual, setupActivity: vi.fn() };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('./executeInteraction.js', async (importOriginal) => {
|
|
25
|
+
const actual = await importOriginal<typeof import('./executeInteraction.js')>();
|
|
26
|
+
return { ...actual, executeInteractionFromActivity: vi.fn() };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let testEnv: MockActivityEnvironment;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
testEnv = new MockActivityEnvironment();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function payload(
|
|
40
|
+
params: GenerateOrAssignContentTypeParams,
|
|
41
|
+
): DSLActivityExecutionPayload<GenerateOrAssignContentTypeParams> {
|
|
42
|
+
return {
|
|
43
|
+
account_id: 'test-account',
|
|
44
|
+
activity: { name: 'generateOrAssignContentType', params: {} },
|
|
45
|
+
auth_token: 'mock-token',
|
|
46
|
+
config: { studio_url: 'http://mock-studio', store_url: 'http://mock-store' },
|
|
47
|
+
event: ContentEventName.create,
|
|
48
|
+
input: { inputType: 'objectIds', objectIds: ['object-1'] },
|
|
49
|
+
objectIds: ['object-1'],
|
|
50
|
+
params,
|
|
51
|
+
project_id: 'test-project',
|
|
52
|
+
vars: {},
|
|
53
|
+
workflow_name: 'test-workflow',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function typeItem(name: string, status?: ContentObjectTypeItem['status']): ContentObjectTypeItem {
|
|
58
|
+
return {
|
|
59
|
+
id: `type-${name}`,
|
|
60
|
+
name,
|
|
61
|
+
status,
|
|
62
|
+
} as ContentObjectTypeItem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mockSelectionResult(documentType: string) {
|
|
66
|
+
vi.mocked(executeInteractionFromActivity).mockResolvedValue({
|
|
67
|
+
id: 'run-1',
|
|
68
|
+
modelId: 'model-1',
|
|
69
|
+
result: {
|
|
70
|
+
object: vi.fn(() => ({ document_type: documentType })),
|
|
71
|
+
},
|
|
72
|
+
} as unknown as Awaited<ReturnType<typeof executeInteractionFromActivity>>);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function mockSetup(
|
|
76
|
+
types: ContentObjectTypeItem[],
|
|
77
|
+
options: {
|
|
78
|
+
contentType?: string;
|
|
79
|
+
renditions?: Rendition[];
|
|
80
|
+
text?: string;
|
|
81
|
+
} = {},
|
|
82
|
+
) {
|
|
83
|
+
const { setupActivity } = await import('../dsl/setup/ActivityContext.js');
|
|
84
|
+
const update = vi.fn(() => Promise.resolve({}));
|
|
85
|
+
const retrieveObject = vi.fn(() =>
|
|
86
|
+
Promise.resolve({
|
|
87
|
+
id: 'object-1',
|
|
88
|
+
content: { type: options.contentType ?? 'text/plain' },
|
|
89
|
+
metadata: { renditions: options.renditions },
|
|
90
|
+
text: options.text ?? 'Document text',
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const listTypes = vi.fn(() => Promise.resolve(types));
|
|
94
|
+
|
|
95
|
+
vi.mocked(setupActivity).mockImplementation((activityPayload) =>
|
|
96
|
+
Promise.resolve({
|
|
97
|
+
client: {
|
|
98
|
+
objects: {
|
|
99
|
+
retrieve: retrieveObject,
|
|
100
|
+
update,
|
|
101
|
+
},
|
|
102
|
+
types: {
|
|
103
|
+
catalog: {
|
|
104
|
+
list: listTypes,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
} as unknown as VertesiaClient,
|
|
108
|
+
fetchProject: vi.fn(() => Promise.resolve({ configuration: {} })),
|
|
109
|
+
objectId: 'object-1',
|
|
110
|
+
params: activityPayload.params,
|
|
111
|
+
} as unknown as ActivityContext<GenerateOrAssignContentTypeParams>),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return { listTypes, update };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('generateOrAssignContentType', () => {
|
|
118
|
+
it('excludes draft types from selection and does not assign them if returned by the model', async () => {
|
|
119
|
+
const { update } = await mockSetup([
|
|
120
|
+
typeItem('Invoice', 'active'),
|
|
121
|
+
typeItem('DraftInvoice', 'draft'),
|
|
122
|
+
typeItem('DocumentPart', 'active'),
|
|
123
|
+
typeItem('GenericDocument', 'active'),
|
|
124
|
+
typeItem('Rendition', 'active'),
|
|
125
|
+
]);
|
|
126
|
+
mockSelectionResult('DraftInvoice');
|
|
127
|
+
|
|
128
|
+
const result: GenerateOrAssignContentTypeResult = await testEnv.run(
|
|
129
|
+
generateOrAssignContentType,
|
|
130
|
+
payload({ allowNewContentTypes: false }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(executeInteractionFromActivity).toHaveBeenCalledWith(
|
|
134
|
+
expect.anything(),
|
|
135
|
+
'sys:SelectDocumentType',
|
|
136
|
+
expect.objectContaining({ allowNewContentTypes: false }),
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
existing_types: [
|
|
139
|
+
expect.objectContaining({ name: 'Invoice' }),
|
|
140
|
+
expect.objectContaining({ name: 'GenericDocument' }),
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
expect(update).toHaveBeenCalledWith('object-1', { type: 'sys:GenericDocument' }, { suppressWorkflows: true });
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
id: 'sys:GenericDocument',
|
|
147
|
+
isNew: false,
|
|
148
|
+
name: 'GenericDocument',
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('uses a PDF rendition as visual evidence for PowerPoint type selection', async () => {
|
|
153
|
+
const pdfRendition = {
|
|
154
|
+
name: PDF_RENDITION_NAME,
|
|
155
|
+
content: {
|
|
156
|
+
name: 'slides.pdf',
|
|
157
|
+
source: 'renditions/source-etag/slides.pdf',
|
|
158
|
+
type: 'application/pdf',
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const { update } = await mockSetup(
|
|
162
|
+
[typeItem('MarketPresentation', 'active'), typeItem('GenericDocument', 'active')],
|
|
163
|
+
{
|
|
164
|
+
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
165
|
+
renditions: [pdfRendition],
|
|
166
|
+
text: '',
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
mockSelectionResult('MarketPresentation');
|
|
170
|
+
|
|
171
|
+
const result: GenerateOrAssignContentTypeResult = await testEnv.run(
|
|
172
|
+
generateOrAssignContentType,
|
|
173
|
+
payload({ allowNewContentTypes: false }),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(executeInteractionFromActivity).toHaveBeenCalledWith(
|
|
177
|
+
expect.anything(),
|
|
178
|
+
'sys:SelectDocumentType',
|
|
179
|
+
expect.objectContaining({ allowNewContentTypes: false }),
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
content: undefined,
|
|
182
|
+
image: pdfRendition.content,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
expect(update).toHaveBeenCalledWith(
|
|
186
|
+
'object-1',
|
|
187
|
+
{ type: 'type-MarketPresentation' },
|
|
188
|
+
{ suppressWorkflows: true },
|
|
189
|
+
);
|
|
190
|
+
expect(result).toEqual({
|
|
191
|
+
id: 'type-MarketPresentation',
|
|
192
|
+
isNew: false,
|
|
193
|
+
name: 'MarketPresentation',
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
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:
|
|
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
|
+
});
|