Skip to content

Commit a6abb9d

Browse files
committed
Job logs
1 parent 777e4f5 commit a6abb9d

File tree

4 files changed

+207
-0
lines changed

4 files changed

+207
-0
lines changed

apps/sim/lib/copilot/orchestrator/tool-executor/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ const SERVER_TOOLS = new Set<string>([
701701
'run_from_block',
702702
'workspace_file',
703703
'get_execution_summary',
704+
'get_job_logs',
704705
])
705706

706707
/**
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { db } from '@sim/db'
2+
import { jobExecutionLogs } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, desc, eq } from 'drizzle-orm'
5+
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
6+
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
7+
8+
const logger = createLogger('GetJobLogsServerTool')
9+
10+
interface GetJobLogsArgs {
11+
jobId: string
12+
executionId?: string
13+
limit?: number
14+
includeDetails?: boolean
15+
workspaceId?: string
16+
}
17+
18+
interface ToolCallDetail {
19+
name: string
20+
input: unknown
21+
output: unknown
22+
error?: string
23+
duration: number
24+
}
25+
26+
interface JobLogEntry {
27+
executionId: string
28+
status: string
29+
trigger: string
30+
startedAt: string
31+
endedAt: string | null
32+
durationMs: number | null
33+
error?: string
34+
toolCalls?: ToolCallDetail[]
35+
output?: unknown
36+
cost?: unknown
37+
tokens?: unknown
38+
}
39+
40+
function extractToolCalls(traceSpan: any): ToolCallDetail[] {
41+
if (!traceSpan?.toolCalls || !Array.isArray(traceSpan.toolCalls)) return []
42+
43+
return traceSpan.toolCalls.map((tc: any) => ({
44+
name: tc.name || 'unknown',
45+
input: tc.input || tc.arguments || {},
46+
output: tc.output || tc.result || undefined,
47+
error: tc.error || undefined,
48+
duration: tc.duration || 0,
49+
}))
50+
}
51+
52+
function extractOutputAndError(executionData: any): {
53+
output: unknown
54+
error: string | undefined
55+
toolCalls: ToolCallDetail[]
56+
cost: unknown
57+
tokens: unknown
58+
} {
59+
const traceSpans = executionData?.traceSpans || []
60+
const mainSpan = traceSpans[0]
61+
62+
const toolCalls = mainSpan ? extractToolCalls(mainSpan) : []
63+
const output = mainSpan?.output || executionData?.finalOutput || undefined
64+
const cost = mainSpan?.cost || executionData?.cost || undefined
65+
const tokens = mainSpan?.tokens || undefined
66+
67+
const errorMsg =
68+
mainSpan?.status === 'error'
69+
? mainSpan?.output?.error || executionData?.error
70+
: executionData?.error || undefined
71+
72+
return {
73+
output,
74+
error: errorMsg ? (typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg)) : undefined,
75+
toolCalls,
76+
cost,
77+
tokens,
78+
}
79+
}
80+
81+
export const getJobLogsServerTool: BaseServerTool<GetJobLogsArgs, JobLogEntry[]> = {
82+
name: 'get_job_logs',
83+
async execute(rawArgs: GetJobLogsArgs, context?: ServerToolContext): Promise<JobLogEntry[]> {
84+
const {
85+
jobId,
86+
executionId,
87+
limit = 3,
88+
includeDetails = false,
89+
workspaceId,
90+
} = rawArgs || ({} as GetJobLogsArgs)
91+
92+
if (!jobId || typeof jobId !== 'string') {
93+
throw new Error('jobId is required')
94+
}
95+
if (!context?.userId) {
96+
throw new Error('Unauthorized access')
97+
}
98+
99+
const wsId = workspaceId || context.workspaceId
100+
if (wsId) {
101+
const access = await checkWorkspaceAccess(wsId, context.userId)
102+
if (!access.hasAccess) {
103+
throw new Error('Unauthorized workspace access')
104+
}
105+
}
106+
107+
const clampedLimit = Math.min(Math.max(1, limit), 5)
108+
109+
logger.info('Fetching job logs', { jobId, executionId, limit: clampedLimit, includeDetails })
110+
111+
const conditions = [eq(jobExecutionLogs.scheduleId, jobId)]
112+
if (executionId) {
113+
conditions.push(eq(jobExecutionLogs.executionId, executionId))
114+
}
115+
116+
const rows = await db
117+
.select({
118+
id: jobExecutionLogs.id,
119+
executionId: jobExecutionLogs.executionId,
120+
status: jobExecutionLogs.status,
121+
level: jobExecutionLogs.level,
122+
trigger: jobExecutionLogs.trigger,
123+
startedAt: jobExecutionLogs.startedAt,
124+
endedAt: jobExecutionLogs.endedAt,
125+
totalDurationMs: jobExecutionLogs.totalDurationMs,
126+
executionData: jobExecutionLogs.executionData,
127+
cost: jobExecutionLogs.cost,
128+
})
129+
.from(jobExecutionLogs)
130+
.where(and(...conditions))
131+
.orderBy(desc(jobExecutionLogs.startedAt))
132+
.limit(executionId ? 1 : clampedLimit)
133+
134+
const entries: JobLogEntry[] = rows.map((row) => {
135+
const executionData = row.executionData as any
136+
const details = includeDetails ? extractOutputAndError(executionData) : null
137+
138+
const entry: JobLogEntry = {
139+
executionId: row.executionId,
140+
status: row.status,
141+
trigger: row.trigger,
142+
startedAt: row.startedAt.toISOString(),
143+
endedAt: row.endedAt ? row.endedAt.toISOString() : null,
144+
durationMs: row.totalDurationMs ?? null,
145+
}
146+
147+
if (details) {
148+
if (details.error) entry.error = details.error
149+
if (details.toolCalls.length > 0) entry.toolCalls = details.toolCalls
150+
if (details.output) entry.output = details.output
151+
if (details.cost) entry.cost = details.cost
152+
if (details.tokens) entry.tokens = details.tokens
153+
} else {
154+
const errorMsg =
155+
executionData?.error ||
156+
executionData?.traceSpans?.[0]?.output?.error
157+
if (row.status === 'error' && errorMsg) {
158+
entry.error = typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg)
159+
}
160+
}
161+
162+
return entry
163+
})
164+
165+
logger.info('Job logs prepared', {
166+
jobId,
167+
count: entries.length,
168+
resultSizeKB: Math.round(JSON.stringify(entries).length / 1024),
169+
})
170+
171+
return entries
172+
},
173+
}

apps/sim/lib/copilot/tools/server/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/g
44
import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks'
55
import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation'
66
import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file'
7+
import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs'
78
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
89
import { makeApiRequestServerTool } from '@/lib/copilot/tools/server/other/make-api-request'
910
import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online'
@@ -41,6 +42,7 @@ const serverToolRegistry: Record<string, BaseServerTool> = {
4142
[editWorkflowServerTool.name]: editWorkflowServerTool,
4243
[getExecutionSummaryServerTool.name]: getExecutionSummaryServerTool,
4344
[getWorkflowLogsServerTool.name]: getWorkflowLogsServerTool,
45+
[getJobLogsServerTool.name]: getJobLogsServerTool,
4446
[searchDocumentationServerTool.name]: searchDocumentationServerTool,
4547
[searchOnlineServerTool.name]: searchOnlineServerTool,
4648
[setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool,

apps/sim/lib/copilot/vfs/workspace-vfs.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
copilotChats,
66
document,
77
form,
8+
jobExecutionLogs,
89
knowledgeConnector,
910
mcpServers as mcpServersTable,
1011
workflowDeploymentVersion,
@@ -265,6 +266,7 @@ function getStaticComponentFiles(): Map<string, string> {
265266
* tables/{name}/meta.json
266267
* files/{name}/meta.json
267268
* jobs/{title}/meta.json
269+
* jobs/{title}/executions.json
268270
* tasks/{title}/session.md
269271
* tasks/{title}/chat.json
270272
* custom-tools/{name}.json
@@ -1071,6 +1073,35 @@ export class WorkspaceVFS {
10711073
createdAt: job.createdAt,
10721074
})
10731075
)
1076+
1077+
try {
1078+
const execRows = await db
1079+
.select({
1080+
id: jobExecutionLogs.id,
1081+
executionId: jobExecutionLogs.executionId,
1082+
status: jobExecutionLogs.status,
1083+
trigger: jobExecutionLogs.trigger,
1084+
startedAt: jobExecutionLogs.startedAt,
1085+
endedAt: jobExecutionLogs.endedAt,
1086+
totalDurationMs: jobExecutionLogs.totalDurationMs,
1087+
})
1088+
.from(jobExecutionLogs)
1089+
.where(eq(jobExecutionLogs.scheduleId, job.id))
1090+
.orderBy(desc(jobExecutionLogs.startedAt))
1091+
.limit(5)
1092+
1093+
if (execRows.length > 0) {
1094+
this.files.set(
1095+
`jobs/${safeName}/executions.json`,
1096+
serializeRecentExecutions(execRows)
1097+
)
1098+
}
1099+
} catch (err) {
1100+
logger.warn('Failed to load job execution logs', {
1101+
jobId: job.id,
1102+
error: err instanceof Error ? err.message : String(err),
1103+
})
1104+
}
10741105
}
10751106

10761107
return jobRows

0 commit comments

Comments
 (0)