@ossy/platform 3.19.0 → 3.20.1
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/cron.js +6 -1
- 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 +183 -91
- package/src/tasks/task-service.spec.js +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.1",
|
|
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.
|
|
51
|
+
"@ossy/event-store": "^3.20.1",
|
|
52
52
|
"@ossy/locale": "^3.4.0",
|
|
53
|
-
"@ossy/manifest": "^3.
|
|
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.
|
|
58
|
-
"@ossy/sdk": "^3.
|
|
57
|
+
"@ossy/schema": "^3.20.1",
|
|
58
|
+
"@ossy/sdk": "^3.20.1",
|
|
59
59
|
"@ossy/tokens": "^3.11.1",
|
|
60
|
-
"@ossy/users": "^3.
|
|
61
|
-
"@ossy/workspaces": "^3.
|
|
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": "3e8bb118b3ef163792a004663ccb803ab51a8601"
|
|
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
|
}
|
package/src/tasks/cron.js
CHANGED
|
@@ -76,10 +76,15 @@ export function matchesCron(expression, date = new Date()) {
|
|
|
76
76
|
const month = date.getMonth() + 1 // 1–12
|
|
77
77
|
const dow = date.getDay() // 0–6 (0=Sunday)
|
|
78
78
|
|
|
79
|
+
// Special: L in dom field means last day of the month
|
|
80
|
+
const domMatches = domF === 'L'
|
|
81
|
+
? dom === new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
|
82
|
+
: matchField(domF, dom, 1, 31)
|
|
83
|
+
|
|
79
84
|
return (
|
|
80
85
|
matchField(minuteF, minute, 0, 59) &&
|
|
81
86
|
matchField(hourF, hour, 0, 23) &&
|
|
82
|
-
|
|
87
|
+
domMatches &&
|
|
83
88
|
matchField(monthF, month, 1, 12) &&
|
|
84
89
|
matchField(dowF, dow, 0, 6)
|
|
85
90
|
)
|
|
@@ -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'
|
|
@@ -17,6 +24,42 @@ function buildTaskContext (baseContext, extras = {}) {
|
|
|
17
24
|
}
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
/**
|
|
28
|
+
* In-process SDK bound to this request. Dynamic import avoids a cycle through
|
|
29
|
+
* ActionService. A missing req loads the bot; callers that have a user or a
|
|
30
|
+
* workspace-scoped bot req pass that through.
|
|
31
|
+
*
|
|
32
|
+
* @param {object | undefined} req
|
|
33
|
+
*/
|
|
34
|
+
async function sdkBoundTo (req) {
|
|
35
|
+
const { createInProcessSdk } = await import('./in-process-sdk.js')
|
|
36
|
+
return createInProcessSdk(req ? { req } : {})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function botWorkspaceIds () {
|
|
40
|
+
const { resolveBotReq } = await import('./in-process-sdk.js')
|
|
41
|
+
const bot = await resolveBotReq()
|
|
42
|
+
const workspaces = bot?.user?.workspaces
|
|
43
|
+
if (!Array.isArray(workspaces)) return []
|
|
44
|
+
return [...new Set(workspaces.filter((id) => typeof id === 'string' && id))]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One cron firing per workspace. A global schedule must not run as a bot that
|
|
49
|
+
* can read every workspace in a single invoke.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} schedule
|
|
52
|
+
* @param {string[]} workspaceIds
|
|
53
|
+
* @returns {Array<{ workspaceId: string, schedule: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export function jobsForGlobalSchedule (schedule, workspaceIds) {
|
|
56
|
+
const trimmed = typeof schedule === 'string' ? schedule.trim() : ''
|
|
57
|
+
if (!trimmed) return []
|
|
58
|
+
return (workspaceIds ?? [])
|
|
59
|
+
.filter((id) => typeof id === 'string' && id)
|
|
60
|
+
.map((workspaceId) => ({ workspaceId, schedule: trimmed }))
|
|
61
|
+
}
|
|
62
|
+
|
|
20
63
|
export class TaskService {
|
|
21
64
|
|
|
22
65
|
static _stopped = false
|
|
@@ -104,7 +147,9 @@ export class TaskService {
|
|
|
104
147
|
?? context?.event?.payload?.belongsTo
|
|
105
148
|
?? null
|
|
106
149
|
let config = {}
|
|
107
|
-
|
|
150
|
+
// Only configurable tasks have instance config. Resolvers list resources by
|
|
151
|
+
// calling a task handler; resolving config for that list re-enters this path.
|
|
152
|
+
if (TaskService._instanceConfigResolver && metadata?.id && workspaceId && TaskService._isConfigurable(metadata)) {
|
|
108
153
|
try {
|
|
109
154
|
const loaded = await TaskService._instanceConfigResolver({
|
|
110
155
|
taskId: metadata.id,
|
|
@@ -260,9 +305,11 @@ export class TaskService {
|
|
|
260
305
|
|
|
261
306
|
// GET/API handlers often omit integrations; default like dispatch/scheduler so
|
|
262
307
|
// tasks can always use integrations.get('email') (including local delivery).
|
|
308
|
+
// Nested invokes keep this sdk, so the first actor (the caller, or an sdk
|
|
309
|
+
// the entry point already bound) is the actor for the whole chain.
|
|
263
310
|
const invokeContext = {
|
|
264
311
|
...context,
|
|
265
|
-
sdk: context.sdk ??
|
|
312
|
+
sdk: context.sdk ?? await sdkBoundTo(context.req),
|
|
266
313
|
integrations: context.integrations ?? IntegrationService,
|
|
267
314
|
log: context.log ?? createLogger(id),
|
|
268
315
|
}
|
|
@@ -310,7 +357,7 @@ export class TaskService {
|
|
|
310
357
|
static async dispatch(event, { sdk } = {}) {
|
|
311
358
|
if (!event) return
|
|
312
359
|
|
|
313
|
-
const baseSdk = sdk ??
|
|
360
|
+
const baseSdk = sdk ?? await sdkBoundTo()
|
|
314
361
|
const effectiveSdk = typeof baseSdk?.withEventContext === 'function'
|
|
315
362
|
? baseSdk.withEventContext(event)
|
|
316
363
|
: baseSdk
|
|
@@ -324,32 +371,43 @@ export class TaskService {
|
|
|
324
371
|
const matchedTrigger = triggers.find((trigger) => TaskService._matchesEventTrigger(trigger, event))
|
|
325
372
|
if (!matchedTrigger) continue
|
|
326
373
|
|
|
327
|
-
|
|
374
|
+
const _taskId = metadata.id
|
|
375
|
+
const claimKey = buildEventClaimKey(_taskId, event.id)
|
|
376
|
+
|
|
377
|
+
// Multiple startServer processes share Mongo changestreams (#982) — claim before run.
|
|
378
|
+
void (async () => {
|
|
379
|
+
const won = await tryClaimDispatch(claimKey)
|
|
380
|
+
if (!won) {
|
|
381
|
+
_serviceLog.info(`Skipping task "${_taskId}" — dispatch claim lost`)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
328
384
|
|
|
329
|
-
|
|
330
|
-
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) },
|
|
331
|
-
{
|
|
332
|
-
event,
|
|
333
|
-
payload: { workspaceId },
|
|
334
|
-
triggerConfig: matchedTrigger.config,
|
|
335
|
-
},
|
|
336
|
-
)
|
|
385
|
+
_serviceLog.info(`Dispatching task "${_taskId}"`)
|
|
337
386
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
387
|
+
const taskContext = buildTaskContext(
|
|
388
|
+
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(_taskId) },
|
|
389
|
+
{
|
|
390
|
+
event,
|
|
391
|
+
payload: { workspaceId },
|
|
392
|
+
triggerConfig: matchedTrigger.config,
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
TaskRunService.executeAsync({
|
|
397
|
+
taskId: _taskId,
|
|
398
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
399
|
+
context: taskContext,
|
|
400
|
+
trigger: 'on_event',
|
|
401
|
+
triggeredBy: {
|
|
402
|
+
eventId: event.id,
|
|
403
|
+
resourceId: event.resourceId,
|
|
404
|
+
type: event.type,
|
|
405
|
+
event: event.event,
|
|
406
|
+
},
|
|
407
|
+
executionEnv: 'ossy_server',
|
|
408
|
+
audit: metadata.audit !== false,
|
|
409
|
+
})
|
|
410
|
+
})()
|
|
353
411
|
}
|
|
354
412
|
}
|
|
355
413
|
|
|
@@ -393,42 +451,52 @@ export class TaskService {
|
|
|
393
451
|
|
|
394
452
|
if (!matchedTrigger) continue
|
|
395
453
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
454
|
+
const _taskId = metadata.id
|
|
455
|
+
const claimKey = buildActionClaimKey(_taskId, actionInvocationId)
|
|
456
|
+
|
|
457
|
+
void (async () => {
|
|
458
|
+
const won = await tryClaimDispatch(claimKey)
|
|
459
|
+
if (!won) {
|
|
460
|
+
_serviceLog.info(`Skipping on_action task "${_taskId}" — dispatch claim lost`)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_serviceLog.info(`Dispatching on_action task "${_taskId}" for action "${actionId}"`)
|
|
465
|
+
|
|
466
|
+
const taskContext = buildTaskContext(
|
|
467
|
+
{
|
|
468
|
+
sdk: sdk ?? await sdkBoundTo(req),
|
|
469
|
+
integrations: IntegrationService,
|
|
470
|
+
log: createLogger(_taskId),
|
|
471
|
+
req,
|
|
472
|
+
actionInvocationId,
|
|
473
|
+
payload,
|
|
474
|
+
triggerConfig: matchedTrigger.config,
|
|
414
475
|
},
|
|
415
|
-
|
|
416
|
-
|
|
476
|
+
{
|
|
477
|
+
action: {
|
|
478
|
+
id: actionId,
|
|
479
|
+
success,
|
|
480
|
+
result,
|
|
481
|
+
error: error ? { message: error.message } : undefined,
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
)
|
|
417
485
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
})
|
|
486
|
+
TaskRunService.executeAsync({
|
|
487
|
+
taskId: _taskId,
|
|
488
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
489
|
+
context: taskContext,
|
|
490
|
+
trigger: 'on_action',
|
|
491
|
+
triggeredBy: {
|
|
492
|
+
actionId,
|
|
493
|
+
actionInvocationId: actionInvocationId ?? null,
|
|
494
|
+
success,
|
|
495
|
+
},
|
|
496
|
+
executionEnv: 'ossy_server',
|
|
497
|
+
audit: metadata.audit !== false,
|
|
498
|
+
})
|
|
499
|
+
})()
|
|
432
500
|
}
|
|
433
501
|
}
|
|
434
502
|
|
|
@@ -465,14 +533,15 @@ export class TaskService {
|
|
|
465
533
|
return jobs
|
|
466
534
|
}
|
|
467
535
|
|
|
536
|
+
const workspaceIds = await botWorkspaceIds()
|
|
468
537
|
if (typeof metadata?.schedule === 'string' && metadata.schedule.trim()) {
|
|
469
|
-
jobs.push(
|
|
538
|
+
jobs.push(...jobsForGlobalSchedule(metadata.schedule, workspaceIds))
|
|
470
539
|
}
|
|
471
540
|
for (const trigger of metadata?.triggers ?? []) {
|
|
472
541
|
const kind = trigger?.trigger ?? trigger?.on ?? trigger?.kind
|
|
473
542
|
const schedule = typeof trigger?.schedule === 'string' ? trigger.schedule.trim() : ''
|
|
474
543
|
if ((kind === 'on_schedule' || (!kind && schedule)) && schedule) {
|
|
475
|
-
jobs.push(
|
|
544
|
+
jobs.push(...jobsForGlobalSchedule(schedule, workspaceIds))
|
|
476
545
|
}
|
|
477
546
|
}
|
|
478
547
|
return jobs
|
|
@@ -495,38 +564,61 @@ export class TaskService {
|
|
|
495
564
|
const now = new Date()
|
|
496
565
|
|
|
497
566
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
498
|
-
TaskService._scheduledJobsFor(metadata).then((jobs) => {
|
|
567
|
+
TaskService._scheduledJobsFor(metadata).then(async (jobs) => {
|
|
499
568
|
if (TaskService._stopped) return
|
|
500
569
|
for (const job of jobs) {
|
|
570
|
+
if (!job.workspaceId) continue
|
|
501
571
|
if (!matchesCron(job.schedule, now)) continue
|
|
502
572
|
|
|
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
|
-
|
|
573
|
+
const claimKey = buildScheduleClaimKey(metadata.id, job.workspaceId, now)
|
|
574
|
+
void (async () => {
|
|
575
|
+
const won = await tryClaimDispatch(claimKey)
|
|
576
|
+
if (!won) {
|
|
577
|
+
_serviceLog.info(
|
|
578
|
+
`Skipping schedule for task "${metadata.id}" — dispatch claim lost`
|
|
579
|
+
+ (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
|
|
580
|
+
)
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
_serviceLog.info(
|
|
585
|
+
`Schedule fired for task "${metadata.id}"`
|
|
586
|
+
+ (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
const req = {
|
|
590
|
+
userId: ConfigService.BotUserId,
|
|
591
|
+
workspaceId: job.workspaceId,
|
|
592
|
+
user: {
|
|
593
|
+
id: ConfigService.BotUserId,
|
|
594
|
+
workspaces: [job.workspaceId],
|
|
595
|
+
policies: [],
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
const taskContext = {
|
|
599
|
+
event: { type: 'scheduled', taskId: metadata.id },
|
|
600
|
+
sdk: await sdkBoundTo(req),
|
|
601
|
+
integrations: IntegrationService,
|
|
602
|
+
log: createLogger(metadata.id),
|
|
603
|
+
payload: job.workspaceId ? { workspaceId: job.workspaceId } : undefined,
|
|
604
|
+
// Bot member of this workspace only. Nested sdk.invoke keeps this req.
|
|
605
|
+
req,
|
|
606
|
+
triggerConfig: job.config,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
TaskRunService.executeAsync({
|
|
610
|
+
taskId: metadata.id,
|
|
611
|
+
handler: TaskService._handlerWithInputs(metadata, handler),
|
|
612
|
+
context: taskContext,
|
|
613
|
+
trigger: 'on_schedule',
|
|
614
|
+
triggeredBy: {
|
|
615
|
+
schedule: job.schedule,
|
|
616
|
+
...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
|
|
617
|
+
},
|
|
618
|
+
executionEnv: 'ossy_server',
|
|
619
|
+
audit: metadata.audit !== false,
|
|
620
|
+
})
|
|
621
|
+
})()
|
|
530
622
|
}
|
|
531
623
|
}).catch((err) => {
|
|
532
624
|
_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(
|