Skip to content

Commit 4e18e8e

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oracle-epm): add Profitability and Cost Management integration
1 parent 9e738a3 commit 4e18e8e

53 files changed

Lines changed: 5186 additions & 8 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/ui/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
482482
onedrive: MicrosoftOneDriveIcon,
483483
onepassword: OnePasswordIcon,
484484
openai: OpenAIIcon,
485+
oracle_epm_profitability: NetSuiteIcon,
485486
outlook: OutlookIcon,
486487
pagerduty: PagerDutyIcon,
487488
parallel_ai: ParallelIcon,

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
"onedrive",
189189
"onepassword",
190190
"openai",
191+
"oracle_epm_profitability",
191192
"outlook",
192193
"pagerduty",
193194
"parallel_ai",

apps/docs/content/docs/integrations/oracle_epm_profitability.mdx

Lines changed: 511 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/oracle_epm_profitability.ts

Lines changed: 995 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
252252
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
253253
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
254254
import { OpenAIBlock, OpenAIBlockMeta } from '@/blocks/blocks/openai'
255+
import { OraclePcmBlock, OraclePcmBlockMeta } from '@/blocks/blocks/oracle_epm_profitability'
255256
import { OutlookBlock, OutlookBlockMeta } from '@/blocks/blocks/outlook'
256257
import { PagerDutyBlock, PagerDutyBlockMeta } from '@/blocks/blocks/pagerduty'
257258
import { ParallelBlock, ParallelBlockMeta } from '@/blocks/blocks/parallel'
@@ -595,6 +596,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
595596
onedrive: OneDriveBlock,
596597
onepassword: OnePasswordBlock,
597598
openai: OpenAIBlock,
599+
oracle_epm_profitability: OraclePcmBlock,
598600
outlook: OutlookBlock,
599601
pagerduty: PagerDutyBlock,
600602
parallel_ai: ParallelBlock,
@@ -917,6 +919,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
917919
onedrive: OneDriveBlockMeta,
918920
onepassword: OnePasswordBlockMeta,
919921
openai: OpenAIBlockMeta,
922+
oracle_epm_profitability: OraclePcmBlockMeta,
920923
outlook: OutlookBlockMeta,
921924
pagerduty: PagerDutyBlockMeta,
922925
parallel_ai: ParallelBlockMeta,

apps/sim/lib/copilot/generated/docs-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export const DOCS_MANIFEST: readonly string[] = [
245245
'integrations/onedrive.mdx',
246246
'integrations/onepassword.mdx',
247247
'integrations/openai.mdx',
248+
'integrations/oracle_epm_profitability.mdx',
248249
'integrations/outlook.mdx',
249250
'integrations/pagerduty.mdx',
250251
'integrations/parallel_ai.mdx',

apps/sim/lib/integrations/credential-display.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
6565
'linear-service-account': ['linear'],
6666
'monday-service-account': ['monday'],
6767
'notion-service-account': ['notion'],
68+
'oracle-epm-service-account': [],
6869
// NetSuite remains an API-key catalog integration, like Snowflake, while its
6970
// block uses the shared reusable-credential selector.
7071
'netsuite-service-account': [],
@@ -110,6 +111,28 @@ describe('service-account coverage', () => {
110111
})
111112
})
112113

