@ossy/platform 3.15.0 → 3.17.0

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.
Files changed (34) hide show
  1. package/package.json +9 -8
  2. package/src/audit/audit-helpers.js +60 -6
  3. package/src/audit/audit-helpers.spec.js +42 -0
  4. package/src/audit/task-run-list.aggregate.js +3 -0
  5. package/src/audit/task-run-service.js +10 -2
  6. package/src/audit/task-run.aggregate.js +2 -0
  7. package/src/file.schema.js +0 -4
  8. package/src/metering/assert-llm-usage-budget.js +83 -0
  9. package/src/metering/assert-llm-usage-budget.spec.js +129 -0
  10. package/src/metering/get-workspace-llm-usage.action.js +5 -0
  11. package/src/metering/get-workspace-llm-usage.task.js +31 -0
  12. package/src/metering/get-workspace-llm-usage.task.spec.js +74 -0
  13. package/src/metering/get-workspace-task-metering.action.js +5 -0
  14. package/src/metering/get-workspace-task-metering.task.js +30 -0
  15. package/src/metering/get-workspace-task-metering.task.spec.js +43 -0
  16. package/src/metering/index.js +11 -0
  17. package/src/metering/llm-usage.aggregate.js +127 -0
  18. package/src/metering/llm-usage.aggregate.spec.js +95 -0
  19. package/src/metering/metering-service.js +13 -0
  20. package/src/metering/metering-service.spec.js +62 -0
  21. package/src/metering/task-metering.aggregate.js +108 -0
  22. package/src/metering/task-metering.aggregate.spec.js +71 -0
  23. package/src/metering/usage-month.js +7 -0
  24. package/src/runtime.js +2 -0
  25. package/src/server.js +2 -0
  26. package/src/ssr/detect-mobile-device.js +39 -0
  27. package/src/ssr/detect-mobile-device.spec.js +50 -0
  28. package/src/storage/storage-keys.js +24 -0
  29. package/src/storage/storage-keys.spec.js +12 -1
  30. package/src/tasks/in-process-sdk.js +1 -49
  31. package/src/tasks/in-process-sdk.spec.js +1 -39
  32. package/src/tasks/task-service.js +270 -41
  33. package/src/tasks/task-service.spec.js +108 -0
  34. package/src/text-completion.schema.js +37 -0
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { detectMobileDevice, parseSecChUaMobile } from './detect-mobile-device.js'
3
+
4
+ describe('parseSecChUaMobile', () => {
5
+ it('returns true for the mobile client hint', () => {
6
+ expect(parseSecChUaMobile('?1')).toBe(true)
7
+ })
8
+
9
+ it('returns false for the desktop client hint', () => {
10
+ expect(parseSecChUaMobile('?0')).toBe(false)
11
+ })
12
+
13
+ it('returns null when the header is missing or malformed', () => {
14
+ expect(parseSecChUaMobile(undefined)).toBeNull()
15
+ expect(parseSecChUaMobile(null)).toBeNull()
16
+ expect(parseSecChUaMobile('')).toBeNull()
17
+ expect(parseSecChUaMobile('mobile')).toBeNull()
18
+ })
19
+
20
+ it('tolerates surrounding whitespace', () => {
21
+ expect(parseSecChUaMobile(' ?1 ')).toBe(true)
22
+ })
23
+ })
24
+
25
+ describe('detectMobileDevice', () => {
26
+ it('prefers the Sec-CH-UA-Mobile client hint when present', () => {
27
+ const req = { headers: { 'sec-ch-ua-mobile': '?1', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0)' } }
28
+ expect(detectMobileDevice(req)).toBe(true)
29
+ })
30
+
31
+ it('falls back to sniffing User-Agent when the client hint is absent (e.g. Safari/Firefox)', () => {
32
+ const req = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit' } }
33
+ expect(detectMobileDevice(req)).toBe(true)
34
+ })
35
+
36
+ it('treats desktop User-Agents as non-mobile when the client hint is absent', () => {
37
+ const req = { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit' } }
38
+ expect(detectMobileDevice(req)).toBe(false)
39
+ })
40
+
41
+ it('supports Express-style req.get(...) as a fallback lookup', () => {
42
+ const req = { headers: {}, get: (name) => (name === 'Sec-CH-UA-Mobile' ? '?1' : undefined) }
43
+ expect(detectMobileDevice(req)).toBe(true)
44
+ })
45
+
46
+ it('defaults to false when no request or headers are available', () => {
47
+ expect(detectMobileDevice(undefined)).toBe(false)
48
+ expect(detectMobileDevice({})).toBe(false)
49
+ })
50
+ })
@@ -41,6 +41,30 @@ export function derivativeObjectKey (resourceId, variant) {
41
41
  return `${resourceId}:${variant}`
42
42
  }
43
43
 
44
+ /**
45
+ * Task-scoped derivative key: `{resourceId}:task-{concept}-{artifactName}`.
46
+ *
47
+ * @param {string} resourceId
48
+ * @param {string} taskConcept
49
+ * @param {string} artifactName
50
+ * @returns {string}
51
+ */
52
+ export function taskArtifactObjectKey (resourceId, taskConcept, artifactName) {
53
+ if (!taskConcept || typeof taskConcept !== 'string') {
54
+ throw new Error('[storage-keys] taskConcept is required')
55
+ }
56
+ if (!artifactName || typeof artifactName !== 'string') {
57
+ throw new Error('[storage-keys] artifactName is required')
58
+ }
59
+ if (!/^[A-Za-z0-9_-]+$/.test(taskConcept)) {
60
+ throw new Error('[storage-keys] invalid taskConcept')
61
+ }
62
+ if (!/^[A-Za-z0-9_-]+$/.test(artifactName)) {
63
+ throw new Error('[storage-keys] invalid artifactName')
64
+ }
65
+ return derivativeObjectKey(resourceId, `task-${taskConcept}-${artifactName}`)
66
+ }
67
+
44
68
  export function assertStorageKey (key) {
45
69
  if (!key || typeof key !== 'string' || !KEY_PATTERN.test(key)) {
46
70
  throw new Error(`[storage-keys] invalid storage key: ${key}`)
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from '@jest/globals'
2
- import { originalObjectKey, derivativeObjectKey, assertStorageKey, coerceStorageKey } from './storage-keys.js'
2
+ import {
3
+ originalObjectKey,
4
+ derivativeObjectKey,
5
+ taskArtifactObjectKey,
6
+ assertStorageKey,
7
+ coerceStorageKey,
8
+ } from './storage-keys.js'
3
9
 
4
10
  describe('storage-keys', () => {
5
11
  it('originalObjectKey returns flat resourceId', () => {
@@ -10,6 +16,11 @@ describe('storage-keys', () => {
10
16
  expect(derivativeObjectKey('abc123', 'galleryMedium')).toBe('abc123:galleryMedium')
11
17
  })
12
18
 
19
+ it('taskArtifactObjectKey namespaces by task concept', () => {
20
+ expect(taskArtifactObjectKey('abc123', 'resize-common-web', 'galleryLarge'))
21
+ .toBe('abc123:task-resize-common-web-galleryLarge')
22
+ })
23
+
13
24
  it('rejects path-like keys', () => {
14
25
  expect(() => assertStorageKey('media/ws/file')).toThrow()
15
26
  })
@@ -1,21 +1,6 @@
1
1
  import { resolveActionId } from '@ossy/sdk'
2
2
  import { ActionService } from '../actions/action.service.js'
3
3
  import { IntegrationService } from '../integration.service.js'
4
- import { StorageClient } from '../storage/storage.client.js'
5
- import { derivativeObjectKey } from '../storage/storage-keys.js'
6
-
7
- /**
8
- * @param {unknown} file
9
- * @returns {Promise<Buffer>}
10
- */
11
- async function fileToBuffer (file) {
12
- if (Buffer.isBuffer(file)) return file
13
- if (file instanceof Uint8Array) return Buffer.from(file)
14
- if (typeof file?.arrayBuffer === 'function') {
15
- return Buffer.from(await file.arrayBuffer())
16
- }
17
- throw new Error('[InProcessSdk] uploadNamedVersion expects a File, Blob, Buffer, or Uint8Array')
18
- }
19
4
 
20
5
  /**
21
6
  * Resolve actor/workspace from an eventstore document for nested action invokes.
@@ -39,8 +24,7 @@ function reqFromEvent (event) {
39
24
  * Same-process SDK for changestream / scheduled tasks when no HTTP bot
40
25
  * credentials (`API_URL` + `OSSY_API_KEY`) are configured.
41
26
  *
42
- * Implements the surface media and follow-up tasks use: `invoke` and
43
- * `resources.uploadNamedVersion`, via ActionService + StorageClient.
27
+ * Implements `invoke` via ActionService (task outputs write through patch-content).
44
28
  *
45
29
  * @param {{ req?: { userId?: string, workspaceId?: string } }} [options]
46
30
  */
@@ -69,38 +53,6 @@ export function createInProcessSdk (options = {}) {
69
53
  withEventContext (event) {
70
54
  return createInProcessSdk({ req: reqFromEvent(event) })
71
55
  },
72
-
73
- get resources () {
74
- return {
75
- /**
76
- * Patch named derivative metadata, then write bytes to storage.
77
- * @param {{ id: string, name: string, file: File | Blob | Buffer | Uint8Array }} args
78
- */
79
- async uploadNamedVersion ({ id, name, file }) {
80
- if (!id) throw new Error('[InProcessSdk] uploadNamedVersion requires id')
81
- if (!name) throw new Error('[InProcessSdk] uploadNamedVersion requires name')
82
- if (!file) throw new Error('[InProcessSdk] uploadNamedVersion requires file')
83
-
84
- const size = typeof file.size === 'number' ? file.size : undefined
85
- const type = typeof file.type === 'string' ? file.type : undefined
86
- const buffer = await fileToBuffer(file)
87
-
88
- const resource = await sdk.invoke('@ossy/resources/actions/upload-named-version', {
89
- id,
90
- namedVersion: name,
91
- // HTTP SDK historically sent `name`; accept either in the task.
92
- name,
93
- size: size ?? buffer.length,
94
- type,
95
- })
96
-
97
- // Action response may rewrite `sizes` to read URLs via withResourceMedia;
98
- // always persist with the canonical derivative key.
99
- await StorageClient.save(derivativeObjectKey(id, name), buffer)
100
- return resource
101
- },
102
- }
103
- },
104
56
  }
105
57
 
106
58
  return sdk
@@ -1,15 +1,9 @@
1
1
  import { describe, expect, it, jest } from '@jest/globals'
2
2
 
3
3
  const invokeMock = jest.fn(async (_id, context) => ({
4
- id: context.payload.id,
5
- content: {
6
- sizes: { [context.payload.namedVersion]: `${context.payload.id}:${context.payload.namedVersion}` },
7
- uploadUrl: '/local-storage?key=unused',
8
- },
4
+ id: context.payload.resourceId ?? context.payload.id,
9
5
  }))
10
6
 
11
- const saveMock = jest.fn(async () => {})
12
-
13
7
  jest.unstable_mockModule('../actions/action.service.js', () => ({
14
8
  ActionService: { invoke: invokeMock },
15
9
  }))
@@ -18,10 +12,6 @@ jest.unstable_mockModule('../integration.service.js', () => ({
18
12
  IntegrationService: { get: () => null },
19
13
  }))
20
14
 
21
- jest.unstable_mockModule('../storage/storage.client.js', () => ({
22
- StorageClient: { save: saveMock },
23
- }))
24
-
25
15
  const { createInProcessSdk } = await import('./in-process-sdk.js')
26
16
 
27
17
  describe('createInProcessSdk', () => {
@@ -58,32 +48,4 @@ describe('createInProcessSdk', () => {
58
48
  }),
59
49
  )
60
50
  })
61
-
62
- it('uploadNamedVersion patches then saves bytes', async () => {
63
- invokeMock.mockClear()
64
- saveMock.mockClear()
65
- const sdk = createInProcessSdk()
66
- const bytes = new Uint8Array([1, 2, 3, 4])
67
-
68
- await sdk.resources.uploadNamedVersion({
69
- id: 'file1',
70
- name: 'thumbnailSmall',
71
- file: new File([bytes], 'thumbnailSmall', { type: 'image/jpeg' }),
72
- })
73
-
74
- expect(invokeMock).toHaveBeenCalledWith(
75
- '@ossy/resources/actions/upload-named-version',
76
- expect.objectContaining({
77
- payload: expect.objectContaining({
78
- id: 'file1',
79
- namedVersion: 'thumbnailSmall',
80
- type: 'image/jpeg',
81
- }),
82
- }),
83
- )
84
- expect(saveMock).toHaveBeenCalledWith(
85
- 'file1:thumbnailSmall',
86
- expect.any(Buffer),
87
- )
88
- })
89
51
  })
@@ -2,7 +2,7 @@ import { matchesGlob } from './glob.js'
2
2
  import { matchesCron } from './cron.js'
3
3
  import { IntegrationService } from '../integration.service.js'
4
4
  import { createLogger } from '@ossy/observability'
5
- import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
5
+ import { isPrimaryTaskForAction, resolveTaskInputs, taskIdFromActionId } from '@ossy/schema'
6
6
  import { TaskRunService } from '../audit/task-run-service.js'
7
7
  import { resolveClientIp, runWithEventContext } from '@ossy/event-store'
8
8
 
@@ -38,6 +38,27 @@ export class TaskService {
38
38
  */
39
39
  static _sdk = null
40
40
 
41
+ /**
42
+ * Optional workspace triggers for configurable tasks.
43
+ * Return `null` when no instance exists; return `[]` when inactive;
44
+ * return trigger objects (definition shape) to OR-match instead of definition triggers.
45
+ * @type {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object[] | null>)}
46
+ */
47
+ static _instanceTriggersResolver = null
48
+
49
+ /**
50
+ * Optional per-workspace schedules derived from instance on_schedule triggers.
51
+ * Only consulted for configurable tasks.
52
+ * @type {null | ((args: { taskId: string }) => Promise<Array<{ workspaceId: string, schedule: string }>>)}
53
+ */
54
+ static _instanceScheduleResolver = null
55
+
56
+ /**
57
+ * Optional per-workspace input config for configurable tasks.
58
+ * @type {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object>)}
59
+ */
60
+ static _instanceConfigResolver = null
61
+
41
62
  /**
42
63
  * Register the SDK instance that every task will receive as `sdk`.
43
64
  * Call this once during application startup before ChangeStream / scheduler begin.
@@ -47,6 +68,140 @@ export class TaskService {
47
68
  TaskService._sdk = sdk
48
69
  }
49
70
 
71
+ /**
72
+ * @param {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object[] | null>)} resolver
73
+ */
74
+ static setInstanceTriggersResolver (resolver) {
75
+ TaskService._instanceTriggersResolver = typeof resolver === 'function' ? resolver : null
76
+ }
77
+
78
+ /**
79
+ * @param {null | ((args: { taskId: string }) => Promise<Array<{ workspaceId: string, schedule: string }>>)} resolver
80
+ */
81
+ static setInstanceScheduleResolver (resolver) {
82
+ TaskService._instanceScheduleResolver = typeof resolver === 'function' ? resolver : null
83
+ }
84
+
85
+ /**
86
+ * @param {null | ((args: { taskId: string, workspaceId?: string | null }) => Promise<object>)} resolver
87
+ */
88
+ static setInstanceConfigResolver (resolver) {
89
+ TaskService._instanceConfigResolver = typeof resolver === 'function' ? resolver : null
90
+ }
91
+
92
+ /**
93
+ * Load instance config and resolve declared inputs onto the run context.
94
+ *
95
+ * @param {object} metadata
96
+ * @param {object} context
97
+ * @returns {Promise<Record<string, unknown>>}
98
+ */
99
+ static async _resolveTaskInputs (metadata, context) {
100
+ const workspaceId = context?.req?.workspaceId
101
+ ?? context?.payload?.workspaceId
102
+ ?? context?.payload?.belongsTo
103
+ ?? context?.event?.belongsTo
104
+ ?? context?.event?.payload?.belongsTo
105
+ ?? null
106
+ let config = {}
107
+ if (TaskService._instanceConfigResolver && metadata?.id && workspaceId) {
108
+ try {
109
+ const loaded = await TaskService._instanceConfigResolver({
110
+ taskId: metadata.id,
111
+ workspaceId,
112
+ })
113
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded
114
+ } catch (err) {
115
+ _serviceLog.warn(
116
+ `Instance config resolver failed for ${metadata.id}: ${err?.message || err}`,
117
+ )
118
+ }
119
+ }
120
+ return resolveTaskInputs(metadata?.inputs, {
121
+ payload: context?.payload,
122
+ config,
123
+ triggerConfig: context?.triggerConfig,
124
+ event: context?.event,
125
+ resource: context?.resource,
126
+ })
127
+ }
128
+
129
+ /**
130
+ * @param {object} metadata
131
+ * @param {function} handler
132
+ * @returns {function}
133
+ */
134
+ static _handlerWithInputs (metadata, handler) {
135
+ return async (context) => {
136
+ context.inputs = await TaskService._resolveTaskInputs(metadata, context)
137
+ return handler(context)
138
+ }
139
+ }
140
+
141
+ /**
142
+ * @param {object | null | undefined} metadata
143
+ * @returns {boolean}
144
+ */
145
+ static _isConfigurable (metadata) {
146
+ if (metadata?.configurable === true) return true
147
+ // Legacy metadata still carrying instanceConfig
148
+ if (metadata?.instanceConfig && typeof metadata.instanceConfig === 'object') return true
149
+ return false
150
+ }
151
+
152
+ /**
153
+ * @param {object} metadata
154
+ * @param {string | null | undefined} workspaceId
155
+ * @returns {Promise<object[] | null>}
156
+ */
157
+ static async _resolveInstanceTriggers (metadata, workspaceId) {
158
+ if (!TaskService._instanceTriggersResolver || !metadata?.id || !workspaceId) {
159
+ return null
160
+ }
161
+ try {
162
+ const triggers = await TaskService._instanceTriggersResolver({
163
+ taskId: metadata.id,
164
+ workspaceId,
165
+ })
166
+ if (triggers == null) return null
167
+ return Array.isArray(triggers) ? triggers : []
168
+ } catch (err) {
169
+ _serviceLog.warn(
170
+ `Instance triggers resolver failed for ${metadata.id}: ${err?.message || err}`,
171
+ )
172
+ return null
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Event triggers used for dispatch: instance OR-list when configurable, else definition.
178
+ *
179
+ * @param {object} metadata
180
+ * @param {string | null | undefined} workspaceId
181
+ * @returns {Promise<{ triggers: object[], armed: boolean }>}
182
+ */
183
+ static async _eventTriggersForDispatch (metadata, workspaceId) {
184
+ if (!TaskService._isConfigurable(metadata)) {
185
+ return {
186
+ triggers: Array.isArray(metadata?.triggers) ? metadata.triggers : [],
187
+ armed: true,
188
+ }
189
+ }
190
+
191
+ const instanceTriggers = await TaskService._resolveInstanceTriggers(metadata, workspaceId)
192
+ if (!instanceTriggers || instanceTriggers.length === 0) {
193
+ return { triggers: [], armed: false }
194
+ }
195
+
196
+ return {
197
+ triggers: instanceTriggers.filter((trigger) => {
198
+ const kind = trigger?.trigger ?? trigger?.on ?? trigger?.kind
199
+ return kind !== 'on_action' && kind !== 'on_schedule'
200
+ }),
201
+ armed: true,
202
+ }
203
+ }
204
+
50
205
  /**
51
206
  * Register a task module to be dispatched on matching events
52
207
  * and/or on a cron schedule.
@@ -112,7 +267,7 @@ export class TaskService {
112
267
  return runWithEventContext({ ip: resolveClientIp(context.req) }, () =>
113
268
  TaskRunService.execute({
114
269
  taskId: id,
115
- handler: task.handler,
270
+ handler: TaskService._handlerWithInputs(task.metadata, task.handler),
116
271
  context: invokeContext,
117
272
  trigger: 'invoke',
118
273
  triggeredBy: {
@@ -146,8 +301,9 @@ export class TaskService {
146
301
  * Dispatches a single event (fullDocument from the changestream) to all
147
302
  * registered tasks whose triggers match.
148
303
  * @param {object} event - The eventstore fullDocument.
304
+ * @returns {Promise<void>}
149
305
  */
150
- static dispatch(event, { sdk } = {}) {
306
+ static async dispatch(event, { sdk } = {}) {
151
307
  if (!event) return
152
308
 
153
309
  const baseSdk = sdk ?? TaskService._sdk
@@ -155,11 +311,14 @@ export class TaskService {
155
311
  ? baseSdk.withEventContext(event)
156
312
  : baseSdk
157
313
 
314
+ const workspaceId = event.payload?.belongsTo ?? event.belongsTo ?? null
315
+
158
316
  for (const { metadata, handler } of TaskService._tasks) {
159
- const triggers = metadata.triggers ?? []
160
- const matched = triggers.some(trigger => TaskService._matchesEventTrigger(trigger, event))
317
+ const { triggers, armed } = await TaskService._eventTriggersForDispatch(metadata, workspaceId)
318
+ if (!armed) continue
161
319
 
162
- if (!matched) continue
320
+ const matchedTrigger = triggers.find((trigger) => TaskService._matchesEventTrigger(trigger, event))
321
+ if (!matchedTrigger) continue
163
322
 
164
323
  _serviceLog.info(`Dispatching task "${metadata.id}"`)
165
324
 
@@ -167,14 +326,15 @@ export class TaskService {
167
326
  { sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) },
168
327
  {
169
328
  event,
170
- payload: { workspaceId: event.payload?.belongsTo },
329
+ payload: { workspaceId },
330
+ triggerConfig: matchedTrigger.config,
171
331
  },
172
332
  )
173
333
 
174
334
  const _taskId = metadata.id
175
335
  TaskRunService.executeAsync({
176
336
  taskId: _taskId,
177
- handler,
337
+ handler: TaskService._handlerWithInputs(metadata, handler),
178
338
  context: taskContext,
179
339
  trigger: 'on_event',
180
340
  triggeredBy: {
@@ -202,8 +362,9 @@ export class TaskService {
202
362
  * req?: import('express').Request,
203
363
  * sdk?: unknown,
204
364
  * }} actionContext
365
+ * @returns {Promise<void>}
205
366
  */
206
- static dispatchOnAction (actionContext) {
367
+ static async dispatchOnAction (actionContext) {
207
368
  const {
208
369
  actionId,
209
370
  actionInvocationId,
@@ -215,13 +376,18 @@ export class TaskService {
215
376
  sdk,
216
377
  } = actionContext
217
378
 
379
+ const workspaceId = req?.workspaceId
380
+ ?? payload?.workspaceId
381
+ ?? payload?.belongsTo
382
+ ?? null
383
+
218
384
  for (const { metadata, handler } of TaskService._tasks) {
219
385
  if (isPrimaryTaskForAction(actionId, metadata.id)) continue
220
386
 
221
- const triggers = metadata.triggers ?? []
222
- const matched = triggers.some(trigger => TaskService._matchesActionTrigger(trigger, actionId))
387
+ const triggers = Array.isArray(metadata?.triggers) ? metadata.triggers : []
388
+ const matchedTrigger = triggers.find((trigger) => TaskService._matchesActionTrigger(trigger, actionId))
223
389
 
224
- if (!matched) continue
390
+ if (!matchedTrigger) continue
225
391
 
226
392
  _serviceLog.info(`Dispatching on_action task "${metadata.id}" for action "${actionId}"`)
227
393
 
@@ -233,6 +399,7 @@ export class TaskService {
233
399
  req,
234
400
  actionInvocationId,
235
401
  payload,
402
+ triggerConfig: matchedTrigger.config,
236
403
  },
237
404
  {
238
405
  action: {
@@ -247,7 +414,7 @@ export class TaskService {
247
414
  const _taskId = metadata.id
248
415
  TaskRunService.executeAsync({
249
416
  taskId: _taskId,
250
- handler,
417
+ handler: TaskService._handlerWithInputs(metadata, handler),
251
418
  context: taskContext,
252
419
  trigger: 'on_action',
253
420
  triggeredBy: {
@@ -261,16 +428,61 @@ export class TaskService {
261
428
  }
262
429
  }
263
430
 
431
+ /**
432
+ * Collect cron jobs for a task.
433
+ * System tasks: definition schedule only.
434
+ * Configurable tasks: instance on_schedule triggers only (inactive when none).
435
+ *
436
+ * @param {object} metadata
437
+ * @returns {Promise<Array<{ workspaceId: string | null, schedule: string }>>}
438
+ */
439
+ static async _scheduledJobsFor (metadata) {
440
+ /** @type {Array<{ workspaceId: string | null, schedule: string }>} */
441
+ const jobs = []
442
+
443
+ if (TaskService._isConfigurable(metadata)) {
444
+ if (TaskService._instanceScheduleResolver && metadata?.id) {
445
+ try {
446
+ const fromInstances = await TaskService._instanceScheduleResolver({ taskId: metadata.id })
447
+ for (const job of fromInstances || []) {
448
+ if (!job?.workspaceId || typeof job.schedule !== 'string' || !job.schedule.trim()) continue
449
+ jobs.push({
450
+ workspaceId: job.workspaceId,
451
+ schedule: job.schedule.trim(),
452
+ ...(job.config ? { config: job.config } : {}),
453
+ })
454
+ }
455
+ } catch (err) {
456
+ _serviceLog.warn(
457
+ `Instance schedule resolver failed for ${metadata.id}: ${err?.message || err}`,
458
+ )
459
+ }
460
+ }
461
+ return jobs
462
+ }
463
+
464
+ if (typeof metadata?.schedule === 'string' && metadata.schedule.trim()) {
465
+ jobs.push({ workspaceId: null, schedule: metadata.schedule.trim() })
466
+ }
467
+ for (const trigger of metadata?.triggers ?? []) {
468
+ const kind = trigger?.trigger ?? trigger?.on ?? trigger?.kind
469
+ const schedule = typeof trigger?.schedule === 'string' ? trigger.schedule.trim() : ''
470
+ if ((kind === 'on_schedule' || (!kind && schedule)) && schedule) {
471
+ jobs.push({ workspaceId: null, schedule })
472
+ }
473
+ }
474
+ return jobs
475
+ }
476
+
264
477
  /**
265
478
  * Starts a 60-second interval that fires each scheduled task whose cron
266
479
  * expression matches the current minute. Calls the handler directly
267
480
  * (no synthetic MongoDB events).
268
481
  */
269
482
  static startScheduler() {
270
- const scheduledTasks = TaskService._tasks.filter(t => !!t.metadata.schedule)
271
- if (scheduledTasks.length === 0) return
483
+ if (TaskService._tasks.length === 0) return
272
484
 
273
- _serviceLog.info(`Scheduler started for ${scheduledTasks.length} task(s)`)
485
+ _serviceLog.info(`Scheduler started for ${TaskService._tasks.length} registered task(s)`)
274
486
 
275
487
  TaskService._stopped = false
276
488
  TaskService._schedulerInterval = setInterval(() => {
@@ -278,27 +490,44 @@ export class TaskService {
278
490
 
279
491
  const now = new Date()
280
492
 
281
- for (const { metadata, handler } of scheduledTasks) {
282
- if (!matchesCron(metadata.schedule, now)) continue
283
-
284
- _serviceLog.info(`Schedule fired for task "${metadata.id}"`)
285
-
286
- const taskContext = {
287
- event: { type: 'scheduled', taskId: metadata.id },
288
- sdk: TaskService._sdk,
289
- integrations: IntegrationService,
290
- log: createLogger(metadata.id),
291
- }
292
-
293
- const _taskId = metadata.id
294
- TaskRunService.executeAsync({
295
- taskId: _taskId,
296
- handler,
297
- context: taskContext,
298
- trigger: 'on_schedule',
299
- triggeredBy: { schedule: metadata.schedule },
300
- executionEnv: 'ossy_server',
301
- audit: metadata.audit !== false,
493
+ for (const { metadata, handler } of TaskService._tasks) {
494
+ TaskService._scheduledJobsFor(metadata).then((jobs) => {
495
+ if (TaskService._stopped) return
496
+ for (const job of jobs) {
497
+ if (!matchesCron(job.schedule, now)) continue
498
+
499
+ _serviceLog.info(
500
+ `Schedule fired for task "${metadata.id}"`
501
+ + (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
502
+ )
503
+
504
+ const taskContext = {
505
+ event: { type: 'scheduled', taskId: metadata.id },
506
+ sdk: TaskService._sdk,
507
+ integrations: IntegrationService,
508
+ log: createLogger(metadata.id),
509
+ payload: job.workspaceId ? { workspaceId: job.workspaceId } : undefined,
510
+ req: job.workspaceId ? { workspaceId: job.workspaceId, userId: 'system' } : undefined,
511
+ triggerConfig: job.config,
512
+ }
513
+
514
+ TaskRunService.executeAsync({
515
+ taskId: metadata.id,
516
+ handler: TaskService._handlerWithInputs(metadata, handler),
517
+ context: taskContext,
518
+ trigger: 'on_schedule',
519
+ triggeredBy: {
520
+ schedule: job.schedule,
521
+ ...(job.workspaceId ? { workspaceId: job.workspaceId } : {}),
522
+ },
523
+ executionEnv: 'ossy_server',
524
+ audit: metadata.audit !== false,
525
+ })
526
+ }
527
+ }).catch((err) => {
528
+ _serviceLog.warn(
529
+ `Scheduler tick failed for ${metadata.id}: ${err?.message || err}`,
530
+ )
302
531
  })
303
532
  }
304
533
  }, SCHEDULER_INTERVAL_MS)
@@ -322,7 +551,7 @@ export class TaskService {
322
551
  * @param {string} actionId
323
552
  */
324
553
  static _matchesActionTrigger (trigger, actionId) {
325
- const kind = trigger.trigger ?? trigger.on
554
+ const kind = trigger.trigger ?? trigger.on ?? trigger.kind
326
555
  if (kind !== 'on_action') return false
327
556
 
328
557
  const pattern = trigger.action
@@ -335,11 +564,11 @@ export class TaskService {
335
564
  * Returns true when an event trigger descriptor matches the given eventstore document.
336
565
  *
337
566
  * ADR 0008: `trigger.type` = schemaId, `trigger.event` = lifecycle/custom name.
338
- * Skips `on_action` triggers (handled by {@link TaskService.dispatchOnAction}).
567
+ * Skips `on_action` / `on_schedule` triggers (handled elsewhere).
339
568
  */
340
569
  static _matchesEventTrigger (trigger, event) {
341
- const kind = trigger.trigger ?? trigger.on
342
- if (kind === 'on_action') return false
570
+ const kind = trigger.trigger ?? trigger.on ?? trigger.kind
571
+ if (kind === 'on_action' || kind === 'on_schedule') return false
343
572
 
344
573
  if (trigger.excludeTypes?.includes(event.type)) return false
345
574