@ossy/platform 3.15.0 → 3.16.0
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/package.json +7 -7
- package/src/audit/audit-helpers.js +34 -6
- package/src/audit/task-run-list.aggregate.js +3 -0
- package/src/audit/task-run-service.js +7 -2
- package/src/audit/task-run.aggregate.js +2 -0
- package/src/file.schema.js +0 -4
- package/src/storage/storage-keys.js +24 -0
- package/src/storage/storage-keys.spec.js +12 -1
- package/src/tasks/in-process-sdk.js +1 -49
- package/src/tasks/in-process-sdk.spec.js +1 -39
- package/src/tasks/task-service.js +270 -41
- package/src/tasks/task-service.spec.js +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"@ossy/config": "^3.0.9",
|
|
49
49
|
"@ossy/event-store": "^3.12.0",
|
|
50
50
|
"@ossy/locale": "^3.4.0",
|
|
51
|
-
"@ossy/manifest": "^3.
|
|
51
|
+
"@ossy/manifest": "^3.16.0",
|
|
52
52
|
"@ossy/observability": "^3.0.9",
|
|
53
53
|
"@ossy/policies": "^3.0.9",
|
|
54
54
|
"@ossy/router": "^3.0.9",
|
|
55
|
-
"@ossy/schema": "^3.
|
|
56
|
-
"@ossy/sdk": "^3.
|
|
55
|
+
"@ossy/schema": "^3.16.0",
|
|
56
|
+
"@ossy/sdk": "^3.16.0",
|
|
57
57
|
"@ossy/tokens": "^3.11.1",
|
|
58
|
-
"@ossy/users": "^3.
|
|
59
|
-
"@ossy/workspaces": "^3.
|
|
58
|
+
"@ossy/users": "^3.16.0",
|
|
59
|
+
"@ossy/workspaces": "^3.16.0",
|
|
60
60
|
"cookie-parser": "^1.4.7",
|
|
61
61
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
62
62
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"Dockerfile",
|
|
78
78
|
"docker-healthcheck.js"
|
|
79
79
|
],
|
|
80
|
-
"gitHead": "
|
|
80
|
+
"gitHead": "1872fb1467bf656284aae35881c34960c3860807"
|
|
81
81
|
}
|
|
@@ -19,14 +19,42 @@ export function hashPayload (payload) {
|
|
|
19
19
|
/** Keep event payloads small — full task results stay outside the audit stream. */
|
|
20
20
|
export function summarizeResult (result) {
|
|
21
21
|
if (result == null) return null
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
return previewValue(result, 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolved task inputs for the run detail. Empty maps are omitted.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} inputs
|
|
29
|
+
* @returns {object | undefined}
|
|
30
|
+
*/
|
|
31
|
+
export function summarizeInputs (inputs) {
|
|
32
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined
|
|
33
|
+
if (!Object.keys(inputs).length) return undefined
|
|
34
|
+
const preview = previewValue(inputs, 0)
|
|
35
|
+
if (!preview || typeof preview !== 'object' || Array.isArray(preview)) return undefined
|
|
36
|
+
if (!Object.keys(preview).length) return undefined
|
|
37
|
+
return preview
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function previewValue (value, depth) {
|
|
41
|
+
if (value == null || typeof value !== 'object') {
|
|
42
|
+
if (typeof value === 'string' && value.length > 240) return `${value.slice(0, 240)}…`
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
if (depth >= 4) {
|
|
46
|
+
return Array.isArray(value)
|
|
47
|
+
? { kind: 'array', length: value.length }
|
|
48
|
+
: { kind: 'object', keyCount: Object.keys(value).length }
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return value.slice(0, 12).map((item) => previewValue(item, depth + 1))
|
|
24
52
|
}
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
const preview = {}
|
|
54
|
+
for (const key of Object.keys(value).slice(0, 24)) {
|
|
55
|
+
preview[key] = previewValue(value[key], depth + 1)
|
|
27
56
|
}
|
|
28
|
-
|
|
29
|
-
return { kind: 'object', keys: keys.slice(0, 20), keyCount: keys.length }
|
|
57
|
+
return preview
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
/** @param {Error | { message?: string, code?: string }} error */
|
|
@@ -42,6 +42,7 @@ export class TaskRunListProjection {
|
|
|
42
42
|
if (event.event === 'Started') {
|
|
43
43
|
entry.status = 'in progress'
|
|
44
44
|
entry.startedAt = event.created
|
|
45
|
+
entry.triggeredBy = event.payload.triggeredBy ?? null
|
|
45
46
|
runs.unshift(entry)
|
|
46
47
|
return { workspaceId, runs: runs.slice(0, 200) }
|
|
47
48
|
}
|
|
@@ -54,6 +55,7 @@ export class TaskRunListProjection {
|
|
|
54
55
|
startedAt: event.created,
|
|
55
56
|
completedAt: event.created,
|
|
56
57
|
durationMs: event.payload.durationMs,
|
|
58
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
57
59
|
})
|
|
58
60
|
return { workspaceId, runs: runs.slice(0, 200) }
|
|
59
61
|
}
|
|
@@ -66,6 +68,7 @@ export class TaskRunListProjection {
|
|
|
66
68
|
durationMs: event.payload.durationMs,
|
|
67
69
|
error: event.event === 'Failed' ? event.payload.error : undefined,
|
|
68
70
|
resultSummary: event.event === 'Completed' ? event.payload.resultSummary : undefined,
|
|
71
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
return { workspaceId, runs }
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveActorId,
|
|
10
10
|
resolveWorkspaceId,
|
|
11
11
|
summarizeError,
|
|
12
|
+
summarizeInputs,
|
|
12
13
|
summarizeResult,
|
|
13
14
|
} from './audit-helpers.js'
|
|
14
15
|
import { rejectOnAbort } from '../request-diagnostics.js'
|
|
@@ -105,6 +106,7 @@ export const TaskRunService = {
|
|
|
105
106
|
actorId,
|
|
106
107
|
durationMs,
|
|
107
108
|
resultSummary: summarizeResult(result),
|
|
109
|
+
inputs: summarizeInputs(context.inputs),
|
|
108
110
|
})
|
|
109
111
|
recordTaskMetrics(taskId, durationMs)
|
|
110
112
|
await MeteringService.record({
|
|
@@ -130,6 +132,7 @@ export const TaskRunService = {
|
|
|
130
132
|
actorId,
|
|
131
133
|
durationMs,
|
|
132
134
|
error: summarizeError(error),
|
|
135
|
+
inputs: summarizeInputs(context.inputs),
|
|
133
136
|
})
|
|
134
137
|
recordTaskMetrics(taskId, durationMs)
|
|
135
138
|
await MeteringService.record({
|
|
@@ -193,7 +196,7 @@ export const TaskRunService = {
|
|
|
193
196
|
}
|
|
194
197
|
},
|
|
195
198
|
|
|
196
|
-
async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary }) {
|
|
199
|
+
async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs }) {
|
|
197
200
|
try {
|
|
198
201
|
await Aggregate.Of(TaskRun, runId)
|
|
199
202
|
.then(Aggregate.Add({
|
|
@@ -202,6 +205,7 @@ export const TaskRunService = {
|
|
|
202
205
|
payload: {
|
|
203
206
|
durationMs,
|
|
204
207
|
resultSummary,
|
|
208
|
+
...(inputs ? { inputs } : {}),
|
|
205
209
|
...taskRunScopePayload({ taskId, workspaceId }),
|
|
206
210
|
},
|
|
207
211
|
}))
|
|
@@ -210,7 +214,7 @@ export const TaskRunService = {
|
|
|
210
214
|
}
|
|
211
215
|
},
|
|
212
216
|
|
|
213
|
-
async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error }) {
|
|
217
|
+
async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs }) {
|
|
214
218
|
try {
|
|
215
219
|
await Aggregate.Of(TaskRun, runId)
|
|
216
220
|
.then(Aggregate.Add({
|
|
@@ -219,6 +223,7 @@ export const TaskRunService = {
|
|
|
219
223
|
payload: {
|
|
220
224
|
durationMs,
|
|
221
225
|
error,
|
|
226
|
+
...(inputs ? { inputs } : {}),
|
|
222
227
|
...taskRunScopePayload({ taskId, workspaceId }),
|
|
223
228
|
},
|
|
224
229
|
}))
|
|
@@ -36,6 +36,7 @@ export class TaskRun {
|
|
|
36
36
|
completedAt: event.created,
|
|
37
37
|
durationMs: event.payload.durationMs,
|
|
38
38
|
resultSummary: event.payload.resultSummary ?? null,
|
|
39
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
case 'Failed':
|
|
@@ -45,6 +46,7 @@ export class TaskRun {
|
|
|
45
46
|
completedAt: event.created,
|
|
46
47
|
durationMs: event.payload.durationMs,
|
|
47
48
|
error: event.payload.error ?? null,
|
|
49
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
default:
|
package/src/file.schema.js
CHANGED
|
@@ -7,9 +7,5 @@ export default {
|
|
|
7
7
|
{ name: 'Key', type: 'text' },
|
|
8
8
|
{ name: 'ContentLength', type: 'number' },
|
|
9
9
|
{ name: 'ContentType', type: 'text' },
|
|
10
|
-
// Populated async by @ossy/media-tasks/tasks/resize-common-web
|
|
11
|
-
{ name: 'blurhash', type: 'text' },
|
|
12
|
-
{ name: 'width', type: 'number' },
|
|
13
|
-
{ name: 'height', type: 'number' },
|
|
14
10
|
],
|
|
15
11
|
}
|
|
@@ -41,6 +41,30 @@ export function derivativeObjectKey (resourceId, variant) {
|
|
|
41
41
|
return `${resourceId}:${variant}`
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Task-scoped derivative key: `{resourceId}:task-{concept}-{artifactName}`.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} resourceId
|
|
48
|
+
* @param {string} taskConcept
|
|
49
|
+
* @param {string} artifactName
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function taskArtifactObjectKey (resourceId, taskConcept, artifactName) {
|
|
53
|
+
if (!taskConcept || typeof taskConcept !== 'string') {
|
|
54
|
+
throw new Error('[storage-keys] taskConcept is required')
|
|
55
|
+
}
|
|
56
|
+
if (!artifactName || typeof artifactName !== 'string') {
|
|
57
|
+
throw new Error('[storage-keys] artifactName is required')
|
|
58
|
+
}
|
|
59
|
+
if (!/^[A-Za-z0-9_-]+$/.test(taskConcept)) {
|
|
60
|
+
throw new Error('[storage-keys] invalid taskConcept')
|
|
61
|
+
}
|
|
62
|
+
if (!/^[A-Za-z0-9_-]+$/.test(artifactName)) {
|
|
63
|
+
throw new Error('[storage-keys] invalid artifactName')
|
|
64
|
+
}
|
|
65
|
+
return derivativeObjectKey(resourceId, `task-${taskConcept}-${artifactName}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
export function assertStorageKey (key) {
|
|
45
69
|
if (!key || typeof key !== 'string' || !KEY_PATTERN.test(key)) {
|
|
46
70
|
throw new Error(`[storage-keys] invalid storage key: ${key}`)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
originalObjectKey,
|
|
4
|
+
derivativeObjectKey,
|
|
5
|
+
taskArtifactObjectKey,
|
|
6
|
+
assertStorageKey,
|
|
7
|
+
coerceStorageKey,
|
|
8
|
+
} from './storage-keys.js'
|
|
3
9
|
|
|
4
10
|
describe('storage-keys', () => {
|
|
5
11
|
it('originalObjectKey returns flat resourceId', () => {
|
|
@@ -10,6 +16,11 @@ describe('storage-keys', () => {
|
|
|
10
16
|
expect(derivativeObjectKey('abc123', 'galleryMedium')).toBe('abc123:galleryMedium')
|
|
11
17
|
})
|
|
12
18
|
|
|
19
|
+
it('taskArtifactObjectKey namespaces by task concept', () => {
|
|
20
|
+
expect(taskArtifactObjectKey('abc123', 'resize-common-web', 'galleryLarge'))
|
|
21
|
+
.toBe('abc123:task-resize-common-web-galleryLarge')
|
|
22
|
+
})
|
|
23
|
+
|
|
13
24
|
it('rejects path-like keys', () => {
|
|
14
25
|
expect(() => assertStorageKey('media/ws/file')).toThrow()
|
|
15
26
|
})
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { resolveActionId } from '@ossy/sdk'
|
|
2
2
|
import { ActionService } from '../actions/action.service.js'
|
|
3
3
|
import { IntegrationService } from '../integration.service.js'
|
|
4
|
-
import { StorageClient } from '../storage/storage.client.js'
|
|
5
|
-
import { derivativeObjectKey } from '../storage/storage-keys.js'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* @param {unknown} file
|
|
9
|
-
* @returns {Promise<Buffer>}
|
|
10
|
-
*/
|
|
11
|
-
async function fileToBuffer (file) {
|
|
12
|
-
if (Buffer.isBuffer(file)) return file
|
|
13
|
-
if (file instanceof Uint8Array) return Buffer.from(file)
|
|
14
|
-
if (typeof file?.arrayBuffer === 'function') {
|
|
15
|
-
return Buffer.from(await file.arrayBuffer())
|
|
16
|
-
}
|
|
17
|
-
throw new Error('[InProcessSdk] uploadNamedVersion expects a File, Blob, Buffer, or Uint8Array')
|
|
18
|
-
}
|
|
19
4
|
|
|
20
5
|
/**
|
|
21
6
|
* Resolve actor/workspace from an eventstore document for nested action invokes.
|
|
@@ -39,8 +24,7 @@ function reqFromEvent (event) {
|
|
|
39
24
|
* Same-process SDK for changestream / scheduled tasks when no HTTP bot
|
|
40
25
|
* credentials (`API_URL` + `OSSY_API_KEY`) are configured.
|
|
41
26
|
*
|
|
42
|
-
* Implements
|
|
43
|
-
* `resources.uploadNamedVersion`, via ActionService + StorageClient.
|
|
27
|
+
* Implements `invoke` via ActionService (task outputs write through patch-content).
|
|
44
28
|
*
|
|
45
29
|
* @param {{ req?: { userId?: string, workspaceId?: string } }} [options]
|
|
46
30
|
*/
|
|
@@ -69,38 +53,6 @@ export function createInProcessSdk (options = {}) {
|
|
|
69
53
|
withEventContext (event) {
|
|
70
54
|
return createInProcessSdk({ req: reqFromEvent(event) })
|
|
71
55
|
},
|
|
72
|
-
|
|
73
|
-
get resources () {
|
|
74
|
-
return {
|
|
75
|
-
/**
|
|
76
|
-
* Patch named derivative metadata, then write bytes to storage.
|
|
77
|
-
* @param {{ id: string, name: string, file: File | Blob | Buffer | Uint8Array }} args
|
|
78
|
-
*/
|
|
79
|
-
async uploadNamedVersion ({ id, name, file }) {
|
|
80
|
-
if (!id) throw new Error('[InProcessSdk] uploadNamedVersion requires id')
|
|
81
|
-
if (!name) throw new Error('[InProcessSdk] uploadNamedVersion requires name')
|
|
82
|
-
if (!file) throw new Error('[InProcessSdk] uploadNamedVersion requires file')
|
|
83
|
-
|
|
84
|
-
const size = typeof file.size === 'number' ? file.size : undefined
|
|
85
|
-
const type = typeof file.type === 'string' ? file.type : undefined
|
|
86
|
-
const buffer = await fileToBuffer(file)
|
|
87
|
-
|
|
88
|
-
const resource = await sdk.invoke('@ossy/resources/actions/upload-named-version', {
|
|
89
|
-
id,
|
|
90
|
-
namedVersion: name,
|
|
91
|
-
// HTTP SDK historically sent `name`; accept either in the task.
|
|
92
|
-
name,
|
|
93
|
-
size: size ?? buffer.length,
|
|
94
|
-
type,
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
// Action response may rewrite `sizes` to read URLs via withResourceMedia;
|
|
98
|
-
// always persist with the canonical derivative key.
|
|
99
|
-
await StorageClient.save(derivativeObjectKey(id, name), buffer)
|
|
100
|
-
return resource
|
|
101
|
-
},
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
56
|
}
|
|
105
57
|
|
|
106
58
|
return sdk
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
2
|
|
|
3
3
|
const invokeMock = jest.fn(async (_id, context) => ({
|
|
4
|
-
id: context.payload.id,
|
|
5
|
-
content: {
|
|
6
|
-
sizes: { [context.payload.namedVersion]: `${context.payload.id}:${context.payload.namedVersion}` },
|
|
7
|
-
uploadUrl: '/local-storage?key=unused',
|
|
8
|
-
},
|
|
4
|
+
id: context.payload.resourceId ?? context.payload.id,
|
|
9
5
|
}))
|
|
10
6
|
|
|
11
|
-
const saveMock = jest.fn(async () => {})
|
|
12
|
-
|
|
13
7
|
jest.unstable_mockModule('../actions/action.service.js', () => ({
|
|
14
8
|
ActionService: { invoke: invokeMock },
|
|
15
9
|
}))
|
|
@@ -18,10 +12,6 @@ jest.unstable_mockModule('../integration.service.js', () => ({
|
|
|
18
12
|
IntegrationService: { get: () => null },
|
|
19
13
|
}))
|
|
20
14
|
|
|
21
|
-
jest.unstable_mockModule('../storage/storage.client.js', () => ({
|
|
22
|
-
StorageClient: { save: saveMock },
|
|
23
|
-
}))
|
|
24
|
-
|
|
25
15
|
const { createInProcessSdk } = await import('./in-process-sdk.js')
|
|
26
16
|
|
|
27
17
|
describe('createInProcessSdk', () => {
|
|
@@ -58,32 +48,4 @@ describe('createInProcessSdk', () => {
|
|
|
58
48
|
}),
|
|
59
49
|
)
|
|
60
50
|
})
|
|
61
|
-
|
|
62
|
-
it('uploadNamedVersion patches then saves bytes', async () => {
|
|
63
|
-
invokeMock.mockClear()
|
|
64
|
-
saveMock.mockClear()
|
|
65
|
-
const sdk = createInProcessSdk()
|
|
66
|
-
const bytes = new Uint8Array([1, 2, 3, 4])
|
|
67
|
-
|
|
68
|
-
await sdk.resources.uploadNamedVersion({
|
|
69
|
-
id: 'file1',
|
|
70
|
-
name: 'thumbnailSmall',
|
|
71
|
-
file: new File([bytes], 'thumbnailSmall', { type: 'image/jpeg' }),
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
expect(invokeMock).toHaveBeenCalledWith(
|
|
75
|
-
'@ossy/resources/actions/upload-named-version',
|
|
76
|
-
expect.objectContaining({
|
|
77
|
-
payload: expect.objectContaining({
|
|
78
|
-
id: 'file1',
|
|
79
|
-
namedVersion: 'thumbnailSmall',
|
|
80
|
-
type: 'image/jpeg',
|
|
81
|
-
}),
|
|
82
|
-
}),
|
|
83
|
-
)
|
|
84
|
-
expect(saveMock).toHaveBeenCalledWith(
|
|
85
|
-
'file1:thumbnailSmall',
|
|
86
|
-
expect.any(Buffer),
|
|
87
|
-
)
|
|
88
|
-
})
|
|
89
51
|
})
|
|
@@ -2,7 +2,7 @@ import { matchesGlob } from './glob.js'
|
|
|
2
2
|
import { matchesCron } from './cron.js'
|
|
3
3
|
import { IntegrationService } from '../integration.service.js'
|
|
4
4
|
import { createLogger } from '@ossy/observability'
|
|
5
|
-
import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
|
|
5
|
+
import { isPrimaryTaskForAction, resolveTaskInputs, taskIdFromActionId } from '@ossy/schema'
|
|
6
6
|
import { TaskRunService } from '../audit/task-run-service.js'
|
|
7
7
|
import { resolveClientIp, runWithEventContext } from '@ossy/event-store'
|
|
8
8
|
|
|
@@ -38,6 +38,27 @@ export class TaskService {
|
|
|
38
38
|
*/
|
|
39
39
|
static _sdk = null
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Optional workspace triggers for configurable tasks.
|
|
43
|
+
* Return `null` when no instance exists; return `[]` when inactive;
|
|
44
|
+
* return trigger objects (definition shape) to OR-match instead of definition triggers.
|
|
45
|
+
* @type {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object[] | null>)}
|
|
46
|
+
*/
|
|
47
|
+
static _instanceTriggersResolver = null
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Optional per-workspace schedules derived from instance on_schedule triggers.
|
|
51
|
+
* Only consulted for configurable tasks.
|
|
52
|
+
* @type {null | ((args: { taskId: string }) => Promise<Array<{ workspaceId: string, schedule: string }>>)}
|
|
53
|
+
*/
|
|
54
|
+
static _instanceScheduleResolver = null
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Optional per-workspace input config for configurable tasks.
|
|
58
|
+
* @type {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object>)}
|
|
59
|
+
*/
|
|
60
|
+
static _instanceConfigResolver = null
|
|
61
|
+
|
|
41
62
|
/**
|
|
42
63
|
* Register the SDK instance that every task will receive as `sdk`.
|
|
43
64
|
* Call this once during application startup before ChangeStream / scheduler begin.
|
|
@@ -47,6 +68,140 @@ export class TaskService {
|
|
|
47
68
|
TaskService._sdk = sdk
|
|
48
69
|
}
|
|
49
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @param {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object[] | null>)} resolver
|
|
73
|
+
*/
|
|
74
|
+
static setInstanceTriggersResolver (resolver) {
|
|
75
|
+
TaskService._instanceTriggersResolver = typeof resolver === 'function' ? resolver : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {null | ((args: { taskId: string }) => Promise<Array<{ workspaceId: string, schedule: string }>>)} resolver
|
|
80
|
+
*/
|
|
81
|
+
static setInstanceScheduleResolver (resolver) {
|
|
82
|
+
TaskService._instanceScheduleResolver = typeof resolver === 'function' ? resolver : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object>)} resolver
|
|
87
|
+
*/
|
|
88
|
+
static setInstanceConfigResolver (resolver) {
|
|
89
|
+
TaskService._instanceConfigResolver = typeof resolver === 'function' ? resolver : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load instance config and resolve declared inputs onto the run context.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} metadata
|
|
96
|
+
* @param {object} context
|
|
97
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
98
|
+
*/
|
|
99
|
+
static async _resolveTaskInputs (metadata, context) {
|
|
100
|
+
const workspaceId = context?.req?.workspaceId
|
|
101
|
+
?? context?.payload?.workspaceId
|
|
102
|
+
?? context?.payload?.belongsTo
|
|
103
|
+
?? context?.event?.belongsTo
|
|
104
|
+
?? context?.event?.payload?.belongsTo
|
|
105
|
+
?? null
|
|
106
|
+
let config = {}
|
|
107
|
+
if (TaskService._instanceConfigResolver && metadata?.id && workspaceId) {
|
|
108
|
+
try {
|
|
109
|
+
const loaded = await TaskService._instanceConfigResolver({
|
|
110
|
+
taskId: metadata.id,
|
|
111
|
+
workspaceId,
|
|
112
|
+
})
|
|
113
|
+
if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded
|
|
114
|
+
} catch (err) {
|
|
115
|
+
_serviceLog.warn(
|
|
116
|
+
`Instance config resolver failed for ${metadata.id}: ${err?.message || err}`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return resolveTaskInputs(metadata?.inputs, {
|
|
121
|
+
payload: context?.payload,
|
|
122
|
+
config,
|
|
123
|
+
triggerConfig: context?.triggerConfig,
|
|
124
|
+
event: context?.event,
|
|
125
|
+
resource: context?.resource,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {object} metadata
|
|
131
|
+
* @param {function} handler
|
|
132
|
+
* @returns {function}
|
|
133
|
+
*/
|
|
134
|
+
static _handlerWithInputs (metadata, handler) {
|
|
135
|
+
return async (context) => {
|
|
136
|
+
context.inputs = await TaskService._resolveTaskInputs(metadata, context)
|
|
137
|
+
return handler(context)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {object | null | undefined} metadata
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
static _isConfigurable (metadata) {
|
|
146
|
+
if (metadata?.configurable === true) return true
|
|
147
|
+
// Legacy metadata still carrying instanceConfig
|
|
148
|
+
if (metadata?.instanceConfig && typeof metadata.instanceConfig === 'object') return true
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} metadata
|
|
154
|
+
* @param {string | null | undefined} workspaceId
|
|
155
|
+
* @returns {Promise<object[] | null>}
|
|
156
|
+
*/
|
|
157
|
+
static async _resolveInstanceTriggers (metadata, workspaceId) {
|
|
158
|
+
if (!TaskService._instanceTriggersResolver || !metadata?.id || !workspaceId) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const triggers = await TaskService._instanceTriggersResolver({
|
|
163
|
+
taskId: metadata.id,
|
|
164
|
+
workspaceId,
|
|
165
|
+
})
|
|
166
|
+
if (triggers == null) return null
|
|
167
|
+
return Array.isArray(triggers) ? triggers : []
|
|
168
|
+
} catch (err) {
|
|
169
|
+
_serviceLog.warn(
|
|
170
|
+
`Instance triggers resolver failed for ${metadata.id}: ${err?.message || err}`,
|
|
171
|
+
)
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Event triggers used for dispatch: instance OR-list when configurable, else definition.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} metadata
|
|
180
|
+
* @param {string | null | undefined} workspaceId
|
|
181
|
+
* @returns {Promise<{ triggers: object[], armed: boolean }>}
|
|
182
|
+
*/
|
|
183
|
+
static async _eventTriggersForDispatch (metadata, workspaceId) {
|
|
184
|
+
if (!TaskService._isConfigurable(metadata)) {
|
|
185
|
+
return {
|
|
186
|
+
triggers: Array.isArray(metadata?.triggers) ? metadata.triggers : [],
|
|
187
|
+
armed: true,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const instanceTriggers = await TaskService._resolveInstanceTriggers(metadata, workspaceId)
|
|
192
|
+
if (!instanceTriggers || instanceTriggers.length === 0) {
|
|
193
|
+
return { triggers: [], armed: false }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
triggers: instanceTriggers.filter((trigger) => {
|
|
198
|
+
const kind = trigger?.trigger ?? trigger?.on ?? trigger?.kind
|
|
199
|
+
return kind !== 'on_action' && kind !== 'on_schedule'
|
|
200
|
+
}),
|
|
201
|
+
armed: true,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
50
205
|
/**
|
|
51
206
|
* Register a task module to be dispatched on matching events
|
|
52
207
|
* and/or on a cron schedule.
|
|
@@ -112,7 +267,7 @@ export class TaskService {
|
|
|
112
267
|
return runWithEventContext({ ip: resolveClientIp(context.req) }, () =>
|
|
113
268
|
TaskRunService.execute({
|
|
114
269
|
taskId: id,
|
|
115
|
-
handler: task.handler,
|
|
270
|
+
handler: TaskService._handlerWithInputs(task.metadata, task.handler),
|
|
116
271
|
context: invokeContext,
|
|
117
272
|
trigger: 'invoke',
|
|
118
273
|
triggeredBy: {
|
|
@@ -146,8 +301,9 @@ export class TaskService {
|
|
|
146
301
|
* Dispatches a single event (fullDocument from the changestream) to all
|
|
147
302
|
* registered tasks whose triggers match.
|
|
148
303
|
* @param {object} event - The eventstore fullDocument.
|
|
304
|
+
* @returns {Promise<void>}
|
|
149
305
|
*/
|
|
150
|
-
static dispatch(event, { sdk } = {}) {
|
|
306
|
+
static async dispatch(event, { sdk } = {}) {
|
|
151
307
|
if (!event) return
|
|
152
308
|
|
|
153
309
|
const baseSdk = sdk ?? TaskService._sdk
|
|
@@ -155,11 +311,14 @@ export class TaskService {
|
|
|
155
311
|
? baseSdk.withEventContext(event)
|
|
156
312
|
: baseSdk
|
|
157
313
|
|
|
314
|
+
const workspaceId = event.payload?.belongsTo ?? event.belongsTo ?? null
|
|
315
|
+
|
|
158
316
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
159
|
-
const triggers = metadata
|
|
160
|
-
|
|
317
|
+
const { triggers, armed } = await TaskService._eventTriggersForDispatch(metadata, workspaceId)
|
|
318
|
+
if (!armed) continue
|
|
161
319
|
|
|
162
|
-
|
|
320
|
+
const matchedTrigger = triggers.find((trigger) => TaskService._matchesEventTrigger(trigger, event))
|
|
321
|
+
if (!matchedTrigger) continue
|
|
163
322
|
|
|
164
323
|
_serviceLog.info(`Dispatching task "${metadata.id}"`)
|
|
165
324
|
|
|
@@ -167,14 +326,15 @@ export class TaskService {
|
|
|
167
326
|
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) },
|
|
168
327
|
{
|
|
169
328
|
event,
|
|
170
|
-
payload: { workspaceId
|
|
329
|
+
payload: { workspaceId },
|
|
330
|
+
triggerConfig: matchedTrigger.config,
|
|
171
331
|
},
|
|
172
332
|
)
|
|
173
333
|
|
|
174
334
|
const _taskId = metadata.id
|
|
175
335
|
TaskRunService.executeAsync({
|
|
176
336
|
taskId: _taskId,
|
|
177
|
-
handler,
|
|
337
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
178
338
|
context: taskContext,
|
|
179
339
|
trigger: 'on_event',
|
|
180
340
|
triggeredBy: {
|
|
@@ -202,8 +362,9 @@ export class TaskService {
|
|
|
202
362
|
* req?: import('express').Request,
|
|
203
363
|
* sdk?: unknown,
|
|
204
364
|
* }} actionContext
|
|
365
|
+
* @returns {Promise<void>}
|
|
205
366
|
*/
|
|
206
|
-
static dispatchOnAction (actionContext) {
|
|
367
|
+
static async dispatchOnAction (actionContext) {
|
|
207
368
|
const {
|
|
208
369
|
actionId,
|
|
209
370
|
actionInvocationId,
|
|
@@ -215,13 +376,18 @@ export class TaskService {
|
|
|
215
376
|
sdk,
|
|
216
377
|
} = actionContext
|
|
217
378
|
|
|
379
|
+
const workspaceId = req?.workspaceId
|
|
380
|
+
?? payload?.workspaceId
|
|
381
|
+
?? payload?.belongsTo
|
|
382
|
+
?? null
|
|
383
|
+
|
|
218
384
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
219
385
|
if (isPrimaryTaskForAction(actionId, metadata.id)) continue
|
|
220
386
|
|
|
221
|
-
const triggers = metadata.triggers
|
|
222
|
-
const
|
|
387
|
+
const triggers = Array.isArray(metadata?.triggers) ? metadata.triggers : []
|
|
388
|
+
const matchedTrigger = triggers.find((trigger) => TaskService._matchesActionTrigger(trigger, actionId))
|
|
223
389
|
|
|
224
|
-
if (!
|
|
390
|
+
if (!matchedTrigger) continue
|
|
225
391
|
|
|
226
392
|
_serviceLog.info(`Dispatching on_action task "${metadata.id}" for action "${actionId}"`)
|
|
227
393
|
|
|
@@ -233,6 +399,7 @@ export class TaskService {
|
|
|
233
399
|
req,
|
|
234
400
|
actionInvocationId,
|
|
235
401
|
payload,
|
|
402
|
+
triggerConfig: matchedTrigger.config,
|
|
236
403
|
},
|
|
237
404
|
{
|
|
238
405
|
action: {
|
|
@@ -247,7 +414,7 @@ export class TaskService {
|
|
|
247
414
|
const _taskId = metadata.id
|
|
248
415
|
TaskRunService.executeAsync({
|
|
249
416
|
taskId: _taskId,
|
|
250
|
-
handler,
|
|
417
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
251
418
|
context: taskContext,
|
|
252
419
|
trigger: 'on_action',
|
|
253
420
|
triggeredBy: {
|
|
@@ -261,16 +428,61 @@ export class TaskService {
|
|
|
261
428
|
}
|
|
262
429
|
}
|
|
263
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Collect cron jobs for a task.
|
|
433
|
+
* System tasks: definition schedule only.
|
|
434
|
+
* Configurable tasks: instance on_schedule triggers only (inactive when none).
|
|
435
|
+
*
|
|
436
|
+
* @param {object} metadata
|
|
437
|
+
* @returns {Promise<Array<{ workspaceId: string | null, schedule: string }>>}
|
|
438
|
+
*/
|
|
439
|
+
static async _scheduledJobsFor (metadata) {
|
|
440
|
+
/** @type {Array<{ workspaceId: string | null, schedule: string }>} */
|
|
441
|
+
const jobs = []
|
|
442
|
+
|
|
443
|
+
if (TaskService._isConfigurable(metadata)) {
|
|
444
|
+
if (TaskService._instanceScheduleResolver && metadata?.id) {
|
|
445
|
+
try {
|
|
446
|
+
const fromInstances = await TaskService._instanceScheduleResolver({ taskId: metadata.id })
|
|
447
|
+
for (const job of fromInstances || []) {
|
|
448
|
+
if (!job?.workspaceId || typeof job.schedule !== 'string' || !job.schedule.trim()) continue
|
|
449
|
+
jobs.push({
|
|
450
|
+
workspaceId: job.workspaceId,
|
|
451
|
+
schedule: job.schedule.trim(),
|
|
452
|
+
...(job.config ? { config: job.config } : {}),
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
_serviceLog.warn(
|
|
457
|
+
`Instance schedule resolver failed for ${metadata.id}: ${err?.message || err}`,
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return jobs
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (typeof metadata?.schedule === 'string' && metadata.schedule.trim()) {
|
|
465
|
+
jobs.push({ workspaceId: null, schedule: metadata.schedule.trim() })
|
|
466
|
+
}
|
|
467
|
+
for (const trigger of metadata?.triggers ?? []) {
|
|
468
|
+
const kind = trigger?.trigger ?? trigger?.on ?? trigger?.kind
|
|
469
|
+
const schedule = typeof trigger?.schedule === 'string' ? trigger.schedule.trim() : ''
|
|
470
|
+
if ((kind === 'on_schedule' || (!kind && schedule)) && schedule) {
|
|
471
|
+
jobs.push({ workspaceId: null, schedule })
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return jobs
|
|
475
|
+
}
|
|
476
|
+
|
|
264
477
|
/**
|
|
265
478
|
* Starts a 60-second interval that fires each scheduled task whose cron
|
|
266
479
|
* expression matches the current minute. Calls the handler directly
|
|
267
480
|
* (no synthetic MongoDB events).
|
|
268
481
|
*/
|
|
269
482
|
static startScheduler() {
|
|
270
|
-
|
|
271
|
-
if (scheduledTasks.length === 0) return
|
|
483
|
+
if (TaskService._tasks.length === 0) return
|
|
272
484
|
|
|
273
|
-
_serviceLog.info(`Scheduler started for ${
|
|
485
|
+
_serviceLog.info(`Scheduler started for ${TaskService._tasks.length} registered task(s)`)
|
|
274
486
|
|
|
275
487
|
TaskService._stopped = false
|
|
276
488
|
TaskService._schedulerInterval = setInterval(() => {
|
|
@@ -278,27 +490,44 @@ export class TaskService {
|
|
|
278
490
|
|
|
279
491
|
const now = new Date()
|
|
280
492
|
|
|
281
|
-
for (const { metadata, handler } of
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
493
|
+
for (const { metadata, handler } of TaskService._tasks) {
|
|
494
|
+
TaskService._scheduledJobsFor(metadata).then((jobs) => {
|
|
495
|
+
if (TaskService._stopped) return
|
|
496
|
+
for (const job of jobs) {
|
|
497
|
+
if (!matchesCron(job.schedule, now)) continue
|
|
498
|
+
|
|
499
|
+
_serviceLog.info(
|
|
500
|
+
`Schedule fired for task "${metadata.id}"`
|
|
501
|
+
+ (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
const taskContext = {
|
|
505
|
+
event: { type: 'scheduled', taskId: metadata.id },
|
|
506
|
+
sdk: TaskService._sdk,
|
|
507
|
+
integrations: IntegrationService,
|
|
508
|
+
log: createLogger(metadata.id),
|
|
509
|
+
payload: job.workspaceId ? { workspaceId: job.workspaceId } : undefined,
|
|
510
|
+
req: job.workspaceId ? { workspaceId: job.workspaceId, userId: 'system' } : undefined,
|
|
511
|
+
triggerConfig: job.config,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
TaskRunService.executeAsync({
|
|
515
|
+
taskId: metadata.id,
|
|
516
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
517
|
+
context: taskContext,
|
|
518
|
+
trigger: 'on_schedule',
|
|
519
|
+
triggeredBy: {
|
|
520
|
+
schedule: job.schedule,
|
|
521
|
+
...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
|
|
522
|
+
},
|
|
523
|
+
executionEnv: 'ossy_server',
|
|
524
|
+
audit: metadata.audit !== false,
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
}).catch((err) => {
|
|
528
|
+
_serviceLog.warn(
|
|
529
|
+
`Scheduler tick failed for ${metadata.id}: ${err?.message || err}`,
|
|
530
|
+
)
|
|
302
531
|
})
|
|
303
532
|
}
|
|
304
533
|
}, SCHEDULER_INTERVAL_MS)
|
|
@@ -322,7 +551,7 @@ export class TaskService {
|
|
|
322
551
|
* @param {string} actionId
|
|
323
552
|
*/
|
|
324
553
|
static _matchesActionTrigger (trigger, actionId) {
|
|
325
|
-
const kind = trigger.trigger ?? trigger.on
|
|
554
|
+
const kind = trigger.trigger ?? trigger.on ?? trigger.kind
|
|
326
555
|
if (kind !== 'on_action') return false
|
|
327
556
|
|
|
328
557
|
const pattern = trigger.action
|
|
@@ -335,11 +564,11 @@ export class TaskService {
|
|
|
335
564
|
* Returns true when an event trigger descriptor matches the given eventstore document.
|
|
336
565
|
*
|
|
337
566
|
* ADR 0008: `trigger.type` = schemaId, `trigger.event` = lifecycle/custom name.
|
|
338
|
-
* Skips `on_action` triggers (handled
|
|
567
|
+
* Skips `on_action` / `on_schedule` triggers (handled elsewhere).
|
|
339
568
|
*/
|
|
340
569
|
static _matchesEventTrigger (trigger, event) {
|
|
341
|
-
const kind = trigger.trigger ?? trigger.on
|
|
342
|
-
if (kind === 'on_action') return false
|
|
570
|
+
const kind = trigger.trigger ?? trigger.on ?? trigger.kind
|
|
571
|
+
if (kind === 'on_action' || kind === 'on_schedule') return false
|
|
343
572
|
|
|
344
573
|
if (trigger.excludeTypes?.includes(event.type)) return false
|
|
345
574
|
|
|
@@ -63,6 +63,73 @@ describe('TaskService triggers', () => {
|
|
|
63
63
|
)).toBe(true)
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
it('AND-narrows events with instance watch locations and MIME', () => {
|
|
67
|
+
const event = {
|
|
68
|
+
type: '@ossy/platform/schema/file',
|
|
69
|
+
event: 'Created',
|
|
70
|
+
resourceId: 'f1',
|
|
71
|
+
payload: {
|
|
72
|
+
belongsTo: 'ws1',
|
|
73
|
+
location: '/incoming/photos/a.jpg',
|
|
74
|
+
content: { ContentType: 'image/jpeg' },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(TaskService._matchesEventTrigger({
|
|
79
|
+
kind: 'on_event',
|
|
80
|
+
type: '@ossy/platform/schema/file',
|
|
81
|
+
event: 'Created',
|
|
82
|
+
location: { startsWith: '/incoming/' },
|
|
83
|
+
match: { 'content.ContentType': 'image/*' },
|
|
84
|
+
}, event)).toBe(true)
|
|
85
|
+
|
|
86
|
+
expect(TaskService._matchesEventTrigger({
|
|
87
|
+
kind: 'on_event',
|
|
88
|
+
type: '@ossy/platform/schema/file',
|
|
89
|
+
event: 'Created',
|
|
90
|
+
location: { startsWith: '/other/' },
|
|
91
|
+
}, event)).toBe(false)
|
|
92
|
+
|
|
93
|
+
expect(TaskService._matchesEventTrigger({
|
|
94
|
+
kind: 'on_event',
|
|
95
|
+
type: '@ossy/platform/schema/file',
|
|
96
|
+
event: 'Created',
|
|
97
|
+
match: { 'content.ContentType': 'video/*' },
|
|
98
|
+
}, event)).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('treats configurable tasks without instance triggers as unarmed', async () => {
|
|
102
|
+
TaskService.setInstanceTriggersResolver(async () => [])
|
|
103
|
+
const result = await TaskService._eventTriggersForDispatch(
|
|
104
|
+
{ id: 't1', configurable: true, triggers: [{ type: 'x', event: 'Created' }] },
|
|
105
|
+
'ws1',
|
|
106
|
+
)
|
|
107
|
+
expect(result.armed).toBe(false)
|
|
108
|
+
expect(result.triggers).toEqual([])
|
|
109
|
+
TaskService.setInstanceTriggersResolver(null)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('uses instance triggers for configurable tasks', async () => {
|
|
113
|
+
const instanceTriggers = [{
|
|
114
|
+
kind: 'on_event',
|
|
115
|
+
type: '@ossy/platform/schema/file',
|
|
116
|
+
event: 'Created',
|
|
117
|
+
location: { startsWith: '/incoming/' },
|
|
118
|
+
}]
|
|
119
|
+
TaskService.setInstanceTriggersResolver(async () => instanceTriggers)
|
|
120
|
+
const result = await TaskService._eventTriggersForDispatch(
|
|
121
|
+
{
|
|
122
|
+
id: 't1',
|
|
123
|
+
configurable: true,
|
|
124
|
+
triggers: [{ type: '@ossy/platform/schema/file', event: 'Created' }],
|
|
125
|
+
},
|
|
126
|
+
'ws1',
|
|
127
|
+
)
|
|
128
|
+
expect(result.armed).toBe(true)
|
|
129
|
+
expect(result.triggers).toEqual(instanceTriggers)
|
|
130
|
+
TaskService.setInstanceTriggersResolver(null)
|
|
131
|
+
})
|
|
132
|
+
|
|
66
133
|
it('skips primary derived task in on_action dispatch', () => {
|
|
67
134
|
expect(isPrimaryTaskForAction('@ossy/booking/actions/create', '@ossy/booking/tasks/create')).toBe(true)
|
|
68
135
|
expect(isPrimaryTaskForAction('@ossy/booking/actions/create', '@ossy/booking/tasks/send-reminder')).toBe(false)
|
|
@@ -120,6 +187,11 @@ describe('TaskRunList projection', () => {
|
|
|
120
187
|
moduleId: 'media-tasks',
|
|
121
188
|
trigger: 'on_event',
|
|
122
189
|
belongsTo: 'ws1',
|
|
190
|
+
triggeredBy: {
|
|
191
|
+
resourceId: 'file1',
|
|
192
|
+
type: '@ossy/platform/schema/file',
|
|
193
|
+
event: 'Created',
|
|
194
|
+
},
|
|
123
195
|
},
|
|
124
196
|
}
|
|
125
197
|
|
|
@@ -127,6 +199,11 @@ describe('TaskRunList projection', () => {
|
|
|
127
199
|
state = TaskRunListProjection.Apply(started, state)
|
|
128
200
|
expect(state.runs).toHaveLength(1)
|
|
129
201
|
expect(state.runs[0].status).toBe('in progress')
|
|
202
|
+
expect(state.runs[0].triggeredBy).toEqual({
|
|
203
|
+
resourceId: 'file1',
|
|
204
|
+
type: '@ossy/platform/schema/file',
|
|
205
|
+
event: 'Created',
|
|
206
|
+
})
|
|
130
207
|
|
|
131
208
|
state = TaskRunListProjection.Apply({
|
|
132
209
|
resourceId: 'run1',
|
|
@@ -145,6 +222,37 @@ describe('TaskRunList projection', () => {
|
|
|
145
222
|
expect(state.runs[0].durationMs).toBe(1000)
|
|
146
223
|
})
|
|
147
224
|
|
|
225
|
+
it('keeps resolved inputs on the listed run', () => {
|
|
226
|
+
let state = TaskRunListProjection.initialState('ws1')
|
|
227
|
+
state = TaskRunListProjection.Apply({
|
|
228
|
+
resourceId: 'run-inputs',
|
|
229
|
+
type: '@ossy/platform/schema/task-run',
|
|
230
|
+
event: 'Started',
|
|
231
|
+
created: 1000,
|
|
232
|
+
payload: {
|
|
233
|
+
taskId: '@ossy/media-tasks/tasks/visual-content-descriptors',
|
|
234
|
+
moduleId: 'media-tasks',
|
|
235
|
+
trigger: 'on_event',
|
|
236
|
+
belongsTo: 'ws1',
|
|
237
|
+
},
|
|
238
|
+
}, state)
|
|
239
|
+
state = TaskRunListProjection.Apply({
|
|
240
|
+
resourceId: 'run-inputs',
|
|
241
|
+
event: 'Completed',
|
|
242
|
+
created: 2000,
|
|
243
|
+
payload: {
|
|
244
|
+
durationMs: 40,
|
|
245
|
+
resultSummary: { title: 'A cat' },
|
|
246
|
+
inputs: { model: 'google/gemini-2.5-flash-lite' },
|
|
247
|
+
belongsTo: 'ws1',
|
|
248
|
+
taskId: '@ossy/media-tasks/tasks/visual-content-descriptors',
|
|
249
|
+
moduleId: 'media-tasks',
|
|
250
|
+
},
|
|
251
|
+
}, state)
|
|
252
|
+
|
|
253
|
+
expect(state.runs[0].inputs).toEqual({ model: 'google/gemini-2.5-flash-lite' })
|
|
254
|
+
})
|
|
255
|
+
|
|
148
256
|
it('scopes Completed/Failed from payload.belongsTo (changestream dispatch)', () => {
|
|
149
257
|
expect(TaskRunListProjection.scopeFromEvent({
|
|
150
258
|
payload: { durationMs: 10 },
|