@ossy/platform 3.20.0 → 3.20.2
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 +8 -8
- package/src/index.js +1 -1
- package/src/server.js +2 -1
- package/src/tasks/change-stream.js +46 -32
- package/src/tasks/dispatch-claim.js +116 -0
- package/src/tasks/dispatch-claim.spec.js +82 -0
- package/src/tasks/in-process-sdk.js +95 -16
- package/src/tasks/in-process-sdk.spec.js +83 -5
- package/src/tasks/task-service.js +196 -91
- package/src/tasks/task-service.spec.js +69 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.20.
|
|
3
|
+
"version": "3.20.2",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
50
50
|
"@ossy/config": "^3.0.9",
|
|
51
|
-
"@ossy/event-store": "^3.20.
|
|
51
|
+
"@ossy/event-store": "^3.20.1",
|
|
52
52
|
"@ossy/locale": "^3.4.0",
|
|
53
|
-
"@ossy/manifest": "^3.20.
|
|
53
|
+
"@ossy/manifest": "^3.20.1",
|
|
54
54
|
"@ossy/observability": "^3.0.9",
|
|
55
55
|
"@ossy/policies": "^3.0.9",
|
|
56
56
|
"@ossy/router": "^3.0.9",
|
|
57
|
-
"@ossy/schema": "^3.20.
|
|
58
|
-
"@ossy/sdk": "^3.20.
|
|
57
|
+
"@ossy/schema": "^3.20.1",
|
|
58
|
+
"@ossy/sdk": "^3.20.1",
|
|
59
59
|
"@ossy/tokens": "^3.11.1",
|
|
60
|
-
"@ossy/users": "^3.20.
|
|
61
|
-
"@ossy/workspaces": "^3.20.
|
|
60
|
+
"@ossy/users": "^3.20.1",
|
|
61
|
+
"@ossy/workspaces": "^3.20.1",
|
|
62
62
|
"cookie-parser": "^1.4.7",
|
|
63
63
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
64
64
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -79,5 +79,5 @@
|
|
|
79
79
|
"Dockerfile",
|
|
80
80
|
"docker-healthcheck.js"
|
|
81
81
|
],
|
|
82
|
-
"gitHead": "
|
|
82
|
+
"gitHead": "d0e5d69906917105609af4bb8bbdd2340d9fbaf1"
|
|
83
83
|
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { TaskService } from './tasks/task-service.js'
|
|
2
|
-
export { createInProcessSdk } from './tasks/in-process-sdk.js'
|
|
2
|
+
export { createInProcessSdk, botReq, resolveBotReq } from './tasks/in-process-sdk.js'
|
|
3
3
|
export { loadAndRegisterTasks } from './tasks/task-registry.js'
|
|
4
4
|
export { ChangeStream } from './tasks/change-stream.js'
|
|
5
5
|
export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
|
package/src/server.js
CHANGED
|
@@ -284,7 +284,7 @@ export async function startServer (options = {}) {
|
|
|
284
284
|
const layoutsById = await loadLayoutsById(layoutManifest, buildDir, log)
|
|
285
285
|
|
|
286
286
|
// Register the SDK so all tasks receive it as `sdk`.
|
|
287
|
-
// Priority: explicit options.sdk → HTTP bot SDK (API_URL + OSSY_API_KEY) → in-process ActionService SDK.
|
|
287
|
+
// Priority: explicit options.sdk → HTTP bot SDK (API_URL + OSSY_API_KEY) → in-process ActionService SDK as the bot user.
|
|
288
288
|
const botSdk = (process.env.API_URL && process.env.OSSY_API_KEY)
|
|
289
289
|
? SDK.of({ apiUrl: process.env.API_URL, authorization: process.env.OSSY_API_KEY })
|
|
290
290
|
: null
|
|
@@ -727,6 +727,7 @@ export default startServer
|
|
|
727
727
|
export { loadLayoutsById, resolvePageLayoutRender }
|
|
728
728
|
export { ConfigService } from './config.service.js'
|
|
729
729
|
export { ActionService } from './actions/action.service.js'
|
|
730
|
+
export { createInProcessSdk, botReq, resolveBotReq } from './tasks/in-process-sdk.js'
|
|
730
731
|
export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
|
|
731
732
|
export { IntegrationService } from './integration.service.js'
|
|
732
733
|
export { StorageClient } from './storage/storage.client.js'
|
|
@@ -11,13 +11,19 @@ function isMongoTopologyClosedError(error) {
|
|
|
11
11
|
return false
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/** Watches `eventstore` inserts. Uses the shared {@link Mongo} client — one per process.
|
|
14
|
+
/** Watches `eventstore` inserts. Uses the shared {@link Mongo} client — one per process.
|
|
15
|
+
* Every `startServer` with `DB_URL` opens its own watch; TaskService uses Mongo claims
|
|
16
|
+
* so only one process runs a given trigger (#982 / ADR 0019). */
|
|
15
17
|
export class ChangeStream {
|
|
16
18
|
|
|
17
19
|
static _stopped = false
|
|
18
20
|
static _reconnectAttempts = 0
|
|
19
21
|
/** @type {import('mongodb').ChangeStream | null} */
|
|
20
22
|
static _changeStream = null
|
|
23
|
+
/** @type {Set<import('mongodb').ChangeStream>} */
|
|
24
|
+
static _streams = new Set()
|
|
25
|
+
/** @type {Promise<void> | null} */
|
|
26
|
+
static _openPromise = null
|
|
21
27
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
22
28
|
static _reconnectTimer = null
|
|
23
29
|
|
|
@@ -39,15 +45,7 @@ export class ChangeStream {
|
|
|
39
45
|
ChangeStream._reconnectTimer = null
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
ChangeStream._changeStream = null
|
|
44
|
-
if (stream) {
|
|
45
|
-
try {
|
|
46
|
-
await stream.close()
|
|
47
|
-
} catch {
|
|
48
|
-
// ignore — stream may already be closed
|
|
49
|
-
}
|
|
50
|
-
}
|
|
48
|
+
await ChangeStream._closeStreams()
|
|
51
49
|
|
|
52
50
|
log.info('[ChangeStream] Stopped')
|
|
53
51
|
}
|
|
@@ -87,7 +85,33 @@ export class ChangeStream {
|
|
|
87
85
|
}, delaySecs * 1000)
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
static
|
|
88
|
+
static _open () {
|
|
89
|
+
if (ChangeStream._stopped) return Promise.resolve()
|
|
90
|
+
if (ChangeStream._openPromise) return ChangeStream._openPromise
|
|
91
|
+
ChangeStream._openPromise = ChangeStream._openNow().finally(() => {
|
|
92
|
+
ChangeStream._openPromise = null
|
|
93
|
+
})
|
|
94
|
+
return ChangeStream._openPromise
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Close every live cursor. Streams we close here must not schedule a reconnect.
|
|
99
|
+
*/
|
|
100
|
+
static async _closeStreams () {
|
|
101
|
+
const streams = [...ChangeStream._streams]
|
|
102
|
+
ChangeStream._streams.clear()
|
|
103
|
+
ChangeStream._changeStream = null
|
|
104
|
+
await Promise.all(streams.map(async (stream) => {
|
|
105
|
+
stream.replaced = true
|
|
106
|
+
try {
|
|
107
|
+
await stream.close()
|
|
108
|
+
} catch {
|
|
109
|
+
// ignore — stream may already be closed
|
|
110
|
+
}
|
|
111
|
+
}))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static async _openNow () {
|
|
91
115
|
if (ChangeStream._stopped) return
|
|
92
116
|
|
|
93
117
|
log.info('[ChangeStream] Watching for changes')
|
|
@@ -98,18 +122,13 @@ export class ChangeStream {
|
|
|
98
122
|
|
|
99
123
|
const collection = client.db(dbName).collection('eventstore')
|
|
100
124
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
await ChangeStream._changeStream.close()
|
|
104
|
-
} catch {
|
|
105
|
-
// ignore
|
|
106
|
-
}
|
|
107
|
-
ChangeStream._changeStream = null
|
|
108
|
-
}
|
|
125
|
+
await ChangeStream._closeStreams()
|
|
126
|
+
if (ChangeStream._stopped) return
|
|
109
127
|
|
|
110
128
|
const changeStream = collection.watch([
|
|
111
129
|
{ $match: { operationType: 'insert' } },
|
|
112
130
|
])
|
|
131
|
+
ChangeStream._streams.add(changeStream)
|
|
113
132
|
ChangeStream._changeStream = changeStream
|
|
114
133
|
|
|
115
134
|
const wasReconnecting = ChangeStream._reconnectAttempts > 0
|
|
@@ -129,7 +148,7 @@ export class ChangeStream {
|
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
changeStream.on('error', (error) => {
|
|
132
|
-
if (ChangeStream._stopped) return
|
|
151
|
+
if (changeStream.replaced || ChangeStream._stopped) return
|
|
133
152
|
log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
|
|
134
153
|
if (isMongoTopologyClosedError(error)) {
|
|
135
154
|
ChangeStream._resetSharedClient()
|
|
@@ -149,23 +168,18 @@ export class ChangeStream {
|
|
|
149
168
|
TaskService.dispatch(event)
|
|
150
169
|
})
|
|
151
170
|
|
|
152
|
-
|
|
171
|
+
const drop = (label) => {
|
|
172
|
+
ChangeStream._streams.delete(changeStream)
|
|
153
173
|
if (ChangeStream._changeStream === changeStream) {
|
|
154
174
|
ChangeStream._changeStream = null
|
|
155
175
|
}
|
|
156
|
-
if (ChangeStream._stopped) return
|
|
157
|
-
log.info(
|
|
176
|
+
if (changeStream.replaced || ChangeStream._stopped) return
|
|
177
|
+
log.info(`[ChangeStream] ${label} detected`)
|
|
158
178
|
scheduleOnce()
|
|
159
|
-
}
|
|
179
|
+
}
|
|
160
180
|
|
|
161
|
-
changeStream.on('
|
|
162
|
-
|
|
163
|
-
ChangeStream._changeStream = null
|
|
164
|
-
}
|
|
165
|
-
if (ChangeStream._stopped) return
|
|
166
|
-
log.info('[ChangeStream] end detected')
|
|
167
|
-
scheduleOnce()
|
|
168
|
-
})
|
|
181
|
+
changeStream.on('close', () => drop('close'))
|
|
182
|
+
changeStream.on('end', () => drop('end'))
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { Mongo, withMongoReconnect } from '@ossy/event-store'
|
|
3
|
+
import { createLogger } from '@ossy/observability'
|
|
4
|
+
|
|
5
|
+
const log = createLogger('platform/dispatch-claim')
|
|
6
|
+
|
|
7
|
+
export const DISPATCH_CLAIM_COLLECTION = 'task_dispatch_claims'
|
|
8
|
+
export const DEFAULT_CLAIM_TTL_MS = 15 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
/** @type {null | { insertOne: Function, findOneAndUpdate: Function }} */
|
|
11
|
+
let _collectionOverride = null
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {null | { insertOne: Function, findOneAndUpdate: Function }} collection
|
|
15
|
+
*/
|
|
16
|
+
export function setDispatchClaimCollectionForTests (collection) {
|
|
17
|
+
_collectionOverride = collection
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getCollection () {
|
|
21
|
+
if (_collectionOverride) return _collectionOverride
|
|
22
|
+
return Mongo.db.collection(DISPATCH_CLAIM_COLLECTION)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Natural key for event-triggered task runs (changestream / TaskService.dispatch).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} taskId
|
|
29
|
+
* @param {string | null | undefined} eventId
|
|
30
|
+
* @returns {string | null}
|
|
31
|
+
*/
|
|
32
|
+
export function buildEventClaimKey (taskId, eventId) {
|
|
33
|
+
if (!taskId || !eventId) return null
|
|
34
|
+
return `event:${taskId}:${eventId}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Natural key for cron/schedule ticks (one claim per task+workspace+UTC minute).
|
|
39
|
+
*
|
|
40
|
+
* @param {string} taskId
|
|
41
|
+
* @param {string | null | undefined} workspaceId
|
|
42
|
+
* @param {Date} [at]
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
export function buildScheduleClaimKey (taskId, workspaceId, at = new Date()) {
|
|
46
|
+
if (!taskId) return null
|
|
47
|
+
const minute = at.toISOString().slice(0, 16)
|
|
48
|
+
const ws = workspaceId || '_'
|
|
49
|
+
return `schedule:${taskId}:${ws}:${minute}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Natural key for on_action follow-ups when an actionInvocationId is present.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} taskId
|
|
56
|
+
* @param {string | null | undefined} actionInvocationId
|
|
57
|
+
* @returns {string | null}
|
|
58
|
+
*/
|
|
59
|
+
export function buildActionClaimKey (taskId, actionInvocationId) {
|
|
60
|
+
if (!taskId || !actionInvocationId) return null
|
|
61
|
+
return `action:${taskId}:${actionInvocationId}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultHolder () {
|
|
65
|
+
return `${os.hostname()}:${process.pid}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Try to exclusively claim a dispatch key across platform processes sharing Mongo.
|
|
70
|
+
*
|
|
71
|
+
* - Missing key → allow (caller has no natural id; e.g. invoke without claim).
|
|
72
|
+
* - First insert wins; expired claims can be stolen.
|
|
73
|
+
* - Fail closed on unexpected Mongo errors (skip the run rather than double-run).
|
|
74
|
+
*
|
|
75
|
+
* @param {string | null | undefined} key
|
|
76
|
+
* @param {{ holder?: string, ttlMs?: number }} [opts]
|
|
77
|
+
* @returns {Promise<boolean>}
|
|
78
|
+
*/
|
|
79
|
+
export async function tryClaimDispatch (key, opts = {}) {
|
|
80
|
+
if (!key) return true
|
|
81
|
+
|
|
82
|
+
const holder = opts.holder ?? defaultHolder()
|
|
83
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_CLAIM_TTL_MS
|
|
84
|
+
const now = new Date()
|
|
85
|
+
const expiresAt = new Date(now.getTime() + ttlMs)
|
|
86
|
+
const doc = { _id: key, holder, claimedAt: now, expiresAt }
|
|
87
|
+
|
|
88
|
+
const attempt = async () => {
|
|
89
|
+
const col = getCollection()
|
|
90
|
+
try {
|
|
91
|
+
await col.insertOne(doc)
|
|
92
|
+
return true
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err?.code !== 11000) throw err
|
|
95
|
+
|
|
96
|
+
const stolen = await col.findOneAndUpdate(
|
|
97
|
+
{ _id: key, expiresAt: { $lte: now } },
|
|
98
|
+
{ $set: { holder, claimedAt: now, expiresAt } },
|
|
99
|
+
{ returnDocument: 'after' },
|
|
100
|
+
)
|
|
101
|
+
// Driver v4 returns `{ value }`; v6 may return the document or null.
|
|
102
|
+
const value = stolen && typeof stolen === 'object' && 'value' in stolen
|
|
103
|
+
? stolen.value
|
|
104
|
+
: stolen
|
|
105
|
+
return Boolean(value)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (_collectionOverride) return await attempt()
|
|
111
|
+
return await withMongoReconnect(attempt)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
log.warn(`[dispatch-claim] claim failed for ${key}; skipping run`, { key }, err)
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
buildActionClaimKey,
|
|
4
|
+
buildEventClaimKey,
|
|
5
|
+
buildScheduleClaimKey,
|
|
6
|
+
setDispatchClaimCollectionForTests,
|
|
7
|
+
tryClaimDispatch,
|
|
8
|
+
} from './dispatch-claim.js'
|
|
9
|
+
|
|
10
|
+
describe('dispatch-claim keys', () => {
|
|
11
|
+
it('builds event and action keys', () => {
|
|
12
|
+
expect(buildEventClaimKey('@ossy/x/tasks/y', 'evt1')).toBe('event:@ossy/x/tasks/y:evt1')
|
|
13
|
+
expect(buildEventClaimKey('@ossy/x/tasks/y', null)).toBe(null)
|
|
14
|
+
expect(buildActionClaimKey('@ossy/x/tasks/y', 'inv1')).toBe('action:@ossy/x/tasks/y:inv1')
|
|
15
|
+
expect(buildActionClaimKey('@ossy/x/tasks/y', null)).toBe(null)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('builds schedule keys per UTC minute and workspace', () => {
|
|
19
|
+
const at = new Date('2026-10-01T09:15:42.123Z')
|
|
20
|
+
expect(buildScheduleClaimKey('@ossy/x/tasks/y', 'ws1', at))
|
|
21
|
+
.toBe('schedule:@ossy/x/tasks/y:ws1:2026-10-01T09:15')
|
|
22
|
+
expect(buildScheduleClaimKey('@ossy/x/tasks/y', null, at))
|
|
23
|
+
.toBe('schedule:@ossy/x/tasks/y:_:2026-10-01T09:15')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('tryClaimDispatch', () => {
|
|
28
|
+
/** @type {Map<string, object>} */
|
|
29
|
+
let store
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
store = new Map()
|
|
33
|
+
setDispatchClaimCollectionForTests({
|
|
34
|
+
async insertOne (doc) {
|
|
35
|
+
if (store.has(doc._id)) {
|
|
36
|
+
const err = new Error('E11000 duplicate key')
|
|
37
|
+
err.code = 11000
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
store.set(doc._id, { ...doc })
|
|
41
|
+
return { acknowledged: true }
|
|
42
|
+
},
|
|
43
|
+
async findOneAndUpdate (filter, update) {
|
|
44
|
+
const existing = store.get(filter._id)
|
|
45
|
+
if (!existing) return null
|
|
46
|
+
if (filter.expiresAt?.$lte && !(existing.expiresAt <= filter.expiresAt.$lte)) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
const next = { ...existing, ...update.$set }
|
|
50
|
+
store.set(filter._id, next)
|
|
51
|
+
return next
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
setDispatchClaimCollectionForTests(null)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('allows when key is missing', async () => {
|
|
61
|
+
expect(await tryClaimDispatch(null)).toBe(true)
|
|
62
|
+
expect(await tryClaimDispatch(undefined)).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('first claim wins; second loses until expiry', async () => {
|
|
66
|
+
expect(await tryClaimDispatch('event:t:e1', { holder: 'a' })).toBe(true)
|
|
67
|
+
expect(await tryClaimDispatch('event:t:e1', { holder: 'b' })).toBe(false)
|
|
68
|
+
expect(store.get('event:t:e1').holder).toBe('a')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('expired claim can be stolen', async () => {
|
|
72
|
+
const past = new Date(Date.now() - 60_000)
|
|
73
|
+
store.set('event:t:e2', {
|
|
74
|
+
_id: 'event:t:e2',
|
|
75
|
+
holder: 'old',
|
|
76
|
+
claimedAt: past,
|
|
77
|
+
expiresAt: past,
|
|
78
|
+
})
|
|
79
|
+
expect(await tryClaimDispatch('event:t:e2', { holder: 'new' })).toBe(true)
|
|
80
|
+
expect(store.get('event:t:e2').holder).toBe('new')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -1,35 +1,100 @@
|
|
|
1
1
|
import { resolveActionId } from '@ossy/sdk'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { User } from '@ossy/users/server'
|
|
4
|
+
import { PoliciesQueries } from '@ossy/policies/server'
|
|
2
5
|
import { ActionService } from '../actions/action.service.js'
|
|
3
6
|
import { IntegrationService } from '../integration.service.js'
|
|
7
|
+
import { ConfigService } from '../config.service.js'
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
|
-
*
|
|
10
|
+
* Bot request scoped to zero or more workspaces. Resource ACL treats this as a
|
|
11
|
+
* normal user actor (no system bypass).
|
|
7
12
|
*
|
|
8
|
-
* @param {
|
|
9
|
-
* @returns {{ userId: string, workspaceId?: string }}
|
|
13
|
+
* @param {{ workspaceId?: string, workspaces?: string[], policies?: unknown[] }} [options]
|
|
14
|
+
* @returns {{ userId: string, workspaceId?: string, user: object }}
|
|
10
15
|
*/
|
|
11
|
-
function
|
|
16
|
+
export function botReq (options = {}) {
|
|
17
|
+
const userId = ConfigService.BotUserId
|
|
18
|
+
const workspaces = options.workspaceId
|
|
19
|
+
? [options.workspaceId]
|
|
20
|
+
: (options.workspaces ?? [])
|
|
12
21
|
return {
|
|
13
|
-
userId
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
??
|
|
19
|
-
|
|
22
|
+
userId,
|
|
23
|
+
...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
|
|
24
|
+
user: {
|
|
25
|
+
id: userId,
|
|
26
|
+
workspaces,
|
|
27
|
+
policies: options.policies ?? [],
|
|
28
|
+
},
|
|
20
29
|
}
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Load the platform bot with live workspace membership and policies.
|
|
34
|
+
* Used when a task has no single workspace scope (global schedules).
|
|
35
|
+
*
|
|
36
|
+
* @returns {Promise<{ userId: string, user: object }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveBotReq () {
|
|
39
|
+
const userId = ConfigService.BotUserId
|
|
40
|
+
try {
|
|
41
|
+
const user = await Aggregate.Of(User, userId).then(Aggregate.View())
|
|
42
|
+
const policies = await PoliciesQueries.GetPoliciesForUser(userId).catch(() => [])
|
|
43
|
+
return {
|
|
44
|
+
userId,
|
|
45
|
+
user: {
|
|
46
|
+
...user,
|
|
47
|
+
id: userId,
|
|
48
|
+
workspaces: user?.workspaces ?? [],
|
|
49
|
+
policies: policies ?? [],
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return botReq()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bind the bot to the workspace on an eventstore document for nested invokes.
|
|
59
|
+
* Tasks run as the bot member, not as a system actor and not as the event author
|
|
60
|
+
* (an author userId without `req.user` would otherwise fold as anonymous).
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [event]
|
|
63
|
+
* @returns {{ userId: string, workspaceId?: string, user: object }}
|
|
64
|
+
*/
|
|
65
|
+
function reqFromEvent (event) {
|
|
66
|
+
const workspaceId = event?.payload?.belongsTo
|
|
67
|
+
?? event?.belongsTo
|
|
68
|
+
?? event?.payload?.workspaceId
|
|
69
|
+
return botReq({ workspaceId })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the request bound to an in-process SDK instance.
|
|
74
|
+
* Explicit `req.user` wins. Workspace-scoped reqs get a bot membership stub.
|
|
75
|
+
* Otherwise load the bot's live workspaces.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ userId?: string, workspaceId?: string, user?: object } | undefined} optionsReq
|
|
78
|
+
*/
|
|
79
|
+
function resolveReq (optionsReq) {
|
|
80
|
+
if (optionsReq?.user) return Promise.resolve(optionsReq)
|
|
81
|
+
if (optionsReq?.workspaceId) return Promise.resolve(botReq({ workspaceId: optionsReq.workspaceId }))
|
|
82
|
+
return resolveBotReq()
|
|
83
|
+
}
|
|
84
|
+
|
|
23
85
|
/**
|
|
24
86
|
* Same-process SDK for changestream / scheduled tasks when no HTTP bot
|
|
25
87
|
* credentials (`API_URL` + `OSSY_API_KEY`) are configured.
|
|
26
88
|
*
|
|
89
|
+
* Actor is the platform bot (`ConfigService.BotUserId`) with workspace
|
|
90
|
+
* membership so resource ACL applies — not `userId: 'system'` (ACL bypass).
|
|
91
|
+
*
|
|
27
92
|
* Implements `invoke` via ActionService (task outputs write through patch-content).
|
|
28
93
|
*
|
|
29
|
-
* @param {{ req?: { userId?: string, workspaceId?: string } }} [options]
|
|
94
|
+
* @param {{ req?: { userId?: string, workspaceId?: string, user?: object } }} [options]
|
|
30
95
|
*/
|
|
31
96
|
export function createInProcessSdk (options = {}) {
|
|
32
|
-
const
|
|
97
|
+
const optionsReq = options.req
|
|
33
98
|
|
|
34
99
|
const sdk = {
|
|
35
100
|
/**
|
|
@@ -38,21 +103,35 @@ export function createInProcessSdk (options = {}) {
|
|
|
38
103
|
*/
|
|
39
104
|
invoke (action, payload = {}) {
|
|
40
105
|
const actionId = resolveActionId(action)
|
|
41
|
-
return ActionService.invoke(actionId, {
|
|
106
|
+
return resolveReq(optionsReq).then((req) => ActionService.invoke(actionId, {
|
|
42
107
|
payload,
|
|
43
108
|
sdk,
|
|
44
109
|
integrations: IntegrationService,
|
|
45
110
|
req,
|
|
46
|
-
})
|
|
111
|
+
}))
|
|
47
112
|
},
|
|
48
113
|
|
|
49
114
|
/**
|
|
50
|
-
* Bind actor
|
|
115
|
+
* Bind bot actor + workspace from a triggering event for one dispatch.
|
|
51
116
|
* @param {object} event
|
|
52
117
|
*/
|
|
53
118
|
withEventContext (event) {
|
|
54
119
|
return createInProcessSdk({ req: reqFromEvent(event) })
|
|
55
120
|
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bot limited to one workspace. Public and token entry points use this
|
|
124
|
+
* after they have resolved the workspace, so a visitor is not the actor
|
|
125
|
+
* and the bot cannot see other workspaces.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} workspaceId
|
|
128
|
+
*/
|
|
129
|
+
forWorkspace (workspaceId) {
|
|
130
|
+
if (!workspaceId) {
|
|
131
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
132
|
+
}
|
|
133
|
+
return createInProcessSdk({ req: botReq({ workspaceId }) })
|
|
134
|
+
},
|
|
56
135
|
}
|
|
57
136
|
|
|
58
137
|
return sdk
|
|
@@ -12,13 +12,59 @@ jest.unstable_mockModule('../integration.service.js', () => ({
|
|
|
12
12
|
IntegrationService: { get: () => null },
|
|
13
13
|
}))
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const botUserId = 'bot-user-id'
|
|
16
|
+
jest.unstable_mockModule('../config.service.js', () => ({
|
|
17
|
+
ConfigService: { BotUserId: botUserId },
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('@ossy/sdk', () => ({
|
|
21
|
+
resolveActionId: (action) => (typeof action === 'string' ? action : action.id),
|
|
22
|
+
}), { virtual: true })
|
|
23
|
+
|
|
24
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
25
|
+
Aggregate: {
|
|
26
|
+
Of: jest.fn(async () => ({})),
|
|
27
|
+
View: () => async () => ({ id: botUserId, workspaces: ['ws-bot'] }),
|
|
28
|
+
},
|
|
29
|
+
}), { virtual: true })
|
|
30
|
+
|
|
31
|
+
jest.unstable_mockModule('@ossy/users/server', () => ({
|
|
32
|
+
User: {},
|
|
33
|
+
}), { virtual: true })
|
|
34
|
+
|
|
35
|
+
jest.unstable_mockModule('@ossy/policies/server', () => ({
|
|
36
|
+
PoliciesQueries: {
|
|
37
|
+
GetPoliciesForUser: jest.fn(async () => []),
|
|
38
|
+
},
|
|
39
|
+
}), { virtual: true })
|
|
40
|
+
|
|
41
|
+
const { createInProcessSdk, botReq } = await import('./in-process-sdk.js')
|
|
16
42
|
|
|
17
43
|
describe('createInProcessSdk', () => {
|
|
44
|
+
it('loads the bot with membership when no req is bound', async () => {
|
|
45
|
+
invokeMock.mockClear()
|
|
46
|
+
const sdk = createInProcessSdk()
|
|
47
|
+
|
|
48
|
+
await sdk.invoke({ id: '@ossy/resources/actions/get' }, { resourceId: 'r0' })
|
|
49
|
+
|
|
50
|
+
expect(invokeMock).toHaveBeenCalledWith(
|
|
51
|
+
'@ossy/resources/actions/get',
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
req: expect.objectContaining({
|
|
54
|
+
userId: botUserId,
|
|
55
|
+
user: expect.objectContaining({
|
|
56
|
+
id: botUserId,
|
|
57
|
+
workspaces: ['ws-bot'],
|
|
58
|
+
}),
|
|
59
|
+
}),
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
18
64
|
it('invokes actions in-process with bound req', async () => {
|
|
19
65
|
invokeMock.mockClear()
|
|
20
66
|
const sdk = createInProcessSdk({
|
|
21
|
-
req: { userId: 'u1', workspaceId: 'ws1' },
|
|
67
|
+
req: { userId: 'u1', workspaceId: 'ws1', user: { id: 'u1', workspaces: ['ws1'], policies: [] } },
|
|
22
68
|
})
|
|
23
69
|
|
|
24
70
|
await sdk.invoke({ id: '@ossy/resources/actions/get' }, { resourceId: 'r1' })
|
|
@@ -27,12 +73,12 @@ describe('createInProcessSdk', () => {
|
|
|
27
73
|
'@ossy/resources/actions/get',
|
|
28
74
|
expect.objectContaining({
|
|
29
75
|
payload: { resourceId: 'r1' },
|
|
30
|
-
req: { userId: 'u1', workspaceId: 'ws1' },
|
|
76
|
+
req: expect.objectContaining({ userId: 'u1', workspaceId: 'ws1' }),
|
|
31
77
|
}),
|
|
32
78
|
)
|
|
33
79
|
})
|
|
34
80
|
|
|
35
|
-
it('binds
|
|
81
|
+
it('binds the bot actor and workspace via withEventContext', async () => {
|
|
36
82
|
invokeMock.mockClear()
|
|
37
83
|
const sdk = createInProcessSdk().withEventContext({
|
|
38
84
|
createdBy: 'author',
|
|
@@ -44,7 +90,39 @@ describe('createInProcessSdk', () => {
|
|
|
44
90
|
expect(invokeMock).toHaveBeenCalledWith(
|
|
45
91
|
'@ossy/resources/actions/get',
|
|
46
92
|
expect.objectContaining({
|
|
47
|
-
req: {
|
|
93
|
+
req: botReq({ workspaceId: 'workspace-a' }),
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('forWorkspace binds the bot to that workspace only', async () => {
|
|
99
|
+
invokeMock.mockClear()
|
|
100
|
+
const sdk = createInProcessSdk({
|
|
101
|
+
req: { userId: 'visitor', user: { id: 'visitor', anonymous: true, workspaces: [] } },
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await sdk.forWorkspace('ws-public').invoke('@ossy/resources/actions/create', { workspaceId: 'ws-public' })
|
|
105
|
+
|
|
106
|
+
expect(invokeMock).toHaveBeenCalledWith(
|
|
107
|
+
'@ossy/resources/actions/create',
|
|
108
|
+
expect.objectContaining({
|
|
109
|
+
req: botReq({ workspaceId: 'ws-public' }),
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('scopes the bot to the event workspace when the event has no actor', async () => {
|
|
115
|
+
invokeMock.mockClear()
|
|
116
|
+
const sdk = createInProcessSdk().withEventContext({
|
|
117
|
+
payload: { belongsTo: 'workspace-b' },
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await sdk.invoke('@ossy/resources/actions/get', { resourceId: 'r3' })
|
|
121
|
+
|
|
122
|
+
expect(invokeMock).toHaveBeenCalledWith(
|
|
123
|
+
'@ossy/resources/actions/get',
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
req: botReq({ workspaceId: 'workspace-b' }),
|
|
48
126
|
}),
|
|
49
127
|
)
|
|
50
128
|
})
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { matchesGlob } from './glob.js'
|
|
2
2
|
import { matchesCron } from './cron.js'
|
|
3
|
+
import {
|
|
4
|
+
buildActionClaimKey,
|
|
5
|
+
buildEventClaimKey,
|
|
6
|
+
buildScheduleClaimKey,
|
|
7
|
+
tryClaimDispatch,
|
|
8
|
+
} from './dispatch-claim.js'
|
|
3
9
|
import { IntegrationService } from '../integration.service.js'
|
|
10
|
+
import { ConfigService } from '../config.service.js'
|
|
4
11
|
import { createLogger } from '@ossy/observability'
|
|
5
12
|
import { isPrimaryTaskForAction, resolveTaskInputs, taskIdFromActionId } from '@ossy/schema'
|
|
6
13
|
import { TaskRunService } from '../audit/task-run-service.js'
|
|
@@ -9,6 +16,18 @@ import { resolveClientIp, runWithEventContext } from '@ossy/event-store'
|
|
|
9
16
|
const SCHEDULER_INTERVAL_MS = 60_000
|
|
10
17
|
const _serviceLog = createLogger('TaskService')
|
|
11
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Written on the request path. Dispatch must not resolve workspace triggers
|
|
21
|
+
* for these — that lists task instances once per configurable task.
|
|
22
|
+
* Projections still fold them on the change stream (#1026).
|
|
23
|
+
* Same set as aggregate-rebuild `excludeTypes`.
|
|
24
|
+
*/
|
|
25
|
+
const AUDIT_DISPATCH_TYPES = new Set([
|
|
26
|
+
'@ossy/platform/schema/task-run',
|
|
27
|
+
'@ossy/platform/schema/action-invocation',
|
|
28
|
+
'@ossy/platform/schema/meter-event',
|
|
29
|
+
])
|
|
30
|
+
|
|
12
31
|
function buildTaskContext (baseContext, extras = {}) {
|
|
13
32
|
return {
|
|
14
33
|
...baseContext,
|
|
@@ -17,6 +36,42 @@ function buildTaskContext (baseContext, extras = {}) {
|
|
|
17
36
|
}
|
|
18
37
|
}
|
|
19
38
|
|
|
39
|
+
/**
|
|
40
|
+
* In-process SDK bound to this request. Dynamic import avoids a cycle through
|
|
41
|
+
* ActionService. A missing req loads the bot; callers that have a user or a
|
|
42
|
+
* workspace-scoped bot req pass that through.
|
|
43
|
+
*
|
|
44
|
+
* @param {object | undefined} req
|
|
45
|
+
*/
|
|
46
|
+
async function sdkBoundTo (req) {
|
|
47
|
+
const { createInProcessSdk } = await import('./in-process-sdk.js')
|
|
48
|
+
return createInProcessSdk(req ? { req } : {})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function botWorkspaceIds () {
|
|
52
|
+
const { resolveBotReq } = await import('./in-process-sdk.js')
|
|
53
|
+
const bot = await resolveBotReq()
|
|
54
|
+
const workspaces = bot?.user?.workspaces
|
|
55
|
+
if (!Array.isArray(workspaces)) return []
|
|
56
|
+
return [...new Set(workspaces.filter((id) => typeof id === 'string' && id))]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One cron firing per workspace. A global schedule must not run as a bot that
|
|
61
|
+
* can read every workspace in a single invoke.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} schedule
|
|
64
|
+
* @param {string[]} workspaceIds
|
|
65
|
+
* @returns {Array<{ workspaceId: string, schedule: string }>}
|
|
66
|
+
*/
|
|
67
|
+
export function jobsForGlobalSchedule (schedule, workspaceIds) {
|
|
68
|
+
const trimmed = typeof schedule === 'string' ? schedule.trim() : ''
|
|
69
|
+
if (!trimmed) return []
|
|
70
|
+
return (workspaceIds ?? [])
|
|
71
|
+
.filter((id) => typeof id === 'string' && id)
|
|
72
|
+
.map((workspaceId) => ({ workspaceId, schedule: trimmed }))
|
|
73
|
+
}
|
|
74
|
+
|
|
20
75
|
export class TaskService {
|
|
21
76
|
|
|
22
77
|
static _stopped = false
|
|
@@ -104,7 +159,9 @@ export class TaskService {
|
|
|
104
159
|
?? context?.event?.payload?.belongsTo
|
|
105
160
|
?? null
|
|
106
161
|
let config = {}
|
|
107
|
-
|
|
162
|
+
// Only configurable tasks have instance config. Resolvers list resources by
|
|
163
|
+
// calling a task handler; resolving config for that list re-enters this path.
|
|
164
|
+
if (TaskService._instanceConfigResolver && metadata?.id && workspaceId && TaskService._isConfigurable(metadata)) {
|
|
108
165
|
try {
|
|
109
166
|
const loaded = await TaskService._instanceConfigResolver({
|
|
110
167
|
taskId: metadata.id,
|
|
@@ -260,9 +317,11 @@ export class TaskService {
|
|
|
260
317
|
|
|
261
318
|
// GET/API handlers often omit integrations; default like dispatch/scheduler so
|
|
262
319
|
// tasks can always use integrations.get('email') (including local delivery).
|
|
320
|
+
// Nested invokes keep this sdk, so the first actor (the caller, or an sdk
|
|
321
|
+
// the entry point already bound) is the actor for the whole chain.
|
|
263
322
|
const invokeContext = {
|
|
264
323
|
...context,
|
|
265
|
-
sdk: context.sdk ??
|
|
324
|
+
sdk: context.sdk ?? await sdkBoundTo(context.req),
|
|
266
325
|
integrations: context.integrations ?? IntegrationService,
|
|
267
326
|
log: context.log ?? createLogger(id),
|
|
268
327
|
}
|
|
@@ -309,8 +368,9 @@ export class TaskService {
|
|
|
309
368
|
*/
|
|
310
369
|
static async dispatch(event, { sdk } = {}) {
|
|
311
370
|
if (!event) return
|
|
371
|
+
if (AUDIT_DISPATCH_TYPES.has(event.type)) return
|
|
312
372
|
|
|
313
|
-
const baseSdk = sdk ??
|
|
373
|
+
const baseSdk = sdk ?? await sdkBoundTo()
|
|
314
374
|
const effectiveSdk = typeof baseSdk?.withEventContext === 'function'
|
|
315
375
|
? baseSdk.withEventContext(event)
|
|
316
376
|
: baseSdk
|
|
@@ -324,32 +384,43 @@ export class TaskService {
|
|
|
324
384
|
const matchedTrigger = triggers.find((trigger) => TaskService._matchesEventTrigger(trigger, event))
|
|
325
385
|
if (!matchedTrigger) continue
|
|
326
386
|
|
|
327
|
-
|
|
387
|
+
const _taskId = metadata.id
|
|
388
|
+
const claimKey = buildEventClaimKey(_taskId, event.id)
|
|
389
|
+
|
|
390
|
+
// Multiple startServer processes share Mongo changestreams (#982) — claim before run.
|
|
391
|
+
void (async () => {
|
|
392
|
+
const won = await tryClaimDispatch(claimKey)
|
|
393
|
+
if (!won) {
|
|
394
|
+
_serviceLog.info(`Skipping task "${_taskId}" — dispatch claim lost`)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
328
397
|
|
|
329
|
-
|
|
330
|
-
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) },
|
|
331
|
-
{
|
|
332
|
-
event,
|
|
333
|
-
payload: { workspaceId },
|
|
334
|
-
triggerConfig: matchedTrigger.config,
|
|
335
|
-
},
|
|
336
|
-
)
|
|
398
|
+
_serviceLog.info(`Dispatching task "${_taskId}"`)
|
|
337
399
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
400
|
+
const taskContext = buildTaskContext(
|
|
401
|
+
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(_taskId) },
|
|
402
|
+
{
|
|
403
|
+
event,
|
|
404
|
+
payload: { workspaceId },
|
|
405
|
+
triggerConfig: matchedTrigger.config,
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
TaskRunService.executeAsync({
|
|
410
|
+
taskId: _taskId,
|
|
411
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
412
|
+
context: taskContext,
|
|
413
|
+
trigger: 'on_event',
|
|
414
|
+
triggeredBy: {
|
|
415
|
+
eventId: event.id,
|
|
416
|
+
resourceId: event.resourceId,
|
|
417
|
+
type: event.type,
|
|
418
|
+
event: event.event,
|
|
419
|
+
},
|
|
420
|
+
executionEnv: 'ossy_server',
|
|
421
|
+
audit: metadata.audit !== false,
|
|
422
|
+
})
|
|
423
|
+
})()
|
|
353
424
|
}
|
|
354
425
|
}
|
|
355
426
|
|
|
@@ -393,42 +464,52 @@ export class TaskService {
|
|
|
393
464
|
|
|
394
465
|
if (!matchedTrigger) continue
|
|
395
466
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
467
|
+
const _taskId = metadata.id
|
|
468
|
+
const claimKey = buildActionClaimKey(_taskId, actionInvocationId)
|
|
469
|
+
|
|
470
|
+
void (async () => {
|
|
471
|
+
const won = await tryClaimDispatch(claimKey)
|
|
472
|
+
if (!won) {
|
|
473
|
+
_serviceLog.info(`Skipping on_action task "${_taskId}" — dispatch claim lost`)
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
_serviceLog.info(`Dispatching on_action task "${_taskId}" for action "${actionId}"`)
|
|
478
|
+
|
|
479
|
+
const taskContext = buildTaskContext(
|
|
480
|
+
{
|
|
481
|
+
sdk: sdk ?? await sdkBoundTo(req),
|
|
482
|
+
integrations: IntegrationService,
|
|
483
|
+
log: createLogger(_taskId),
|
|
484
|
+
req,
|
|
485
|
+
actionInvocationId,
|
|
486
|
+
payload,
|
|
487
|
+
triggerConfig: matchedTrigger.config,
|
|
414
488
|
},
|
|
415
|
-
|
|
416
|
-
|
|
489
|
+
{
|
|
490
|
+
action: {
|
|
491
|
+
id: actionId,
|
|
492
|
+
success,
|
|
493
|
+
result,
|
|
494
|
+
error: error ? { message: error.message } : undefined,
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
)
|
|
417
498
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
})
|
|
499
|
+
TaskRunService.executeAsync({
|
|
500
|
+
taskId: _taskId,
|
|
501
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
502
|
+
context: taskContext,
|
|
503
|
+
trigger: 'on_action',
|
|
504
|
+
triggeredBy: {
|
|
505
|
+
actionId,
|
|
506
|
+
actionInvocationId: actionInvocationId ?? null,
|
|
507
|
+
success,
|
|
508
|
+
},
|
|
509
|
+
executionEnv: 'ossy_server',
|
|
510
|
+
audit: metadata.audit !== false,
|
|
511
|
+
})
|
|
512
|
+
})()
|
|
432
513
|
}
|
|
433
514
|
}
|
|
434
515
|
|
|
@@ -465,14 +546,15 @@ export class TaskService {
|
|
|
465
546
|
return jobs
|
|
466
547
|
}
|
|
467
548
|
|
|
549
|
+
const workspaceIds = await botWorkspaceIds()
|
|
468
550
|
if (typeof metadata?.schedule === 'string' && metadata.schedule.trim()) {
|
|
469
|
-
jobs.push(
|
|
551
|
+
jobs.push(...jobsForGlobalSchedule(metadata.schedule, workspaceIds))
|
|
470
552
|
}
|
|
471
553
|
for (const trigger of metadata?.triggers ?? []) {
|
|
472
554
|
const kind = trigger?.trigger ?? trigger?.on ?? trigger?.kind
|
|
473
555
|
const schedule = typeof trigger?.schedule === 'string' ? trigger.schedule.trim() : ''
|
|
474
556
|
if ((kind === 'on_schedule' || (!kind && schedule)) && schedule) {
|
|
475
|
-
jobs.push(
|
|
557
|
+
jobs.push(...jobsForGlobalSchedule(schedule, workspaceIds))
|
|
476
558
|
}
|
|
477
559
|
}
|
|
478
560
|
return jobs
|
|
@@ -495,38 +577,61 @@ export class TaskService {
|
|
|
495
577
|
const now = new Date()
|
|
496
578
|
|
|
497
579
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
498
|
-
TaskService._scheduledJobsFor(metadata).then((jobs) => {
|
|
580
|
+
TaskService._scheduledJobsFor(metadata).then(async (jobs) => {
|
|
499
581
|
if (TaskService._stopped) return
|
|
500
582
|
for (const job of jobs) {
|
|
583
|
+
if (!job.workspaceId) continue
|
|
501
584
|
if (!matchesCron(job.schedule, now)) continue
|
|
502
585
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
586
|
+
const claimKey = buildScheduleClaimKey(metadata.id, job.workspaceId, now)
|
|
587
|
+
void (async () => {
|
|
588
|
+
const won = await tryClaimDispatch(claimKey)
|
|
589
|
+
if (!won) {
|
|
590
|
+
_serviceLog.info(
|
|
591
|
+
`Skipping schedule for task "${metadata.id}" — dispatch claim lost`
|
|
592
|
+
+ (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
|
|
593
|
+
)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
_serviceLog.info(
|
|
598
|
+
`Schedule fired for task "${metadata.id}"`
|
|
599
|
+
+ (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
const req = {
|
|
603
|
+
userId: ConfigService.BotUserId,
|
|
604
|
+
workspaceId: job.workspaceId,
|
|
605
|
+
user: {
|
|
606
|
+
id: ConfigService.BotUserId,
|
|
607
|
+
workspaces: [job.workspaceId],
|
|
608
|
+
policies: [],
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
const taskContext = {
|
|
612
|
+
event: { type: 'scheduled', taskId: metadata.id },
|
|
613
|
+
sdk: await sdkBoundTo(req),
|
|
614
|
+
integrations: IntegrationService,
|
|
615
|
+
log: createLogger(metadata.id),
|
|
616
|
+
payload: job.workspaceId ? { workspaceId: job.workspaceId } : undefined,
|
|
617
|
+
// Bot member of this workspace only. Nested sdk.invoke keeps this req.
|
|
618
|
+
req,
|
|
619
|
+
triggerConfig: job.config,
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
TaskRunService.executeAsync({
|
|
623
|
+
taskId: metadata.id,
|
|
624
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
625
|
+
context: taskContext,
|
|
626
|
+
trigger: 'on_schedule',
|
|
627
|
+
triggeredBy: {
|
|
628
|
+
schedule: job.schedule,
|
|
629
|
+
...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
|
|
630
|
+
},
|
|
631
|
+
executionEnv: 'ossy_server',
|
|
632
|
+
audit: metadata.audit !== false,
|
|
633
|
+
})
|
|
634
|
+
})()
|
|
530
635
|
}
|
|
531
636
|
}).catch((err) => {
|
|
532
637
|
_serviceLog.warn(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals'
|
|
2
|
-
import { TaskService } from './task-service.js'
|
|
2
|
+
import { jobsForGlobalSchedule, TaskService } from './task-service.js'
|
|
3
3
|
import { TaskRun } from '../audit/task-run.aggregate.js'
|
|
4
4
|
import { ActionInvocation } from '../audit/action-invocation.aggregate.js'
|
|
5
5
|
import { TaskRunListProjection } from '../audit/task-run-list.aggregate.js'
|
|
@@ -7,6 +7,19 @@ import { detectChannel } from '../audit/detect-channel.js'
|
|
|
7
7
|
import { hashPayload, moduleIdFromTaskId } from '../audit/audit-helpers.js'
|
|
8
8
|
import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
|
|
9
9
|
|
|
10
|
+
describe('jobsForGlobalSchedule', () => {
|
|
11
|
+
it('fires once per workspace', () => {
|
|
12
|
+
expect(jobsForGlobalSchedule('0 * * * *', ['ws-a', 'ws-b'])).toEqual([
|
|
13
|
+
{ workspaceId: 'ws-a', schedule: '0 * * * *' },
|
|
14
|
+
{ workspaceId: 'ws-b', schedule: '0 * * * *' },
|
|
15
|
+
])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns no jobs when the bot has no workspaces', () => {
|
|
19
|
+
expect(jobsForGlobalSchedule('0 6 * * *', [])).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
10
23
|
describe('TaskService triggers', () => {
|
|
11
24
|
it('matches on_action triggers with glob patterns', () => {
|
|
12
25
|
expect(TaskService._matchesActionTrigger(
|
|
@@ -109,6 +122,61 @@ describe('TaskService triggers', () => {
|
|
|
109
122
|
TaskService.setInstanceTriggersResolver(null)
|
|
110
123
|
})
|
|
111
124
|
|
|
125
|
+
it('skips audit events before resolving instance triggers', async () => {
|
|
126
|
+
const calls = []
|
|
127
|
+
const previousTasks = TaskService._tasks
|
|
128
|
+
const previousById = TaskService._tasksById
|
|
129
|
+
TaskService._tasks = []
|
|
130
|
+
TaskService._tasksById = new Map()
|
|
131
|
+
TaskService.setInstanceTriggersResolver(async ({ taskId, workspaceId }) => {
|
|
132
|
+
calls.push({ taskId, workspaceId })
|
|
133
|
+
return []
|
|
134
|
+
})
|
|
135
|
+
TaskService.registerTask({
|
|
136
|
+
metadata: {
|
|
137
|
+
id: '@ossy/media-tasks/tasks/resize-common-web',
|
|
138
|
+
configurable: true,
|
|
139
|
+
triggers: [{ type: '@ossy/platform/schema/file', event: 'Created' }],
|
|
140
|
+
},
|
|
141
|
+
run () {},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const auditEvent = (type) => ({
|
|
145
|
+
id: `evt-${type}`,
|
|
146
|
+
type,
|
|
147
|
+
event: 'Recorded',
|
|
148
|
+
resourceId: 'r1',
|
|
149
|
+
payload: { belongsTo: 'ws1' },
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
for (const type of [
|
|
154
|
+
'@ossy/platform/schema/task-run',
|
|
155
|
+
'@ossy/platform/schema/action-invocation',
|
|
156
|
+
'@ossy/platform/schema/meter-event',
|
|
157
|
+
]) {
|
|
158
|
+
await TaskService.dispatch(auditEvent(type), { sdk: {} })
|
|
159
|
+
}
|
|
160
|
+
expect(calls).toEqual([])
|
|
161
|
+
|
|
162
|
+
await TaskService.dispatch({
|
|
163
|
+
id: 'evt-file',
|
|
164
|
+
type: '@ossy/platform/schema/file',
|
|
165
|
+
event: 'Created',
|
|
166
|
+
resourceId: 'file1',
|
|
167
|
+
payload: { belongsTo: 'ws1', location: '/incoming/a.jpg' },
|
|
168
|
+
}, { sdk: {} })
|
|
169
|
+
expect(calls).toEqual([{
|
|
170
|
+
taskId: '@ossy/media-tasks/tasks/resize-common-web',
|
|
171
|
+
workspaceId: 'ws1',
|
|
172
|
+
}])
|
|
173
|
+
} finally {
|
|
174
|
+
TaskService._tasks = previousTasks
|
|
175
|
+
TaskService._tasksById = previousById
|
|
176
|
+
TaskService.setInstanceTriggersResolver(null)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
112
180
|
it('uses instance triggers for configurable tasks', async () => {
|
|
113
181
|
const instanceTriggers = [{
|
|
114
182
|
kind: 'on_event',
|