@ossy/platform 3.15.0 → 3.17.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 +9 -8
- package/src/audit/audit-helpers.js +60 -6
- package/src/audit/audit-helpers.spec.js +42 -0
- package/src/audit/task-run-list.aggregate.js +3 -0
- package/src/audit/task-run-service.js +10 -2
- package/src/audit/task-run.aggregate.js +2 -0
- package/src/file.schema.js +0 -4
- package/src/metering/assert-llm-usage-budget.js +83 -0
- package/src/metering/assert-llm-usage-budget.spec.js +129 -0
- package/src/metering/get-workspace-llm-usage.action.js +5 -0
- package/src/metering/get-workspace-llm-usage.task.js +31 -0
- package/src/metering/get-workspace-llm-usage.task.spec.js +74 -0
- package/src/metering/get-workspace-task-metering.action.js +5 -0
- package/src/metering/get-workspace-task-metering.task.js +30 -0
- package/src/metering/get-workspace-task-metering.task.spec.js +43 -0
- package/src/metering/index.js +11 -0
- package/src/metering/llm-usage.aggregate.js +127 -0
- package/src/metering/llm-usage.aggregate.spec.js +95 -0
- package/src/metering/metering-service.js +13 -0
- package/src/metering/metering-service.spec.js +62 -0
- package/src/metering/task-metering.aggregate.js +108 -0
- package/src/metering/task-metering.aggregate.spec.js +71 -0
- package/src/metering/usage-month.js +7 -0
- package/src/runtime.js +2 -0
- package/src/server.js +2 -0
- package/src/ssr/detect-mobile-device.js +39 -0
- package/src/ssr/detect-mobile-device.spec.js +50 -0
- 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/src/text-completion.schema.js +37 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { detectMobileDevice, parseSecChUaMobile } from './detect-mobile-device.js'
|
|
3
|
+
|
|
4
|
+
describe('parseSecChUaMobile', () => {
|
|
5
|
+
it('returns true for the mobile client hint', () => {
|
|
6
|
+
expect(parseSecChUaMobile('?1')).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns false for the desktop client hint', () => {
|
|
10
|
+
expect(parseSecChUaMobile('?0')).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns null when the header is missing or malformed', () => {
|
|
14
|
+
expect(parseSecChUaMobile(undefined)).toBeNull()
|
|
15
|
+
expect(parseSecChUaMobile(null)).toBeNull()
|
|
16
|
+
expect(parseSecChUaMobile('')).toBeNull()
|
|
17
|
+
expect(parseSecChUaMobile('mobile')).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('tolerates surrounding whitespace', () => {
|
|
21
|
+
expect(parseSecChUaMobile(' ?1 ')).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('detectMobileDevice', () => {
|
|
26
|
+
it('prefers the Sec-CH-UA-Mobile client hint when present', () => {
|
|
27
|
+
const req = { headers: { 'sec-ch-ua-mobile': '?1', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0)' } }
|
|
28
|
+
expect(detectMobileDevice(req)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('falls back to sniffing User-Agent when the client hint is absent (e.g. Safari/Firefox)', () => {
|
|
32
|
+
const req = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit' } }
|
|
33
|
+
expect(detectMobileDevice(req)).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('treats desktop User-Agents as non-mobile when the client hint is absent', () => {
|
|
37
|
+
const req = { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit' } }
|
|
38
|
+
expect(detectMobileDevice(req)).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('supports Express-style req.get(...) as a fallback lookup', () => {
|
|
42
|
+
const req = { headers: {}, get: (name) => (name === 'Sec-CH-UA-Mobile' ? '?1' : undefined) }
|
|
43
|
+
expect(detectMobileDevice(req)).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('defaults to false when no request or headers are available', () => {
|
|
47
|
+
expect(detectMobileDevice(undefined)).toBe(false)
|
|
48
|
+
expect(detectMobileDevice({})).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -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
|
|