114+
it('binds PCM to the existing reusable Oracle EPM credential', () => {
115+
expect(
116+
OAUTH_PROVIDERS['oracle-epm-profitability'].services['oracle-epm-profitability']
117+
).toMatchObject({
118+
providerId: 'oracle-epm-profitability',
119+
serviceAccountProviderId: 'oracle-epm-service-account',
120+
authType: 'service_account',
121+
})
122+
expect(
123+
credentialProviderMatchesService(
124+
'oracle-epm-service-account',
125+
OAUTH_PROVIDERS['oracle-epm-profitability'].services['oracle-epm-profitability']
126+
)
127+
).toBe(true)
128+
expect(
129+
credentialProviderMatchesService(
130+
'netsuite-service-account',
131+
OAUTH_PROVIDERS['oracle-epm-profitability'].services['oracle-epm-profitability']
132+
)
133+
).toBe(false)
134+
})
135+
113136
it('pins the table to exactly the registered service-account provider ids', () => {
114137
expect(REGISTERED_SERVICE_ACCOUNT_IDS).toEqual(Object.keys(EXPECTED_COVERAGE).sort())
115138
})

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
464464
okta: OktaIcon,
465465
onedrive: MicrosoftOneDriveIcon,
466466
onepassword: OnePasswordIcon,
467+
oracle_epm_profitability: NetSuiteIcon,
467468
outlook: OutlookIcon,
468469
pagerduty: PagerDutyIcon,
469470
parallel_ai: ParallelIcon,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({ operation: vi.fn(), job: vi.fn(), file: vi.fn() }))
5+
vi.mock('@/lib/internal/oracle-epm-profitability/operations', () => ({
6+
executeOraclePcmOperation: mocks.operation,
7+
}))
8+
vi.mock('@/lib/internal/oracle-epm-profitability/jobs', () => ({
9+
executeOraclePcmJobOperation: mocks.job,
10+
}))
11+
vi.mock('@/lib/internal/oracle-epm-profitability/files.server', () => ({
12+
executeOraclePcmFileOperation: mocks.file,
13+
}))
14+
vi.mock('@/lib/internal/oracle-epm/files.server', () => ({
15+
openOracleEpmSourceFile: vi.fn(),
16+
storeOracleEpmDownload: vi.fn(),
17+
}))
18+
19+
import { oracleEpmLocalError } from '@/lib/internal/oracle-epm/errors'
20+
import { executeOraclePcmTool } from '@/lib/internal/oracle-epm-profitability/execute-tool'
21+
import { OraclePcmOperationError } from '@/lib/internal/oracle-epm-profitability/normalizers'
22+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
23+
24+
function call(operation: string): InternalToolOperationCall {
25+
return {
26+
toolId: `oracle_epm_profitability_${operation}`,
27+
input: {
28+
oauthCredential: 'credential-1',
29+
accessToken: 'injected-token',
30+
instanceUrl: 'https://epm.example.com',
31+
userId: 'untrusted',
32+
},
33+
context: { workflowId: 'workflow-1', userId: 'trusted-user' },
34+
headers: new Headers(),
35+
requestId: 'request-1',
36+
signal: new AbortController().signal,
37+
}
38+
}
39+
describe('Oracle PCM direct tool handler', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
for (const mock of Object.values(mocks)) mock.mockResolvedValue({ success: true, output: {} })
43+
})
44+
it.each([
45+
'create_application',
46+
'enable_application',
47+
'deploy_cube',
48+
'update_dimensions',
49+
'load_data',
50+
'run_calculation',
51+
'copy_pov',
52+
'clear_pov',
53+
'get_rule_balancing',
54+
'generate_program_documentation',
55+
'export_query_results',
56+
'import_template',
57+
'apply_data_grants',
58+
'merge_slices',
59+
'optimize_cube',
60+
])('dispatches %s without an internal HTTP hop', async (operation) => {
61+
const request = call(operation)
62+
expect((await executeOraclePcmTool(request)).status).toBe(200)
63+
expect(mocks.operation).toHaveBeenCalledWith(operation, request.input, request.signal)
64+
})
65+
it.each(['get_task_status', 'wait_for_task'])(
66+
'forwards cancellation for %s',
67+
async (operation) => {
68+
const request = call(operation)
69+
await executeOraclePcmTool(request)
70+
expect(mocks.job).toHaveBeenCalledWith(operation, request.input, request.signal)
71+
}
72+
)
73+
it.each(['list_files', 'upload_file', 'download_file'])(
74+
'forwards trusted context for %s',
75+
async (operation) => {
76+
const request = call(operation)
77+
await executeOraclePcmTool(request)
78+
expect(mocks.file).toHaveBeenCalledWith(
79+
operation,
80+
request.input,
81+
request.signal,
82+
request.context
83+
)
84+
}
85+
)
86+
it.each([
87+
[new OraclePcmOperationError('Invalid application', 400), 400, 'Invalid application'],
88+
[oracleEpmLocalError('invalid_response'), 502, 'Oracle EPM returned an invalid response'],
89+
[oracleEpmLocalError('timeout'), 408, 'The Oracle EPM request timed out'],
90+
[
91+
oracleEpmLocalError('payload_too_large'),
92+
413,
93+
'The Oracle EPM payload exceeded the allowed size',
94+
],
95+
[new Error('credential-secret-canary'), 500, 'Oracle PCM operation failed'],
96+
])(
97+
'returns safe failures and disallows automatic resubmission',
98+
async (error, status, message) => {
99+
mocks.operation.mockRejectedValue(error)
100+
const response = await executeOraclePcmTool(call('run_calculation'))
101+
expect(response.status).toBe(status)
102+
expect(await response.json()).toEqual({
103+
success: false,
104+
output: {},
105+
error: message,
106+
retryable: false,
107+
})
108+
}
109+
)
110+
it('distinguishes local timeout from remote cancellation', async () => {
111+
mocks.job.mockRejectedValue(new DOMException('deadline', 'TimeoutError'))
112+
const response = await executeOraclePcmTool(call('wait_for_task'))
113+
expect(response.status).toBe(408)
114+
expect((await response.json()).error).toContain('remote task was not cancelled')
115+
})
116+
it('propagates aborts and rejects other tool namespaces', async () => {
117+
const request = call('run_calculation')
118+
request.signal = AbortSignal.abort()
119+
await expect(executeOraclePcmTool(request)).rejects.toThrow()
120+
expect(mocks.operation).not.toHaveBeenCalled()
121+
expect(
122+
(await executeOraclePcmTool({ ...call('list_files'), toolId: 'unrelated_tool' })).status
123+
).toBe(400)
124+
})
125+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
2+
import { OracleEpmError } from '@/lib/internal/oracle-epm'
3+
import { executeOraclePcmFileOperation } from '@/lib/internal/oracle-epm-profitability/files.server'
4+
import { executeOraclePcmJobOperation } from '@/lib/internal/oracle-epm-profitability/jobs'
5+
import { OraclePcmOperationError } from '@/lib/internal/oracle-epm-profitability/normalizers'
6+
import { executeOraclePcmOperation } from '@/lib/internal/oracle-epm-profitability/operations'
7+
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
8+
9+
const PREFIX = 'oracle_epm_profitability_'
10+
const JOB_OPERATIONS = new Set(['get_task_status', 'wait_for_task'])
11+
const FILE_OPERATIONS = new Set(['list_files', 'upload_file', 'download_file'])
12+
13+
export const executeOraclePcmTool: InternalToolOperationHandler = async (request) => {
14+
request.signal?.throwIfAborted()
15+
if (!request.toolId.startsWith(PREFIX)) {
16+
return Response.json(
17+
{ success: false, output: {}, error: 'Unsupported Oracle PCM tool', retryable: false },
18+
{ status: 400 }
19+
)
20+
}
21+
const operation = request.toolId.slice(PREFIX.length)
22+
try {
23+
const result = FILE_OPERATIONS.has(operation)
24+
? await executeOraclePcmFileOperation(
25+
operation,
26+
request.input,
27+
request.signal,
28+
request.context
29+
)
30+
: JOB_OPERATIONS.has(operation)
31+
? await executeOraclePcmJobOperation(operation, request.input, request.signal)
32+
: await executeOraclePcmOperation(operation, request.input, request.signal)
33+
return Response.json(result)
34+
} catch (error) {
35+
request.signal?.throwIfAborted()
36+
const timeout = error instanceof Error && error.name === 'TimeoutError'
37+
const status = isPayloadSizeLimitError(error)
38+
? 413
39+
: error instanceof OraclePcmOperationError
40+
? error.status
41+
: error instanceof OracleEpmError
42+
? (error.status ??
43+
(error.category === 'timeout'
44+
? 408
45+
: error.category === 'payload_too_large'
46+
? 413
47+
: 502))
48+
: timeout
49+
? 408
50+
: 500
51+
const message =
52+
error instanceof OraclePcmOperationError || error instanceof OracleEpmError
53+
? error.message
54+
: timeout
55+
? 'Oracle PCM wait timed out; the remote task was not cancelled. Resume with the same processName.'
56+
: isPayloadSizeLimitError(error)
57+
? 'Oracle PCM payload exceeded its allowed size'
58+
: 'Oracle PCM operation failed'
59+
return Response.json(
60+
{ success: false, output: {}, error: message, retryable: false },
61+
{ status }
62+
)
63+
}
64+
}

0 commit comments

Comments
 (0)