@ossy/platform 3.15.0 → 3.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,15 +48,15 @@
48
48
  "@ossy/config": "^3.0.9",
49
49
  "@ossy/event-store": "^3.12.0",
50
50
  "@ossy/locale": "^3.4.0",
51
- "@ossy/manifest": "^3.15.0",
51
+ "@ossy/manifest": "^3.16.0",
52
52
  "@ossy/observability": "^3.0.9",
53
53
  "@ossy/policies": "^3.0.9",
54
54
  "@ossy/router": "^3.0.9",
55
- "@ossy/schema": "^3.15.0",
56
- "@ossy/sdk": "^3.12.0",
55
+ "@ossy/schema": "^3.16.0",
56
+ "@ossy/sdk": "^3.16.0",
57
57
  "@ossy/tokens": "^3.11.1",
58
- "@ossy/users": "^3.15.0",
59
- "@ossy/workspaces": "^3.15.0",
58
+ "@ossy/users": "^3.16.0",
59
+ "@ossy/workspaces": "^3.16.0",
60
60
  "cookie-parser": "^1.4.7",
61
61
  "dotenv": ">=16.0.0 <18.0.0",
62
62
  "express": ">=5.0.0 <6.0.0",
@@ -77,5 +77,5 @@
77
77
  "Dockerfile",
78
78
  "docker-healthcheck.js"
79
79
  ],
80
- "gitHead": "23517232ddc4f2c64ca0062faa3cad84ceaa34a7"
80
+ "gitHead": "1872fb1467bf656284aae35881c34960c3860807"
81
81
  }
