@ossy/platform 3.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.20.0",
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.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": "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
- 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'
@@ -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
- if (TaskService._instanceConfigResolver && metadata?.id && workspaceId) {
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 ?? TaskService._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 ?? TaskService._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
- _serviceLog.info(`Dispatching task "${metadata.id}"`)
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
- const taskContext = buildTaskContext(
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
- 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
- })
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
- _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,
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
- 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
- })
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({ workspaceId: null, schedule: metadata.schedule.trim() })
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({ workspaceId: null, schedule })
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
- _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
- })
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(