@ossy/platform 1.39.2 → 1.39.3
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/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +1 -0
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +79 -6
- package/src/audit/action-invocation-service.js +144 -0
- package/src/audit/action-invocation.aggregate.js +59 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +78 -0
- package/src/audit/task-run-service.js +193 -0
- package/src/audit/task-run.aggregate.js +60 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +9 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +13 -0
- package/src/index.js +13 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +92 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +43 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +4 -1
- package/src/schema-ids.js +9 -0
- package/src/server.js +121 -20
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +9 -2
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +14 -2
- package/src/resources/resource-template.registry.js +0 -29
- package/src/resources/resource-template.validation.js +0 -232
package/src/storage/s3.client.js
CHANGED
|
@@ -2,8 +2,9 @@ import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-
|
|
|
2
2
|
import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
|
|
3
3
|
import { createRequest } from '@aws-sdk/util-create-request'
|
|
4
4
|
import { formatUrl } from '@aws-sdk/util-format-url'
|
|
5
|
-
import { ConfigService } from '../config.service.js'
|
|
6
5
|
import { createLogger } from '@ossy/observability'
|
|
6
|
+
import { createResourceReadUrl } from './resource-read-url.js'
|
|
7
|
+
import { ConfigService } from '../config.service.js'
|
|
7
8
|
|
|
8
9
|
const log = createLogger('storage')
|
|
9
10
|
|
|
@@ -30,35 +31,18 @@ function normalizeKey(keyOrShape) {
|
|
|
30
31
|
export class S3Client {
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
*
|
|
34
|
+
* App read URL — same shape as local filesystem (`/r/{resourceId}`).
|
|
34
35
|
*/
|
|
35
36
|
static createDownloadUrl(keyOrShape) {
|
|
36
37
|
const Key = normalizeKey(keyOrShape)
|
|
37
|
-
|
|
38
|
-
const base = ConfigService.MediaCdnDomainName || ''
|
|
39
|
-
return `${base.replace(/\/$/, '')}/${pathAfterMedia}`
|
|
38
|
+
return createResourceReadUrl(Key)
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
*
|
|
42
|
+
* Same as createDownloadUrl — auth is enforced by `/r/` route handlers.
|
|
44
43
|
*/
|
|
45
|
-
static createPresignedDownloadUrl(keyOrShape
|
|
46
|
-
|
|
47
|
-
const Bucket = ConfigService.MediaRepository
|
|
48
|
-
if (!Bucket) {
|
|
49
|
-
return Promise.reject(new Error('[S3Client] MEDIA_REPOSITORY is not configured'))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const s3Client = createS3Client()
|
|
53
|
-
const signedRequest = new S3RequestPresigner(s3Client.config)
|
|
54
|
-
|
|
55
|
-
return createRequest(s3Client, new GetObjectCommand({ Bucket, Key }))
|
|
56
|
-
.then(request => signedRequest.presign(request, { expiresIn }))
|
|
57
|
-
.then(formatUrl)
|
|
58
|
-
.catch(error => {
|
|
59
|
-
log.error('S3Client.createPresignedDownloadUrl error', undefined, error)
|
|
60
|
-
return Promise.reject(error)
|
|
61
|
-
})
|
|
44
|
+
static createPresignedDownloadUrl(keyOrShape) {
|
|
45
|
+
return Promise.resolve(S3Client.createDownloadUrl(keyOrShape))
|
|
62
46
|
}
|
|
63
47
|
|
|
64
48
|
static createUploadUrl({ ContentType, ContentLength, Key, Bucket, expiresIn = 60 * 60 * 24 }) {
|
|
@@ -97,4 +81,24 @@ export class S3Client {
|
|
|
97
81
|
return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key }))
|
|
98
82
|
}
|
|
99
83
|
|
|
84
|
+
static async load(keyOrShape) {
|
|
85
|
+
const Key = normalizeKey(keyOrShape)
|
|
86
|
+
const Bucket = ConfigService.MediaRepository
|
|
87
|
+
if (!Bucket) {
|
|
88
|
+
return { buffer: null, exists: false }
|
|
89
|
+
}
|
|
90
|
+
const s3Client = createS3Client()
|
|
91
|
+
try {
|
|
92
|
+
const response = await s3Client.send(new GetObjectCommand({ Bucket, Key }))
|
|
93
|
+
const bytes = await response.Body?.transformToByteArray?.()
|
|
94
|
+
if (!bytes) return { buffer: null, exists: false }
|
|
95
|
+
return { buffer: Buffer.from(bytes), exists: true }
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
|
|
98
|
+
return { buffer: null, exists: false }
|
|
99
|
+
}
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
100
104
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat blob keys — folder hierarchy lives in Mongo resource metadata (`location`), not in storage.
|
|
3
|
+
*
|
|
4
|
+
* Original binary: `{resourceId}`
|
|
5
|
+
* Derivatives (thumbnails, etc.): `{resourceId}:{variant}`
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)?$/
|
|
9
|
+
|
|
10
|
+
export function originalObjectKey (resourceId) {
|
|
11
|
+
assertResourceId(resourceId)
|
|
12
|
+
return resourceId
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function derivativeObjectKey (resourceId, variant) {
|
|
16
|
+
assertResourceId(resourceId)
|
|
17
|
+
if (!variant || typeof variant !== 'string') {
|
|
18
|
+
throw new Error('[storage-keys] variant is required')
|
|
19
|
+
}
|
|
20
|
+
if (!/^[A-Za-z0-9_-]+$/.test(variant)) {
|
|
21
|
+
throw new Error('[storage-keys] invalid variant')
|
|
22
|
+
}
|
|
23
|
+
return `${resourceId}:${variant}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertStorageKey (key) {
|
|
27
|
+
if (!key || typeof key !== 'string' || !KEY_PATTERN.test(key)) {
|
|
28
|
+
throw new Error(`[storage-keys] invalid storage key: ${key}`)
|
|
29
|
+
}
|
|
30
|
+
return key
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertResourceId (resourceId) {
|
|
34
|
+
if (!resourceId || typeof resourceId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(resourceId)) {
|
|
35
|
+
throw new Error(`[storage-keys] invalid resourceId: ${resourceId}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage-keys.js'
|
|
3
|
+
|
|
4
|
+
describe('storage-keys', () => {
|
|
5
|
+
it('originalObjectKey returns flat resourceId', () => {
|
|
6
|
+
expect(originalObjectKey('abc123XYZ')).toBe('abc123XYZ')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('derivativeObjectKey uses resourceId:variant', () => {
|
|
10
|
+
expect(derivativeObjectKey('abc123', 'galleryMedium')).toBe('abc123:galleryMedium')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('rejects path-like keys', () => {
|
|
14
|
+
expect(() => assertStorageKey('media/ws/file')).toThrow()
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -1,12 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { LocalStorageClient } from './local-storage.client.js'
|
|
1
|
+
import { IntegrationService } from '../integration.service.js'
|
|
3
2
|
import { ConfigService } from '../config.service.js'
|
|
3
|
+
import { createFilesystemStorageClient } from './filesystem-storage.client.js'
|
|
4
|
+
import { createS3StorageClient } from './s3-storage.client.js'
|
|
5
|
+
|
|
6
|
+
/** @type {import('./filesystem-storage.client.js').createFilesystemStorageClient extends (...args: any) => infer R ? R : never | null} */
|
|
7
|
+
let fallbackClient = null
|
|
8
|
+
|
|
9
|
+
function getFallbackClient () {
|
|
10
|
+
if (!fallbackClient) {
|
|
11
|
+
if (ConfigService.MediaRepository && ConfigService.awsAccessKeyId && ConfigService.awsSecretAccessKey) {
|
|
12
|
+
fallbackClient = createS3StorageClient({
|
|
13
|
+
bucket: ConfigService.MediaRepository,
|
|
14
|
+
cdnDomainName: ConfigService.MediaCdnDomainName,
|
|
15
|
+
accessKeyId: ConfigService.awsAccessKeyId,
|
|
16
|
+
secretAccessKey: ConfigService.awsSecretAccessKey,
|
|
17
|
+
})
|
|
18
|
+
} else {
|
|
19
|
+
fallbackClient = createFilesystemStorageClient()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return fallbackClient
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStorageClient () {
|
|
26
|
+
return IntegrationService.get('storage') ?? getFallbackClient()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bindMethod (name) {
|
|
30
|
+
return (...args) => {
|
|
31
|
+
const client = getStorageClient()
|
|
32
|
+
const fn = client[name]
|
|
33
|
+
if (typeof fn !== 'function') {
|
|
34
|
+
throw new Error(`[StorageClient] storage backend missing method "${name}"`)
|
|
35
|
+
}
|
|
36
|
+
return fn.apply(client, args)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
4
39
|
|
|
5
40
|
/**
|
|
6
|
-
* Active storage backend.
|
|
7
|
-
*
|
|
8
|
-
* - When `MEDIA_REPOSITORY` is set (production): delegates to S3.
|
|
9
|
-
* - When `MEDIA_REPOSITORY` is unset (local dev): delegates to LocalStorageClient,
|
|
10
|
-
* which stores files in /tmp/ossy-local-media/ and serves them via GET /local-storage.
|
|
41
|
+
* Active primary storage backend (filesystem or S3 via `storage` integration).
|
|
11
42
|
*/
|
|
12
|
-
export const StorageClient =
|
|
43
|
+
export const StorageClient = {
|
|
44
|
+
get backend () {
|
|
45
|
+
return getStorageClient().backend
|
|
46
|
+
},
|
|
47
|
+
createUploadUrl: bindMethod('createUploadUrl'),
|
|
48
|
+
createPresignedDownloadUrl: bindMethod('createPresignedDownloadUrl'),
|
|
49
|
+
createDownloadUrl: bindMethod('createDownloadUrl'),
|
|
50
|
+
load: bindMethod('load'),
|
|
51
|
+
headObject: bindMethod('headObject'),
|
|
52
|
+
save: bindMethod('save'),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { LocalStorageClient } from './filesystem-storage.client.js'
|
|
56
|
+
export { S3Client } from './s3.client.js'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
import { createFilesystemStorageClient } from './filesystem-storage.client.js'
|
|
3
|
+
import { createS3StorageClient } from './s3-storage.client.js'
|
|
4
|
+
|
|
5
|
+
const log = createLogger('storage')
|
|
6
|
+
|
|
7
|
+
export const id = 'storage'
|
|
8
|
+
|
|
9
|
+
/** Always connect — filesystem when S3 is not configured. */
|
|
10
|
+
export const credentials = []
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Primary blob storage: S3 when MEDIA_REPOSITORY is set, otherwise local filesystem.
|
|
14
|
+
*
|
|
15
|
+
* Logical keys are flat resource ids (see storage-keys.js). Mongo holds folder paths.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ env: NodeJS.ProcessEnv }} opts
|
|
18
|
+
*/
|
|
19
|
+
export async function connect ({ env }) {
|
|
20
|
+
const bucket = env.MEDIA_REPOSITORY
|
|
21
|
+
|
|
22
|
+
if (bucket) {
|
|
23
|
+
const missing = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].filter((key) => !(key in env))
|
|
24
|
+
if (missing.length) {
|
|
25
|
+
log.warn(`[storage] MEDIA_REPOSITORY set but missing ${missing.join(', ')} — using filesystem fallback`)
|
|
26
|
+
} else {
|
|
27
|
+
log.info('[storage] Using S3 backend')
|
|
28
|
+
return createS3StorageClient({
|
|
29
|
+
bucket,
|
|
30
|
+
cdnDomainName: env.MEDIA_CDN_DOMAIN_NAME,
|
|
31
|
+
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
32
|
+
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const client = createFilesystemStorageClient({ env })
|
|
38
|
+
log.info(`[storage] Using filesystem backend at ${client.root}`)
|
|
39
|
+
return client
|
|
40
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TaskService } from './task-service.js'
|
|
2
2
|
import { createLogger } from '@ossy/observability'
|
|
3
|
-
import { resolveMongoUrl } from '@ossy/event-store'
|
|
3
|
+
import { ProjectionRebuild, PushInvalidation, resolveMongoUrl } from '@ossy/event-store'
|
|
4
4
|
|
|
5
5
|
const log = createLogger('platform')
|
|
6
6
|
|
|
@@ -121,7 +121,14 @@ export class ChangeStream {
|
|
|
121
121
|
|
|
122
122
|
changeStream.on('change', (change) => {
|
|
123
123
|
log.info('[ChangeStream] Change detected')
|
|
124
|
-
|
|
124
|
+
const event = change.fullDocument
|
|
125
|
+
if (event) {
|
|
126
|
+
ProjectionRebuild.dispatch(event).catch((error) => {
|
|
127
|
+
log.error('[ChangeStream] Projection rebuild failed', undefined, error)
|
|
128
|
+
})
|
|
129
|
+
PushInvalidation.publish(event)
|
|
130
|
+
}
|
|
131
|
+
TaskService.dispatch(event)
|
|
125
132
|
})
|
|
126
133
|
|
|
127
134
|
changeStream.on('close', () => {
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { matchesGlob } from './glob.js'
|
|
2
2
|
import { matchesCron } from './cron.js'
|
|
3
3
|
import { IntegrationService } from '../integration.service.js'
|
|
4
|
-
import { createLogger
|
|
4
|
+
import { createLogger } from '@ossy/observability'
|
|
5
|
+
import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
|
|
6
|
+
import { TaskRunService } from '../audit/task-run-service.js'
|
|
5
7
|
|
|
6
8
|
const SCHEDULER_INTERVAL_MS = 60_000
|
|
7
9
|
const _serviceLog = createLogger('TaskService')
|
|
8
10
|
|
|
11
|
+
function buildTaskContext (baseContext, extras = {}) {
|
|
12
|
+
return {
|
|
13
|
+
...baseContext,
|
|
14
|
+
...extras,
|
|
15
|
+
payload: baseContext.payload ?? extras.payload,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export class TaskService {
|
|
10
20
|
|
|
11
21
|
static _stopped = false
|
|
@@ -80,13 +90,44 @@ export class TaskService {
|
|
|
80
90
|
* Synchronously invoke a task by id (HTTP / sdk.invoke path).
|
|
81
91
|
*
|
|
82
92
|
* @param {string} id
|
|
83
|
-
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
|
|
93
|
+
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown, actionInvocationId?: string }} context
|
|
84
94
|
* @returns {Promise<unknown>}
|
|
85
95
|
*/
|
|
86
96
|
static async invoke(id, context = {}) {
|
|
87
97
|
const task = TaskService._tasksById.get(id)
|
|
88
98
|
if (!task) throw new Error(`[TaskService] Task not found: "${id}"`)
|
|
89
|
-
|
|
99
|
+
|
|
100
|
+
const audit = context.audit !== false && task.metadata.audit !== false
|
|
101
|
+
|
|
102
|
+
return TaskRunService.execute({
|
|
103
|
+
taskId: id,
|
|
104
|
+
handler: task.handler,
|
|
105
|
+
context,
|
|
106
|
+
trigger: 'invoke',
|
|
107
|
+
triggeredBy: {
|
|
108
|
+
channel: context.req ? undefined : 'api',
|
|
109
|
+
actionInvocationId: context.actionInvocationId ?? null,
|
|
110
|
+
},
|
|
111
|
+
executionEnv: 'ossy_server',
|
|
112
|
+
audit,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @param {string} id */
|
|
117
|
+
static has (id) {
|
|
118
|
+
return TaskService._tasksById.has(id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} actionId
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
static hasTaskForAction (actionId) {
|
|
126
|
+
try {
|
|
127
|
+
return TaskService.has(taskIdFromActionId(actionId))
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
/**
|
|
@@ -101,23 +142,107 @@ export class TaskService {
|
|
|
101
142
|
|
|
102
143
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
103
144
|
const triggers = metadata.triggers ?? []
|
|
104
|
-
const matched = triggers.some(trigger => TaskService.
|
|
145
|
+
const matched = triggers.some(trigger => TaskService._matchesEventTrigger(trigger, event))
|
|
105
146
|
|
|
106
147
|
if (!matched) continue
|
|
107
148
|
|
|
108
149
|
_serviceLog.info(`Dispatching task "${metadata.id}"`)
|
|
109
150
|
|
|
151
|
+
const taskContext = buildTaskContext(
|
|
152
|
+
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) },
|
|
153
|
+
{
|
|
154
|
+
event,
|
|
155
|
+
payload: { workspaceId: event.payload?.belongsTo },
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
110
159
|
const _taskId = metadata.id
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
160
|
+
TaskRunService.executeAsync({
|
|
161
|
+
taskId: _taskId,
|
|
162
|
+
handler,
|
|
163
|
+
context: taskContext,
|
|
164
|
+
trigger: 'on_event',
|
|
165
|
+
triggeredBy: {
|
|
166
|
+
eventId: event.id,
|
|
167
|
+
resourceId: event.resourceId,
|
|
168
|
+
type: event.type,
|
|
169
|
+
event: event.event,
|
|
170
|
+
},
|
|
171
|
+
executionEnv: 'ossy_server',
|
|
172
|
+
audit: metadata.audit !== false,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Dispatches follow-up tasks registered with `on_action` triggers after an action completes.
|
|
179
|
+
*
|
|
180
|
+
* @param {{
|
|
181
|
+
* actionId: string,
|
|
182
|
+
* actionInvocationId?: string,
|
|
183
|
+
* payload?: unknown,
|
|
184
|
+
* result?: unknown,
|
|
185
|
+
* success: boolean,
|
|
186
|
+
* error?: Error,
|
|
187
|
+
* req?: import('express').Request,
|
|
188
|
+
* sdk?: unknown,
|
|
189
|
+
* }} actionContext
|
|
190
|
+
*/
|
|
191
|
+
static dispatchOnAction (actionContext) {
|
|
192
|
+
const {
|
|
193
|
+
actionId,
|
|
194
|
+
actionInvocationId,
|
|
195
|
+
payload,
|
|
196
|
+
result,
|
|
197
|
+
success,
|
|
198
|
+
error,
|
|
199
|
+
req,
|
|
200
|
+
sdk,
|
|
201
|
+
} = actionContext
|
|
202
|
+
|
|
203
|
+
for (const { metadata, handler } of TaskService._tasks) {
|
|
204
|
+
if (isPrimaryTaskForAction(actionId, metadata.id)) continue
|
|
205
|
+
|
|
206
|
+
const triggers = metadata.triggers ?? []
|
|
207
|
+
const matched = triggers.some(trigger => TaskService._matchesActionTrigger(trigger, actionId))
|
|
208
|
+
|
|
209
|
+
if (!matched) continue
|
|
210
|
+
|
|
211
|
+
_serviceLog.info(`Dispatching on_action task "${metadata.id}" for action "${actionId}"`)
|
|
212
|
+
|
|
213
|
+
const taskContext = buildTaskContext(
|
|
214
|
+
{
|
|
215
|
+
sdk: sdk ?? TaskService._sdk,
|
|
216
|
+
integrations: IntegrationService,
|
|
217
|
+
log: createLogger(metadata.id),
|
|
218
|
+
req,
|
|
219
|
+
actionInvocationId,
|
|
220
|
+
payload,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
action: {
|
|
224
|
+
id: actionId,
|
|
225
|
+
success,
|
|
226
|
+
result,
|
|
227
|
+
error: error ? { message: error.message } : undefined,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const _taskId = metadata.id
|
|
233
|
+
TaskRunService.executeAsync({
|
|
234
|
+
taskId: _taskId,
|
|
235
|
+
handler,
|
|
236
|
+
context: taskContext,
|
|
237
|
+
trigger: 'on_action',
|
|
238
|
+
triggeredBy: {
|
|
239
|
+
actionId,
|
|
240
|
+
actionInvocationId: actionInvocationId ?? null,
|
|
241
|
+
success,
|
|
242
|
+
},
|
|
243
|
+
executionEnv: 'ossy_server',
|
|
244
|
+
audit: metadata.audit !== false,
|
|
245
|
+
})
|
|
121
246
|
}
|
|
122
247
|
}
|
|
123
248
|
|
|
@@ -143,17 +268,23 @@ export class TaskService {
|
|
|
143
268
|
|
|
144
269
|
_serviceLog.info(`Schedule fired for task "${metadata.id}"`)
|
|
145
270
|
|
|
271
|
+
const taskContext = {
|
|
272
|
+
event: { type: 'scheduled', taskId: metadata.id },
|
|
273
|
+
sdk: TaskService._sdk,
|
|
274
|
+
integrations: IntegrationService,
|
|
275
|
+
log: createLogger(metadata.id),
|
|
276
|
+
}
|
|
277
|
+
|
|
146
278
|
const _taskId = metadata.id
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
})
|
|
279
|
+
TaskRunService.executeAsync({
|
|
280
|
+
taskId: _taskId,
|
|
281
|
+
handler,
|
|
282
|
+
context: taskContext,
|
|
283
|
+
trigger: 'on_schedule',
|
|
284
|
+
triggeredBy: { schedule: metadata.schedule },
|
|
285
|
+
executionEnv: 'ossy_server',
|
|
286
|
+
audit: metadata.audit !== false,
|
|
287
|
+
})
|
|
157
288
|
}
|
|
158
289
|
}, SCHEDULER_INTERVAL_MS)
|
|
159
290
|
}
|
|
@@ -170,19 +301,65 @@ export class TaskService {
|
|
|
170
301
|
}
|
|
171
302
|
|
|
172
303
|
/**
|
|
173
|
-
* Returns true when
|
|
304
|
+
* Returns true when an `on_action` trigger matches the given action id.
|
|
174
305
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* trigger.event — exact match against event.type
|
|
178
|
-
* trigger.resource.type — glob match (supports *) against event.payload.type
|
|
179
|
-
* trigger.location.startsWith — prefix match against event.payload.location
|
|
306
|
+
* @param {object} trigger
|
|
307
|
+
* @param {string} actionId
|
|
180
308
|
*/
|
|
181
|
-
static
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
309
|
+
static _matchesActionTrigger (trigger, actionId) {
|
|
310
|
+
const kind = trigger.trigger ?? trigger.on
|
|
311
|
+
if (kind !== 'on_action') return false
|
|
312
|
+
|
|
313
|
+
const pattern = trigger.action
|
|
314
|
+
if (!pattern) return false
|
|
315
|
+
|
|
316
|
+
return matchesGlob(pattern, actionId)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Returns true when an event trigger descriptor matches the given eventstore document.
|
|
321
|
+
*
|
|
322
|
+
* ADR 0008: `trigger.type` = schemaId, `trigger.event` = lifecycle/custom name.
|
|
323
|
+
* Skips `on_action` triggers (handled by {@link TaskService.dispatchOnAction}).
|
|
324
|
+
*/
|
|
325
|
+
static _matchesEventTrigger (trigger, event) {
|
|
326
|
+
const kind = trigger.trigger ?? trigger.on
|
|
327
|
+
if (kind === 'on_action') return false
|
|
328
|
+
|
|
329
|
+
if (trigger.excludeTypes?.includes(event.type)) return false
|
|
330
|
+
|
|
331
|
+
const hasPositiveFilter = Boolean(
|
|
332
|
+
trigger.type ||
|
|
333
|
+
trigger.event ||
|
|
334
|
+
trigger.match ||
|
|
335
|
+
trigger.resource ||
|
|
336
|
+
trigger.location,
|
|
337
|
+
)
|
|
338
|
+
if (!hasPositiveFilter && trigger.excludeTypes?.length) return true
|
|
339
|
+
|
|
340
|
+
if (trigger.type && trigger.type !== event.type) return false
|
|
341
|
+
if (trigger.event && trigger.event !== event.event) return false
|
|
342
|
+
|
|
343
|
+
if (trigger.match) {
|
|
344
|
+
for (const [path, pattern] of Object.entries(trigger.match)) {
|
|
345
|
+
const fullPath = path.startsWith('payload.') ? path : `payload.${path}`
|
|
346
|
+
const parts = fullPath.split('.')
|
|
347
|
+
let cur = event
|
|
348
|
+
for (const part of parts) {
|
|
349
|
+
if (cur == null || typeof cur !== 'object') { cur = undefined; break }
|
|
350
|
+
cur = cur[part]
|
|
351
|
+
}
|
|
352
|
+
if (!matchesGlob(pattern, cur)) return false
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (trigger.resource?.type) {
|
|
357
|
+
const mime = event.payload?.content?.ContentType ?? event.type
|
|
358
|
+
if (!matchesGlob(trigger.resource.type, mime)) return false
|
|
359
|
+
}
|
|
360
|
+
|
|
185
361
|
if (trigger.location?.startsWith && !event.payload?.location?.startsWith?.(trigger.location.startsWith)) return false
|
|
362
|
+
|
|
186
363
|
return true
|
|
187
364
|
}
|
|
188
365
|
|