@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.20.0",
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.0",
51
+ "@ossy/event-store": "^3.20.1",
52
52
  "@ossy/locale": "^3.4.0",
53
- "@ossy/manifest": "^3.20.0",
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.0",
58
- "@ossy/sdk": "^3.20.0",
57
+ "@ossy/schema": "^3.20.1",
58
+ "@ossy/sdk": "^3.20.1",
59
59
  "@ossy/tokens": "^3.11.1",
60
- "@ossy/users": "^3.20.0",
61
- "@ossy/workspaces": "^3.20.0",
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": "edab6a966f41c07a68bdd86f3864cc2ad02e3406"
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
- const stream = ChangeStream._changeStream
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 async _open () {
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
- if (ChangeStream._changeStream) {
102
- try {
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
- changeStream.on('close', () => {
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('[ChangeStream] close detected')
176
+ if (changeStream.replaced || ChangeStream._stopped) return
177
+ log.info(`[ChangeStream] ${label} detected`)
158
178
  scheduleOnce()
159
- })
179
+ }
160
180
 
161
- changeStream.on('end', () => {
162
- if (ChangeStream._changeStream === changeStream) {
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
- * Resolve actor/workspace from an eventstore document for nested action invokes.
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 {object} [event]
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 reqFromEvent (event) {
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: event?.createdBy
14
- ?? event?.payload?.createdBy
15
- ?? event?.payload?.userId
16
- ?? 'system',
17
- workspaceId: event?.payload?.belongsTo
18
- ?? event?.belongsTo
19
- ?? event?.payload?.workspaceId,
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 req = options.req ?? { userId: 'system' }
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/workspace from a triggering event for one dispatch.
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 { createInProcessSdk } = await import('./in-process-sdk.js')
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 event actor/workspace via withEventContext', async () => {
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: { userId: 'author', workspaceId: 'workspace-a' },
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
- if (TaskService._instanceConfigResolver && metadata?.id && workspaceId) {
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 ?? TaskService._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 ?? TaskService._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
- _serviceLog.info(`Dispatching task "${metadata.id}"`)
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
- const taskContext = buildTaskContext(
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
- const _taskId = metadata.id
339
- TaskRunService.executeAsync({
340
- taskId: _taskId,
341
- handler: TaskService._handlerWithInputs(metadata, handler),
342
- context: taskContext,
343
- trigger: 'on_event',
344
- triggeredBy: {
345
- eventId: event.id,
346
- resourceId: event.resourceId,
347
- type: event.type,
348
- event: event.event,
349
- },
350
- executionEnv: 'ossy_server',
351
- audit: metadata.audit !== false,
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
- _serviceLog.info(`Dispatching on_action task "${metadata.id}" for action "${actionId}"`)
397
-
398
- const taskContext = buildTaskContext(
399
- {
400
- sdk: sdk ?? TaskService._sdk,
401
- integrations: IntegrationService,
402
- log: createLogger(metadata.id),
403
- req,
404
- actionInvocationId,
405
- payload,
406
- triggerConfig: matchedTrigger.config,
407
- },
408
- {
409
- action: {
410
- id: actionId,
411
- success,
412
- result,
413
- error: error ? { message: error.message } : undefined,
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
- const _taskId = metadata.id
419
- TaskRunService.executeAsync({
420
- taskId: _taskId,
421
- handler: TaskService._handlerWithInputs(metadata, handler),
422
- context: taskContext,
423
- trigger: 'on_action',
424
- triggeredBy: {
425
- actionId,
426
- actionInvocationId: actionInvocationId ?? null,
427
- success,
428
- },
429
- executionEnv: 'ossy_server',
430
- audit: metadata.audit !== false,
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({ workspaceId: null, schedule: metadata.schedule.trim() })
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({ workspaceId: null, schedule })
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
- _serviceLog.info(
504
- `Schedule fired for task "${metadata.id}"`
505
- + (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
506
- )
507
-
508
- const taskContext = {
509
- event: { type: 'scheduled', taskId: metadata.id },
510
- sdk: TaskService._sdk,
511
- integrations: IntegrationService,
512
- log: createLogger(metadata.id),
513
- payload: job.workspaceId ? { workspaceId: job.workspaceId } : undefined,
514
- req: job.workspaceId ? { workspaceId: job.workspaceId, userId: 'system' } : undefined,
515
- triggerConfig: job.config,
516
- }
517
-
518
- TaskRunService.executeAsync({
519
- taskId: metadata.id,
520
- handler: TaskService._handlerWithInputs(metadata, handler),
521
- context: taskContext,
522
- trigger: 'on_schedule',
523
- triggeredBy: {
524
- schedule: job.schedule,
525
- ...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
526
- },
527
- executionEnv: 'ossy_server',
528
- audit: metadata.audit !== false,
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',