@ossy/platform 3.18.0 → 3.20.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 +10 -9
- package/src/audit/task-run-list.aggregate.js +2 -0
- package/src/audit/task-run-service.js +6 -2
- package/src/audit/task-run.aggregate.js +3 -1
- package/src/mcp/mount-ossy-mcp.js +16 -11
- package/src/mcp/mount-ossy-mcp.spec.js +81 -0
- package/src/server.js +18 -0
- package/src/storage/s3-storage.client.js +14 -0
- package/src/storage/s3-storage.client.spec.js +72 -0
- package/src/tasks/cron.js +6 -1
- package/src/tasks/task-service.js +7 -3
- package/src/tasks/task-service.spec.js +30 -0
- package/src/vector/vector-store.js +176 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"./verify-sign-in-session": "./src/verify-sign-in-session.js",
|
|
31
31
|
"./storage-keys": "./src/storage/storage-keys.js",
|
|
32
32
|
"./mcp": "./src/mcp/mount-platform-mcp.js",
|
|
33
|
-
"./metering": "./src/metering/index.js"
|
|
33
|
+
"./metering": "./src/metering/index.js",
|
|
34
|
+
"./vector/vector-store": "./src/vector/vector-store.js"
|
|
34
35
|
},
|
|
35
36
|
"scripts": {
|
|
36
37
|
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\"",
|
|
@@ -47,17 +48,17 @@
|
|
|
47
48
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
48
49
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
49
50
|
"@ossy/config": "^3.0.9",
|
|
50
|
-
"@ossy/event-store": "^3.
|
|
51
|
+
"@ossy/event-store": "^3.20.0",
|
|
51
52
|
"@ossy/locale": "^3.4.0",
|
|
52
|
-
"@ossy/manifest": "^3.
|
|
53
|
+
"@ossy/manifest": "^3.20.0",
|
|
53
54
|
"@ossy/observability": "^3.0.9",
|
|
54
55
|
"@ossy/policies": "^3.0.9",
|
|
55
56
|
"@ossy/router": "^3.0.9",
|
|
56
|
-
"@ossy/schema": "^3.
|
|
57
|
-
"@ossy/sdk": "^3.
|
|
57
|
+
"@ossy/schema": "^3.20.0",
|
|
58
|
+
"@ossy/sdk": "^3.20.0",
|
|
58
59
|
"@ossy/tokens": "^3.11.1",
|
|
59
|
-
"@ossy/users": "^3.
|
|
60
|
-
"@ossy/workspaces": "^3.
|
|
60
|
+
"@ossy/users": "^3.20.0",
|
|
61
|
+
"@ossy/workspaces": "^3.20.0",
|
|
61
62
|
"cookie-parser": "^1.4.7",
|
|
62
63
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
63
64
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -78,5 +79,5 @@
|
|
|
78
79
|
"Dockerfile",
|
|
79
80
|
"docker-healthcheck.js"
|
|
80
81
|
],
|
|
81
|
-
"gitHead": "
|
|
82
|
+
"gitHead": "edab6a966f41c07a68bdd86f3864cc2ad02e3406"
|
|
82
83
|
}
|
|
@@ -55,6 +55,7 @@ export class TaskRunListProjection {
|
|
|
55
55
|
startedAt: event.created,
|
|
56
56
|
completedAt: event.created,
|
|
57
57
|
durationMs: event.payload.durationMs,
|
|
58
|
+
triggeredBy: event.payload.triggeredBy ?? null,
|
|
58
59
|
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
59
60
|
})
|
|
60
61
|
return { workspaceId, runs: runs.slice(0, 200) }
|
|
@@ -68,6 +69,7 @@ export class TaskRunListProjection {
|
|
|
68
69
|
durationMs: event.payload.durationMs,
|
|
69
70
|
error: event.event === 'Failed' ? event.payload.error : undefined,
|
|
70
71
|
resultSummary: event.event === 'Completed' ? event.payload.resultSummary : undefined,
|
|
72
|
+
triggeredBy: runs[idx].triggeredBy ?? event.payload.triggeredBy ?? null,
|
|
71
73
|
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -108,6 +108,7 @@ export const TaskRunService = {
|
|
|
108
108
|
durationMs,
|
|
109
109
|
resultSummary: summarizeResult(result),
|
|
110
110
|
inputs: summarizeInputs(context.inputs),
|
|
111
|
+
triggeredBy,
|
|
111
112
|
})
|
|
112
113
|
recordTaskMetrics(taskId, durationMs)
|
|
113
114
|
const usage = extractUsage(result)
|
|
@@ -136,6 +137,7 @@ export const TaskRunService = {
|
|
|
136
137
|
durationMs,
|
|
137
138
|
error: summarizeError(error),
|
|
138
139
|
inputs: summarizeInputs(context.inputs),
|
|
140
|
+
triggeredBy,
|
|
139
141
|
})
|
|
140
142
|
recordTaskMetrics(taskId, durationMs)
|
|
141
143
|
await MeteringService.record({
|
|
@@ -199,7 +201,7 @@ export const TaskRunService = {
|
|
|
199
201
|
}
|
|
200
202
|
},
|
|
201
203
|
|
|
202
|
-
async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs }) {
|
|
204
|
+
async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs, triggeredBy }) {
|
|
203
205
|
try {
|
|
204
206
|
await Aggregate.Of(TaskRun, runId)
|
|
205
207
|
.then(Aggregate.Add({
|
|
@@ -209,6 +211,7 @@ export const TaskRunService = {
|
|
|
209
211
|
durationMs,
|
|
210
212
|
resultSummary,
|
|
211
213
|
...(inputs ? { inputs } : {}),
|
|
214
|
+
...(triggeredBy ? { triggeredBy } : {}),
|
|
212
215
|
...taskRunScopePayload({ taskId, workspaceId }),
|
|
213
216
|
},
|
|
214
217
|
}))
|
|
@@ -217,7 +220,7 @@ export const TaskRunService = {
|
|
|
217
220
|
}
|
|
218
221
|
},
|
|
219
222
|
|
|
220
|
-
async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs }) {
|
|
223
|
+
async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs, triggeredBy }) {
|
|
221
224
|
try {
|
|
222
225
|
await Aggregate.Of(TaskRun, runId)
|
|
223
226
|
.then(Aggregate.Add({
|
|
@@ -227,6 +230,7 @@ export const TaskRunService = {
|
|
|
227
230
|
durationMs,
|
|
228
231
|
error,
|
|
229
232
|
...(inputs ? { inputs } : {}),
|
|
233
|
+
...(triggeredBy ? { triggeredBy } : {}),
|
|
230
234
|
...taskRunScopePayload({ taskId, workspaceId }),
|
|
231
235
|
},
|
|
232
236
|
}))
|
|
@@ -20,7 +20,7 @@ export class TaskRun {
|
|
|
20
20
|
moduleId: event.payload.moduleId,
|
|
21
21
|
status: 'in progress',
|
|
22
22
|
trigger: event.payload.trigger,
|
|
23
|
-
triggeredBy: event.payload.triggeredBy ??
|
|
23
|
+
triggeredBy: event.payload.triggeredBy ?? null,
|
|
24
24
|
actionInvocationId: event.payload.actionInvocationId ?? null,
|
|
25
25
|
actorId: event.payload.actorId ?? event.createdBy ?? null,
|
|
26
26
|
workspaceId: event.payload.belongsTo ?? null,
|
|
@@ -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
|
+
triggeredBy: run.triggeredBy ?? event.payload.triggeredBy ?? null,
|
|
39
40
|
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -46,6 +47,7 @@ export class TaskRun {
|
|
|
46
47
|
completedAt: event.created,
|
|
47
48
|
durationMs: event.payload.durationMs,
|
|
48
49
|
error: event.payload.error ?? null,
|
|
50
|
+
triggeredBy: run.triggeredBy ?? event.payload.triggeredBy ?? null,
|
|
49
51
|
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -54,19 +54,24 @@ export function mountOssyMcp ({ app, capabilities, invokeAction, requireAuth, cu
|
|
|
54
54
|
|
|
55
55
|
app.post(path, handleMcpPost)
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// GET /mcp — Streamable HTTP clients (e.g. Cursor) open this for the
|
|
58
|
+
// server→client SSE channel after initialize. We don't send server-initiated
|
|
59
|
+
// messages, so keep the stream idle until the client closes it.
|
|
60
|
+
// Returning 405 here blocks tool discovery entirely.
|
|
61
|
+
app.get(path, (req, res) => {
|
|
62
|
+
if (requireAuth && !requireAuth(req, res)) return
|
|
63
|
+
res.writeHead(200, {
|
|
64
|
+
'Content-Type': 'text/event-stream',
|
|
65
|
+
'Cache-Control': 'no-cache',
|
|
66
|
+
Connection: 'keep-alive',
|
|
62
67
|
})
|
|
68
|
+
res.write('\n')
|
|
69
|
+
req.on('close', () => res.end())
|
|
63
70
|
})
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
id: null,
|
|
70
|
-
})
|
|
72
|
+
// DELETE /mcp — session teardown; stateless server has nothing to clean up.
|
|
73
|
+
app.delete(path, (req, res) => {
|
|
74
|
+
if (requireAuth && !requireAuth(req, res)) return
|
|
75
|
+
res.status(200).end()
|
|
71
76
|
})
|
|
72
77
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from '@jest/globals'
|
|
2
|
+
import express from 'express'
|
|
3
|
+
import { mountOssyMcp } from './mount-ossy-mcp.js'
|
|
4
|
+
|
|
5
|
+
function mountTestApp () {
|
|
6
|
+
const app = express()
|
|
7
|
+
app.use(express.json({ type: ['application/json', 'application/*+json'] }))
|
|
8
|
+
mountOssyMcp({
|
|
9
|
+
app,
|
|
10
|
+
capabilities: { tools: [], tasks: [], graph: { edges: [] } },
|
|
11
|
+
invokeAction: async () => ({ ok: true }),
|
|
12
|
+
})
|
|
13
|
+
return app
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function listen (app) {
|
|
17
|
+
const server = await new Promise((resolve) => {
|
|
18
|
+
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
|
19
|
+
})
|
|
20
|
+
return {
|
|
21
|
+
server,
|
|
22
|
+
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('mountOssyMcp', () => {
|
|
27
|
+
let server
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
if (server) {
|
|
31
|
+
await new Promise((resolve) => server.close(resolve))
|
|
32
|
+
server = undefined
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('GET /mcp returns 200 text/event-stream (not 405)', async () => {
|
|
37
|
+
;({ server, baseUrl } = await listen(mountTestApp()))
|
|
38
|
+
|
|
39
|
+
const controller = new AbortController()
|
|
40
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
41
|
+
method: 'GET',
|
|
42
|
+
headers: { accept: 'text/event-stream' },
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(res.status).toBe(200)
|
|
47
|
+
expect(res.headers.get('content-type')).toMatch(/text\/event-stream/)
|
|
48
|
+
controller.abort()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('DELETE /mcp returns 200', async () => {
|
|
52
|
+
;({ server, baseUrl } = await listen(mountTestApp()))
|
|
53
|
+
|
|
54
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'DELETE' })
|
|
55
|
+
expect(res.status).toBe(200)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('POST /mcp initialize returns 200', async () => {
|
|
59
|
+
;({ server, baseUrl } = await listen(mountTestApp()))
|
|
60
|
+
|
|
61
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'content-type': 'application/json',
|
|
65
|
+
accept: 'application/json, text/event-stream',
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
jsonrpc: '2.0',
|
|
69
|
+
id: 1,
|
|
70
|
+
method: 'initialize',
|
|
71
|
+
params: {
|
|
72
|
+
protocolVersion: '2025-03-26',
|
|
73
|
+
capabilities: {},
|
|
74
|
+
clientInfo: { name: 'test', version: '0.0.0' },
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(200)
|
|
80
|
+
})
|
|
81
|
+
})
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import cookieParser from 'cookie-parser'
|
|
|
8
8
|
import { ProxyInternal } from './proxy-internal.js'
|
|
9
9
|
import { SDK, resolveActionId } from '@ossy/sdk'
|
|
10
10
|
import { AggregateRebuild, ensureEventStoreIndexes, Mongo, resolveMongoUrl } from '@ossy/event-store'
|
|
11
|
+
import { ensureEmbeddingIndexes } from './vector/vector-store.js'
|
|
11
12
|
import { TaskService } from './tasks/task-service.js'
|
|
12
13
|
import { ChangeStream } from './tasks/change-stream.js'
|
|
13
14
|
import { buildManifestSummary } from '@ossy/manifest/build-manifest-summary'
|
|
@@ -300,6 +301,9 @@ export async function startServer (options = {}) {
|
|
|
300
301
|
await ensureEventStoreIndexes().catch((err) => {
|
|
301
302
|
log.error('Failed to ensure MongoDB indexes — aggregate reads may be very slow', undefined, err)
|
|
302
303
|
})
|
|
304
|
+
ensureEmbeddingIndexes().catch((err) => {
|
|
305
|
+
log.error('Failed to ensure vector embedding indexes', undefined, err)
|
|
306
|
+
})
|
|
303
307
|
ChangeStream.start(resolveMongoUrl(process.env.DB_URL))
|
|
304
308
|
}
|
|
305
309
|
|
|
@@ -384,6 +388,20 @@ export async function startServer (options = {}) {
|
|
|
384
388
|
|
|
385
389
|
const payload = req.body?.payload ?? {}
|
|
386
390
|
|
|
391
|
+
// Fall back to reading the workspace from the session cookie when the
|
|
392
|
+
// client did not send the workspaceId header (e.g. SDK workspaceId was
|
|
393
|
+
// not initialised from SSR props).
|
|
394
|
+
if (!req.workspaceId) {
|
|
395
|
+
const cookieWorkspaceId = readWorkspaceIdFromCookies(req)
|
|
396
|
+
if (cookieWorkspaceId) req.workspaceId = cookieWorkspaceId
|
|
397
|
+
}
|
|
398
|
+
// Last resort: accept workspaceId from the request payload itself.
|
|
399
|
+
// This is no less secure than a header — actual workspace membership is
|
|
400
|
+
// enforced inside the action handler.
|
|
401
|
+
if (!req.workspaceId && typeof payload.workspaceId === 'string' && payload.workspaceId) {
|
|
402
|
+
req.workspaceId = payload.workspaceId
|
|
403
|
+
}
|
|
404
|
+
|
|
387
405
|
const action = ActionService.get(actionId)
|
|
388
406
|
if (!action) return res.status(404).json({ error: 'Action not found' })
|
|
389
407
|
|
|
@@ -83,6 +83,20 @@ export function createS3StorageClient ({
|
|
|
83
83
|
return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: toS3Key(normalizeLogicalKey({ Key })) }))
|
|
84
84
|
},
|
|
85
85
|
|
|
86
|
+
async save (key, body, { ContentType } = {}) {
|
|
87
|
+
const logicalKey = normalizeLogicalKey(key)
|
|
88
|
+
const Key = toS3Key(logicalKey)
|
|
89
|
+
const s3Client = createAwsClient()
|
|
90
|
+
const Body = Buffer.isBuffer(body) ? body : Buffer.from(body)
|
|
91
|
+
log.info('[S3Storage][save] Putting object', { key: logicalKey, bytes: Body.length })
|
|
92
|
+
await s3Client.send(new PutObjectCommand({
|
|
93
|
+
Bucket: bucket,
|
|
94
|
+
Key,
|
|
95
|
+
Body,
|
|
96
|
+
...(ContentType ? { ContentType } : {}),
|
|
97
|
+
}))
|
|
98
|
+
},
|
|
99
|
+
|
|
86
100
|
async load (keyOrShape) {
|
|
87
101
|
const Key = toS3Key(normalizeLogicalKey(keyOrShape))
|
|
88
102
|
const s3Client = createAwsClient()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const send = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('@aws-sdk/client-s3', () => ({
|
|
6
|
+
S3: jest.fn().mockImplementation(() => ({ send })),
|
|
7
|
+
PutObjectCommand: jest.fn().mockImplementation((input) => ({ input, _type: 'PutObject' })),
|
|
8
|
+
GetObjectCommand: jest.fn().mockImplementation((input) => ({ input, _type: 'GetObject' })),
|
|
9
|
+
HeadObjectCommand: jest.fn().mockImplementation((input) => ({ input, _type: 'HeadObject' })),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.unstable_mockModule('@aws-sdk/s3-request-presigner', () => ({
|
|
13
|
+
S3RequestPresigner: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.unstable_mockModule('@aws-sdk/util-create-request', () => ({
|
|
17
|
+
createRequest: jest.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('@aws-sdk/util-format-url', () => ({
|
|
21
|
+
formatUrl: jest.fn(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const { createS3StorageClient } = await import('./s3-storage.client.js')
|
|
25
|
+
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
|
|
26
|
+
|
|
27
|
+
describe('createS3StorageClient.save', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
send.mockReset()
|
|
30
|
+
send.mockResolvedValue({})
|
|
31
|
+
PutObjectCommand.mockClear()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('exposes save and puts objects under the media/ prefix', async () => {
|
|
35
|
+
const client = createS3StorageClient({
|
|
36
|
+
bucket: 'media-bucket',
|
|
37
|
+
accessKeyId: 'ak',
|
|
38
|
+
secretAccessKey: 'sk',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(typeof client.save).toBe('function')
|
|
42
|
+
|
|
43
|
+
const body = Buffer.from('{"ok":true}')
|
|
44
|
+
await client.save('abc123:task-extract-colors-palette', body, {
|
|
45
|
+
ContentType: 'application/json',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
49
|
+
Bucket: 'media-bucket',
|
|
50
|
+
Key: 'media/abc123:task-extract-colors-palette',
|
|
51
|
+
Body: body,
|
|
52
|
+
ContentType: 'application/json',
|
|
53
|
+
})
|
|
54
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('accepts string bodies without ContentType', async () => {
|
|
58
|
+
const client = createS3StorageClient({
|
|
59
|
+
bucket: 'media-bucket',
|
|
60
|
+
accessKeyId: 'ak',
|
|
61
|
+
secretAccessKey: 'sk',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
await client.save('file-id', 'hello')
|
|
65
|
+
|
|
66
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
67
|
+
Bucket: 'media-bucket',
|
|
68
|
+
Key: 'media/file-id',
|
|
69
|
+
Body: Buffer.from('hello'),
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
package/src/tasks/cron.js
CHANGED
|
@@ -76,10 +76,15 @@ export function matchesCron(expression, date = new Date()) {
|
|
|
76
76
|
const month = date.getMonth() + 1 // 1–12
|
|
77
77
|
const dow = date.getDay() // 0–6 (0=Sunday)
|
|
78
78
|
|
|
79
|
+
// Special: L in dom field means last day of the month
|
|
80
|
+
const domMatches = domF === 'L'
|
|
81
|
+
? dom === new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
|
82
|
+
: matchField(domF, dom, 1, 31)
|
|
83
|
+
|
|
79
84
|
return (
|
|
80
85
|
matchField(minuteF, minute, 0, 59) &&
|
|
81
86
|
matchField(hourF, hour, 0, 23) &&
|
|
82
|
-
|
|
87
|
+
domMatches &&
|
|
83
88
|
matchField(monthF, month, 1, 12) &&
|
|
84
89
|
matchField(dowF, dow, 0, 6)
|
|
85
90
|
)
|
|
@@ -221,9 +221,12 @@ export class TaskService {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
const entry = { metadata, handler }
|
|
224
|
-
TaskService._tasks.
|
|
225
|
-
if (
|
|
224
|
+
const existingIdx = TaskService._tasks.findIndex((t) => t.metadata.id === metadata.id)
|
|
225
|
+
if (existingIdx >= 0) {
|
|
226
226
|
_serviceLog.warn(`Task "${metadata.id}" already registered — overwriting`)
|
|
227
|
+
TaskService._tasks[existingIdx] = entry
|
|
228
|
+
} else {
|
|
229
|
+
TaskService._tasks.push(entry)
|
|
227
230
|
}
|
|
228
231
|
TaskService._tasksById.set(metadata.id, entry)
|
|
229
232
|
_serviceLog.info(
|
|
@@ -271,8 +274,9 @@ export class TaskService {
|
|
|
271
274
|
context: invokeContext,
|
|
272
275
|
trigger: 'invoke',
|
|
273
276
|
triggeredBy: {
|
|
274
|
-
|
|
277
|
+
...(context.req ? {} : { channel: 'api' }),
|
|
275
278
|
actionInvocationId: context.actionInvocationId ?? null,
|
|
279
|
+
...(context.inputs?.resourceId ? { resourceId: context.inputs.resourceId } : {}),
|
|
276
280
|
},
|
|
277
281
|
executionEnv: 'ossy_server',
|
|
278
282
|
audit,
|
|
@@ -253,6 +253,36 @@ describe('TaskRunList projection', () => {
|
|
|
253
253
|
expect(state.runs[0].inputs).toEqual({ model: 'google/gemini-2.5-flash-lite' })
|
|
254
254
|
})
|
|
255
255
|
|
|
256
|
+
it('keeps triggeredBy when Failed arrives without a prior Started in the list', () => {
|
|
257
|
+
let state = TaskRunListProjection.initialState('ws1')
|
|
258
|
+
state = TaskRunListProjection.Apply({
|
|
259
|
+
resourceId: 'run-orphan',
|
|
260
|
+
type: '@ossy/platform/schema/task-run',
|
|
261
|
+
event: 'Failed',
|
|
262
|
+
created: 3000,
|
|
263
|
+
payload: {
|
|
264
|
+
durationMs: 800,
|
|
265
|
+
error: { message: 'storage backend missing method "save"' },
|
|
266
|
+
belongsTo: 'ws1',
|
|
267
|
+
taskId: '@ossy/media-tasks/tasks/resize-common-web',
|
|
268
|
+
moduleId: 'media-tasks',
|
|
269
|
+
triggeredBy: {
|
|
270
|
+
resourceId: 'file-xyz',
|
|
271
|
+
type: '@ossy/platform/schema/file',
|
|
272
|
+
event: 'Created',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
}, state)
|
|
276
|
+
|
|
277
|
+
expect(state.runs).toHaveLength(1)
|
|
278
|
+
expect(state.runs[0].status).toBe('failed')
|
|
279
|
+
expect(state.runs[0].triggeredBy).toEqual({
|
|
280
|
+
resourceId: 'file-xyz',
|
|
281
|
+
type: '@ossy/platform/schema/file',
|
|
282
|
+
event: 'Created',
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
256
286
|
it('scopes Completed/Failed from payload.belongsTo (changestream dispatch)', () => {
|
|
257
287
|
expect(TaskRunListProjection.scopeFromEvent({
|
|
258
288
|
payload: { durationMs: 10 },
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vector embedding store backed by MongoDB.
|
|
3
|
+
*
|
|
4
|
+
* Collection: `resource_embeddings`
|
|
5
|
+
* Schema: { resourceId, workspaceId, model, dimensions, vector, textSource, textLength, updatedAt }
|
|
6
|
+
*
|
|
7
|
+
* Search strategy:
|
|
8
|
+
* - If Atlas Vector Search index `resource_embedding_vector` is present → $vectorSearch
|
|
9
|
+
* - Otherwise → in-memory cosine similarity (works fine for <10k resources per workspace)
|
|
10
|
+
*
|
|
11
|
+
* Atlas Vector Search index definition (create via Atlas UI or Admin API):
|
|
12
|
+
* ```json
|
|
13
|
+
* {
|
|
14
|
+
* "mappings": {
|
|
15
|
+
* "dynamic": false,
|
|
16
|
+
* "fields": {
|
|
17
|
+
* "vector": { "type": "knnVector", "dimensions": 1536, "similarity": "cosine" }
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createLogger } from '@ossy/observability'
|
|
25
|
+
import { Mongo } from '@ossy/event-store'
|
|
26
|
+
|
|
27
|
+
const log = createLogger('resources')
|
|
28
|
+
|
|
29
|
+
const COLLECTION = 'resource_embeddings'
|
|
30
|
+
const ATLAS_INDEX = 'resource_embedding_vector'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cosine similarity between two equal-length float arrays.
|
|
34
|
+
*
|
|
35
|
+
* @param {number[]} a
|
|
36
|
+
* @param {number[]} b
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function cosineSimilarity (a, b) {
|
|
40
|
+
let dot = 0
|
|
41
|
+
let magA = 0
|
|
42
|
+
let magB = 0
|
|
43
|
+
for (let i = 0; i < a.length; i++) {
|
|
44
|
+
dot += a[i] * b[i]
|
|
45
|
+
magA += a[i] * a[i]
|
|
46
|
+
magB += b[i] * b[i]
|
|
47
|
+
}
|
|
48
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB)
|
|
49
|
+
return denom === 0 ? 0 : dot / denom
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {import('mongodb').Collection}
|
|
54
|
+
*/
|
|
55
|
+
function col () {
|
|
56
|
+
return Mongo.db.collection(COLLECTION)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Upsert a resource embedding.
|
|
61
|
+
*
|
|
62
|
+
* @param {{
|
|
63
|
+
* resourceId: string,
|
|
64
|
+
* workspaceId: string,
|
|
65
|
+
* model: string,
|
|
66
|
+
* dimensions: number,
|
|
67
|
+
* vector: number[],
|
|
68
|
+
* textSource: string,
|
|
69
|
+
* textLength: number,
|
|
70
|
+
* }} doc
|
|
71
|
+
*/
|
|
72
|
+
export async function upsertEmbedding (doc) {
|
|
73
|
+
await col().updateOne(
|
|
74
|
+
{ resourceId: doc.resourceId },
|
|
75
|
+
{
|
|
76
|
+
$set: {
|
|
77
|
+
...doc,
|
|
78
|
+
updatedAt: new Date(),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{ upsert: true },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Delete the embedding for a resource (e.g. when the resource is removed).
|
|
87
|
+
*
|
|
88
|
+
* @param {string} resourceId
|
|
89
|
+
*/
|
|
90
|
+
export async function deleteEmbedding (resourceId) {
|
|
91
|
+
await col().deleteOne({ resourceId })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Semantic search within a workspace using cosine similarity.
|
|
96
|
+
* Tries Atlas $vectorSearch first; falls back to in-memory similarity.
|
|
97
|
+
*
|
|
98
|
+
* @param {{
|
|
99
|
+
* workspaceId: string,
|
|
100
|
+
* queryVector: number[],
|
|
101
|
+
* limit?: number,
|
|
102
|
+
* threshold?: number,
|
|
103
|
+
* }} opts
|
|
104
|
+
* @returns {Promise<Array<{ resourceId: string, score: number }>>}
|
|
105
|
+
*/
|
|
106
|
+
export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0 }) {
|
|
107
|
+
// Try Atlas Vector Search (requires index on Atlas M10+).
|
|
108
|
+
try {
|
|
109
|
+
const pipeline = [
|
|
110
|
+
{
|
|
111
|
+
$vectorSearch: {
|
|
112
|
+
index: ATLAS_INDEX,
|
|
113
|
+
path: 'vector',
|
|
114
|
+
queryVector,
|
|
115
|
+
numCandidates: limit * 10,
|
|
116
|
+
limit,
|
|
117
|
+
filter: { workspaceId },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
$project: {
|
|
122
|
+
_id: 0,
|
|
123
|
+
resourceId: 1,
|
|
124
|
+
score: { $meta: 'vectorSearchScore' },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
const results = await col().aggregate(pipeline).toArray()
|
|
129
|
+
if (results.length > 0) {
|
|
130
|
+
return results.filter((r) => r.score >= threshold)
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Atlas Vector Search not available — fall through to cosine similarity.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// In-memory cosine similarity fallback.
|
|
137
|
+
log.debug('[VectorStore] Atlas $vectorSearch unavailable — using in-memory cosine similarity')
|
|
138
|
+
const docs = await col()
|
|
139
|
+
.find({ workspaceId })
|
|
140
|
+
.project({ resourceId: 1, vector: 1 })
|
|
141
|
+
.toArray()
|
|
142
|
+
|
|
143
|
+
return docs
|
|
144
|
+
.map((doc) => ({
|
|
145
|
+
resourceId: doc.resourceId,
|
|
146
|
+
score: cosineSimilarity(queryVector, doc.vector),
|
|
147
|
+
}))
|
|
148
|
+
.filter((r) => r.score >= threshold)
|
|
149
|
+
.sort((a, b) => b.score - a.score)
|
|
150
|
+
.slice(0, limit)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load all embeddings for a workspace (for clustering).
|
|
155
|
+
*
|
|
156
|
+
* @param {string} workspaceId
|
|
157
|
+
* @returns {Promise<Array<{ resourceId: string, vector: number[] }>>}
|
|
158
|
+
*/
|
|
159
|
+
export async function loadWorkspaceEmbeddings (workspaceId) {
|
|
160
|
+
return col()
|
|
161
|
+
.find({ workspaceId })
|
|
162
|
+
.project({ resourceId: 1, vector: 1, _id: 0 })
|
|
163
|
+
.toArray()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Ensure MongoDB indexes for the resource_embeddings collection.
|
|
168
|
+
* Call once at server startup alongside ensureEventStoreIndexes().
|
|
169
|
+
*/
|
|
170
|
+
export async function ensureEmbeddingIndexes () {
|
|
171
|
+
const c = col()
|
|
172
|
+
await c.createIndex({ resourceId: 1 }, { name: 'resourceId', unique: true, background: true })
|
|
173
|
+
await c.createIndex({ workspaceId: 1 }, { name: 'workspaceId', background: true })
|
|
174
|
+
await c.createIndex({ workspaceId: 1, updatedAt: -1 }, { name: 'workspaceId_updatedAt', background: true })
|
|
175
|
+
log.info('[VectorStore] resource_embeddings indexes ensured')
|
|
176
|
+
}
|