@@ -19,14 +19,42 @@ export function hashPayload (payload) {
19
19
  /** Keep event payloads small — full task results stay outside the audit stream. */
20
20
  export function summarizeResult (result) {
21
21
  if (result == null) return null
22
- if (typeof result !== 'object') {
23
- return { kind: typeof result, preview: String(result).slice(0, 200) }
22
+ return previewValue(result, 0)
23
+ }
24
+
25
+ /**
26
+ * Resolved task inputs for the run detail. Empty maps are omitted.
27
+ *
28
+ * @param {unknown} inputs
29
+ * @returns {object | undefined}
30
+ */
31
+ export function summarizeInputs (inputs) {
32
+ if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined
33
+ if (!Object.keys(inputs).length) return undefined
34
+ const preview = previewValue(inputs, 0)
35
+ if (!preview || typeof preview !== 'object' || Array.isArray(preview)) return undefined
36
+ if (!Object.keys(preview).length) return undefined
37
+ return preview
38
+ }
39
+
40
+ function previewValue (value, depth) {
41
+ if (value == null || typeof value !== 'object') {
42
+ if (typeof value === 'string' && value.length > 240) return `${value.slice(0, 240)}…`
43
+ return value
44
+ }
45
+ if (depth >= 4) {
46
+ return Array.isArray(value)
47
+ ? { kind: 'array', length: value.length }
48
+ : { kind: 'object', keyCount: Object.keys(value).length }
49
+ }
50
+ if (Array.isArray(value)) {
51
+ return value.slice(0, 12).map((item) => previewValue(item, depth + 1))
24
52
  }
25
- if (Array.isArray(result)) {
26
- return { kind: 'array', length: result.length }
53
+ const preview = {}
54
+ for (const key of Object.keys(value).slice(0, 24)) {
55
+ preview[key] = previewValue(value[key], depth + 1)
27
56
  }
28
- const keys = Object.keys(result)
29
- return { kind: 'object', keys: keys.slice(0, 20), keyCount: keys.length }
57
+ return preview
30
58
  }
31
59
 
32
60
  /** @param {Error | { message?: string, code?: string }} error */
@@ -42,6 +42,7 @@ export class TaskRunListProjection {
42
42
  if (event.event === 'Started') {
43
43
  entry.status = 'in progress'
44
44
  entry.startedAt = event.created
45
+ entry.triggeredBy = event.payload.triggeredBy ?? null
45
46
  runs.unshift(entry)
46
47
  return { workspaceId, runs: runs.slice(0, 200) }
47
48
  }
@@ -54,6 +55,7 @@ export class TaskRunListProjection {
54
55
  startedAt: event.created,
55
56
  completedAt: event.created,
56
57
  durationMs: event.payload.durationMs,
58
+ ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
57
59
  })
58
60
  return { workspaceId, runs: runs.slice(0, 200) }
59
61
  }
@@ -66,6 +68,7 @@ export class TaskRunListProjection {
66
68
  durationMs: event.payload.durationMs,
67
69
  error: event.event === 'Failed' ? event.payload.error : undefined,
68
70
  resultSummary: event.event === 'Completed' ? event.payload.resultSummary : undefined,
71
+ ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
69
72
  }
70
73
 
71
74
  return { workspaceId, runs }
@@ -9,6 +9,7 @@ import {
9
9
  resolveActorId,
10
10
  resolveWorkspaceId,
11
11
  summarizeError,
12
+ summarizeInputs,
12
13
  summarizeResult,
13
14
  } from './audit-helpers.js'
14
15
  import { rejectOnAbort } from '../request-diagnostics.js'
@@ -105,6 +106,7 @@ export const TaskRunService = {
105
106
  actorId,
106
107
  durationMs,
107
108
  resultSummary: summarizeResult(result),
109
+ inputs: summarizeInputs(context.inputs),
108
110
  })
109
111
  recordTaskMetrics(taskId, durationMs)
110
112
  await MeteringService.record({
@@ -130,6 +132,7 @@ export const TaskRunService = {
130
132
  actorId,
131
133
  durationMs,
132
134
  error: summarizeError(error),
135
+ inputs: summarizeInputs(context.inputs),
133
136
  })
134
137
  recordTaskMetrics(taskId, durationMs)
135
138
  await MeteringService.record({
@@ -193,7 +196,7 @@ export const TaskRunService = {
193
196
  }
194
197
  },
195
198
 
196
- async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary }) {
199
+ async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs }) {
197
200
  try {
198
201
  await Aggregate.Of(TaskRun, runId)
199
202
  .then(Aggregate.Add({
@@ -202,6 +205,7 @@ export const TaskRunService = {
202
205
  payload: {
203
206
  durationMs,
204
207
  resultSummary,
208
+ ...(inputs ? { inputs } : {}),
205
209
  ...taskRunScopePayload({ taskId, workspaceId }),
206
210
  },
207
211
  }))
@@ -210,7 +214,7 @@ export const TaskRunService = {
210
214
  }
211
215
  },
212
216
 
213
- async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error }) {
217
+ async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs }) {
214
218
  try {
215
219
  await Aggregate.Of(TaskRun, runId)
216
220
  .then(Aggregate.Add({
@@ -219,6 +223,7 @@ export const TaskRunService = {
219
223
  payload: {
220
224
  durationMs,
221
225
  error,
226
+ ...(inputs ? { inputs } : {}),
222
227
  ...taskRunScopePayload({ taskId, workspaceId }),
223
228
  },
224
229
  }))
@@ -36,6 +36,7 @@ export class TaskRun {
36
36
  completedAt: event.created,
37
37
  durationMs: event.payload.durationMs,
38
38
  resultSummary: event.payload.resultSummary ?? null,
39
+ ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
39
40
  }
40
41
 
41
42
  case 'Failed':
@@ -45,6 +46,7 @@ export class TaskRun {
45
46
  completedAt: event.created,
46
47
  durationMs: event.payload.durationMs,
47
48
  error: event.payload.error ?? null,
49
+ ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
48
50
  }
49
51
 
50
52
  default:
@@ -7,9 +7,5 @@ export default {
7
7
  { name: 'Key', type: 'text' },
8
8
  { name: 'ContentLength', type: 'number' },
9
9
  { name: 'ContentType', type: 'text' },
10
- // Populated async by @ossy/media-tasks/tasks/resize-common-web
11
- { name: 'blurhash', type: 'text' },
12
- { name: 'width', type: 'number' },
13
- { name: 'height', type: 'number' },
14
10
  ],
15
11
  }
@@ -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
 
@@ -63,6 +63,73 @@ describe('TaskService triggers', () => {
63
63
  )).toBe(true)
64
64
  })
65
65
 
66
+ it('AND-narrows events with instance watch locations and MIME', () => {
67
+ const event = {
68
+ type: '@ossy/platform/schema/file',
69
+ event: 'Created',
70
+ resourceId: 'f1',
71
+ payload: {
72
+ belongsTo: 'ws1',
73
+ location: '/incoming/photos/a.jpg',
74
+ content: { ContentType: 'image/jpeg' },
75
+ },
76
+ }
77
+
78
+ expect(TaskService._matchesEventTrigger({
79
+ kind: 'on_event',
80
+ type: '@ossy/platform/schema/file',
81
+ event: 'Created',
82
+ location: { startsWith: '/incoming/' },
83
+ match: { 'content.ContentType': 'image/*' },
84
+ }, event)).toBe(true)
85
+
86
+ expect(TaskService._matchesEventTrigger({
87
+ kind: 'on_event',
88
+ type: '@ossy/platform/schema/file',
89
+ event: 'Created',
90
+ location: { startsWith: '/other/' },
91
+ }, event)).toBe(false)
92
+
93
+ expect(TaskService._matchesEventTrigger({
94
+ kind: 'on_event',
95
+ type: '@ossy/platform/schema/file',
96
+ event: 'Created',
97
+ match: { 'content.ContentType': 'video/*' },
98
+ }, event)).toBe(false)
99
+ })
100
+
101
+ it('treats configurable tasks without instance triggers as unarmed', async () => {
102
+ TaskService.setInstanceTriggersResolver(async () => [])
103
+ const result = await TaskService._eventTriggersForDispatch(
104
+ { id: 't1', configurable: true, triggers: [{ type: 'x', event: 'Created' }] },
105
+ 'ws1',
106
+ )
107
+ expect(result.armed).toBe(false)
108
+ expect(result.triggers).toEqual([])
109
+ TaskService.setInstanceTriggersResolver(null)
110
+ })
111
+
112
+ it('uses instance triggers for configurable tasks', async () => {
113
+ const instanceTriggers = [{
114
+ kind: 'on_event',
115
+ type: '@ossy/platform/schema/file',
116
+ event: 'Created',
117
+ location: { startsWith: '/incoming/' },
118
+ }]
119
+ TaskService.setInstanceTriggersResolver(async () => instanceTriggers)
120
+ const result = await TaskService._eventTriggersForDispatch(
121
+ {
122
+ id: 't1',
123
+ configurable: true,
124
+ triggers: [{ type: '@ossy/platform/schema/file', event: 'Created' }],
125
+ },
126
+ 'ws1',
127
+ )
128
+ expect(result.armed).toBe(true)
129
+ expect(result.triggers).toEqual(instanceTriggers)
130
+ TaskService.setInstanceTriggersResolver(null)
131
+ })
132
+
66
133
  it('skips primary derived task in on_action dispatch', () => {
67
134
  expect(isPrimaryTaskForAction('@ossy/booking/actions/create', '@ossy/booking/tasks/create')).toBe(true)
68
135
  expect(isPrimaryTaskForAction('@ossy/booking/actions/create', '@ossy/booking/tasks/send-reminder')).toBe(false)
@@ -120,6 +187,11 @@ describe('TaskRunList projection', () => {
120
187
  moduleId: 'media-tasks',
121
188
  trigger: 'on_event',
122
189
  belongsTo: 'ws1',
190
+ triggeredBy: {
191
+ resourceId: 'file1',
192
+ type: '@ossy/platform/schema/file',
193
+ event: 'Created',
194
+ },
123
195
  },
124
196
  }
125
197
 
@@ -127,6 +199,11 @@ describe('TaskRunList projection', () => {
127
199
  state = TaskRunListProjection.Apply(started, state)
128
200
  expect(state.runs).toHaveLength(1)
129
201
  expect(state.runs[0].status).toBe('in progress')
202
+ expect(state.runs[0].triggeredBy).toEqual({
203
+ resourceId: 'file1',
204
+ type: '@ossy/platform/schema/file',
205
+ event: 'Created',
206
+ })
130
207
 
131
208
  state = TaskRunListProjection.Apply({
132
209
  resourceId: 'run1',
@@ -145,6 +222,37 @@ describe('TaskRunList projection', () => {
145
222
  expect(state.runs[0].durationMs).toBe(1000)
146
223
  })
147
224
 
225
+ it('keeps resolved inputs on the listed run', () => {
226
+ let state = TaskRunListProjection.initialState('ws1')
227
+ state = TaskRunListProjection.Apply({
228
+ resourceId: 'run-inputs',
229
+ type: '@ossy/platform/schema/task-run',
230
+ event: 'Started',
231
+ created: 1000,
232
+ payload: {
233
+ taskId: '@ossy/media-tasks/tasks/visual-content-descriptors',
234
+ moduleId: 'media-tasks',
235
+ trigger: 'on_event',
236
+ belongsTo: 'ws1',
237
+ },
238
+ }, state)
239
+ state = TaskRunListProjection.Apply({
240
+ resourceId: 'run-inputs',
241
+ event: 'Completed',
242
+ created: 2000,
243
+ payload: {
244
+ durationMs: 40,
245
+ resultSummary: { title: 'A cat' },
246
+ inputs: { model: 'google/gemini-2.5-flash-lite' },
247
+ belongsTo: 'ws1',
248
+ taskId: '@ossy/media-tasks/tasks/visual-content-descriptors',
249
+ moduleId: 'media-tasks',
250
+ },
251
+ }, state)
252
+
253
+ expect(state.runs[0].inputs).toEqual({ model: 'google/gemini-2.5-flash-lite' })
254
+ })
255
+
148
256
  it('scopes Completed/Failed from payload.belongsTo (changestream dispatch)', () => {
149
257
  expect(TaskRunListProjection.scopeFromEvent({
150
258
  payload: { durationMs: 10 },