diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 278c907ed4..7fb35a0359 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -211,6 +211,12 @@ "icon": "$(link-external)", "category": "Posit Publisher" }, + { + "command": "posit.publisher.logs.viewJobLog", + "title": "View Full Job Error Log", + "icon": "$(output)", + "category": "Posit Publisher" + }, { "command": "posit.publisher.homeView.navigateToDeployment.Server", "title": "Browse Deployment Server", @@ -349,6 +355,10 @@ "command": "posit.publisher.logs.visit", "when": "false" }, + { + "command": "posit.publisher.logs.viewJobLog", + "when": "false" + }, { "command": "posit.publisher.homeView.edit.Configuration", "when": "false" diff --git a/extensions/vscode/src/constants.ts b/extensions/vscode/src/constants.ts index b1fe054a0e..72bc05d943 100644 --- a/extensions/vscode/src/constants.ts +++ b/extensions/vscode/src/constants.ts @@ -44,6 +44,7 @@ const baseContexts = { const logsCommands = { Visit: "posit.publisher.logs.visit", + ViewJobLog: "posit.publisher.logs.viewJobLog", // Added automatically by VSCode with view registration Fileview: "posit.publisher.logs.fileview", Copy: "posit.publisher.logs.copy", diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 81c535e641..2c515e3c0e 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -20,6 +20,7 @@ import { Commands } from "src/constants"; import { DocumentTracker } from "./entrypointTracker"; import { getXDGConfigProperty } from "src/utils/config"; import { PublisherState } from "./state"; +import { normalizeURL } from "src/utils/url"; import { PublisherAuthProvider } from "./authProvider"; import { logger } from "./logging"; import { copySystemInfoCommand } from "src/commands"; @@ -129,7 +130,21 @@ async function initializeExtension(context: ExtensionContext) { const projectTreeDataProvider = new ProjectTreeDataProvider(context); // Logs tree view - const logsTreeDataProvider = new LogsTreeDataProvider(context, stream); + const logsTreeDataProvider = new LogsTreeDataProvider( + context, + stream, + (serverUrl: string) => { + const credential = state.credentials.find( + (c) => + normalizeURL(c.url).toLowerCase() === + normalizeURL(serverUrl).toLowerCase(), + ); + if (credential) { + return { url: credential.url, apiKey: credential.apiKey }; + } + return undefined; + }, + ); const homeViewProvider = new HomeViewProvider(context, stream, state); context.subscriptions.push(homeViewProvider); diff --git a/extensions/vscode/src/views/logs.test.ts b/extensions/vscode/src/views/logs.test.ts index ff28e53a2f..1af3590ecf 100644 --- a/extensions/vscode/src/views/logs.test.ts +++ b/extensions/vscode/src/views/logs.test.ts @@ -84,9 +84,26 @@ vi.mock("vscode", () => { vi.mock("src/extension", () => ({ extensionSettings: { autoOpenLogsOnFailure: vi.fn().mockReturnValue(false), + verifyCertificates: vi.fn().mockReturnValue(true), }, })); +// Mock ConnectAPI — use vi.hoisted so variables are available when the hoisted vi.mock factory runs +const { mockGetJobs, mockGetJobLog } = vi.hoisted(() => ({ + mockGetJobs: vi.fn(), + mockGetJobLog: vi.fn(), +})); +vi.mock("@posit-dev/connect-api", () => { + class MockConnectAPI { + getJobs = mockGetJobs; + getJobLog = mockGetJobLog; + } + return { + ConnectAPI: MockConnectAPI, + ContentID: (id: string) => id, + }; +}); + // Mock window utilities vi.mock("src/utils/window", () => ({ showErrorMessageWithTroubleshoot: vi.fn(), @@ -854,4 +871,383 @@ describe("LogsTreeDataProvider", () => { expect(mockTreeView.reveal).not.toHaveBeenCalled(); }); }); + + describe("job error log on validation failure", () => { + const credentialResolver = vi.fn().mockReturnValue({ + url: "https://connect.example.com", + apiKey: "test-api-key", + }); + + function createDeploymentNewSuccessMessage( + contentId: string, + ): EventStreamMessage { + return { + type: "publish/createNewDeployment/success" as EventStreamMessage["type"], + time: new Date().toISOString(), + data: { + localId: "local-1", + contentId, + saveName: "my-app", + }, + }; + } + + beforeEach(() => { + mockGetJobs.mockReset(); + mockGetJobLog.mockReset(); + credentialResolver.mockClear(); + }); + + test("should fetch and append job log entries on validateDeployment failure", async () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + const provider = new LogsTreeDataProvider( + context, + stream, + credentialResolver, + ); + provider.register(); + + // Start deployment and track contentId + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + emit( + "publish/createNewDeployment/success", + createDeploymentNewSuccessMessage("content-guid-123"), + ); + + // Mock the jobs API + mockGetJobs.mockResolvedValue({ + data: [ + { + id: "1", + key: "job-key-1", + status: 1, + start_time: "2024-01-01T00:00:00Z", + }, + ], + }); + mockGetJobLog.mockResolvedValue({ + data: [ + { + source: "stderr", + timestamp: "2024-01-01T00:00:00Z", + data: "Error: module not found", + }, + { + source: "stderr", + timestamp: "2024-01-01T00:00:01Z", + data: "Traceback (most recent call last):", + }, + { + source: "stderr", + timestamp: "2024-01-01T00:00:02Z", + data: ' File "app.py", line 1', + }, + ], + }); + + // Start and fail the validate stage + emit( + "publish/validateDeployment/start", + createStageStartMessage("publish/validateDeployment"), + ); + emit( + "publish/validateDeployment/failure", + createStageFailureMessage( + "publish/validateDeployment", + "Validation failed", + ), + ); + + // Wait for async job log fetch to complete and add events + await vi.waitFor(() => { + const children = provider.getChildren(undefined) as LogsTreeStageItem[]; + const root = children[0]!; + const stages = provider.getChildren(root) as LogsTreeStageItem[]; + const validateStage = stages.find( + (s) => s.stage.inactiveLabel === "Validate Deployment Record", + ); + expect(validateStage).toBeDefined(); + // 1 failure event + 3 job log lines + expect(validateStage!.events.length).toBe(4); + }); + + const children = provider.getChildren(undefined) as LogsTreeStageItem[]; + const root = children[0]!; + const stages = provider.getChildren(root) as LogsTreeStageItem[]; + const validateStage = stages.find( + (s) => s.stage.inactiveLabel === "Validate Deployment Record", + ); + expect(validateStage!.events[1]!.data.message).toBe( + "Error: module not found", + ); + }); + + test("should show header with count when log has more than 10 lines", async () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + const provider = new LogsTreeDataProvider( + context, + stream, + credentialResolver, + ); + provider.register(); + + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + emit( + "publish/createNewDeployment/success", + createDeploymentNewSuccessMessage("content-guid-123"), + ); + + mockGetJobs.mockResolvedValue({ + data: [ + { + id: "1", + key: "job-key-1", + status: 1, + start_time: "2024-01-01T00:00:00Z", + }, + ], + }); + + // Create 15 log entries + const logEntries = Array.from({ length: 15 }, (_, i) => ({ + source: "stderr", + timestamp: "2024-01-01T00:00:00Z", + data: `Log line ${i + 1}`, + })); + mockGetJobLog.mockResolvedValue({ data: logEntries }); + + emit( + "publish/validateDeployment/start", + createStageStartMessage("publish/validateDeployment"), + ); + emit( + "publish/validateDeployment/failure", + createStageFailureMessage( + "publish/validateDeployment", + "Validation failed", + ), + ); + + // Wait for async job log fetch to complete + await vi.waitFor(() => { + const children = provider.getChildren(undefined) as LogsTreeStageItem[]; + const root = children[0]!; + const stages = provider.getChildren(root) as LogsTreeStageItem[]; + const validateStage = stages.find( + (s) => s.stage.inactiveLabel === "Validate Deployment Record", + ); + expect(validateStage).toBeDefined(); + // 1 failure event + 1 header + 10 tail lines = 12 + expect(validateStage!.events.length).toBe(12); + }); + + const children = provider.getChildren(undefined) as LogsTreeStageItem[]; + const root = children[0]!; + const stages = provider.getChildren(root) as LogsTreeStageItem[]; + const validateStage = stages.find( + (s) => s.stage.inactiveLabel === "Validate Deployment Record", + ); + expect(validateStage!.events[1]!.data.message).toContain( + "Showing last 10 of 15", + ); + expect(validateStage!.events[1]!.data.jobLogAction).toBe("true"); + }); + + test("should not fetch job log without credential resolver", () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + // No credential resolver + const provider = new LogsTreeDataProvider(context, stream); + provider.register(); + + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + emit( + "publish/createNewDeployment/success", + createDeploymentNewSuccessMessage("content-guid-123"), + ); + + emit( + "publish/validateDeployment/start", + createStageStartMessage("publish/validateDeployment"), + ); + emit( + "publish/validateDeployment/failure", + createStageFailureMessage( + "publish/validateDeployment", + "Validation failed", + ), + ); + + // Should not have called the API + expect(mockGetJobs).not.toHaveBeenCalled(); + }); + + test("should not fetch job log without tracked contentId", () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + const provider = new LogsTreeDataProvider( + context, + stream, + credentialResolver, + ); + provider.register(); + + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + // Note: no createNewDeployment/success event, so no contentId + + emit( + "publish/validateDeployment/start", + createStageStartMessage("publish/validateDeployment"), + ); + emit( + "publish/validateDeployment/failure", + createStageFailureMessage( + "publish/validateDeployment", + "Validation failed", + ), + ); + + expect(mockGetJobs).not.toHaveBeenCalled(); + }); + + test("should track contentId from publish/createDeployment/start", async () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + const provider = new LogsTreeDataProvider( + context, + stream, + credentialResolver, + ); + provider.register(); + + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + // Use createDeployment/start instead of createNewDeployment/success + emit("publish/createDeployment/start", { + type: "publish/createDeployment/start" as EventStreamMessage["type"], + time: new Date().toISOString(), + data: { + localId: "local-1", + contentId: "content-guid-456", + saveName: "my-app", + }, + }); + + mockGetJobs.mockResolvedValue({ + data: [ + { + id: "1", + key: "job-key-1", + status: 1, + start_time: "2024-01-01T00:00:00Z", + }, + ], + }); + mockGetJobLog.mockResolvedValue({ + data: [{ source: "stderr", message: "Error" }], + }); + + emit( + "publish/validateDeployment/start", + createStageStartMessage("publish/validateDeployment"), + ); + emit( + "publish/validateDeployment/failure", + createStageFailureMessage( + "publish/validateDeployment", + "Validation failed", + ), + ); + + await vi.waitFor(() => { + expect(credentialResolver).toHaveBeenCalledWith("server1.com"); + }); + }); + + test("should handle API errors gracefully", async () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + const provider = new LogsTreeDataProvider( + context, + stream, + credentialResolver, + ); + provider.register(); + + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + emit( + "publish/createNewDeployment/success", + createDeploymentNewSuccessMessage("content-guid-123"), + ); + + mockGetJobs.mockRejectedValue(new Error("Network error")); + + emit( + "publish/validateDeployment/start", + createStageStartMessage("publish/validateDeployment"), + ); + + // Should not throw + emit( + "publish/validateDeployment/failure", + createStageFailureMessage( + "publish/validateDeployment", + "Validation failed", + ), + ); + + // Wait for the error to be caught (the API call will fail but be handled) + await vi.waitFor(() => { + expect(mockGetJobs).toHaveBeenCalled(); + }); + + // Stage should still have just the original failure event (no log entries added) + const children = provider.getChildren(undefined) as LogsTreeStageItem[]; + const root = children[0]!; + const stages = provider.getChildren(root) as LogsTreeStageItem[]; + const validateStage = stages.find( + (s) => s.stage.inactiveLabel === "Validate Deployment Record", + ); + expect(validateStage!.events.length).toBe(1); + }); + + test("should not fetch job log for non-validation failures", () => { + const { stream, emit } = createMockEventStream(); + const context = createMockContext(); + + const provider = new LogsTreeDataProvider( + context, + stream, + credentialResolver, + ); + provider.register(); + + emit("publish/start", createPublishStartMessage("App1", "server1.com")); + emit( + "publish/createNewDeployment/success", + createDeploymentNewSuccessMessage("content-guid-123"), + ); + + emit( + "publish/createBundle/start", + createStageStartMessage("publish/createBundle"), + ); + emit( + "publish/createBundle/failure", + createStageFailureMessage( + "publish/createBundle", + "Bundle creation failed", + ), + ); + + expect(mockGetJobs).not.toHaveBeenCalled(); + }); + }); }); diff --git a/extensions/vscode/src/views/logs.ts b/extensions/vscode/src/views/logs.ts index 9dc36ff514..3bce8c50b6 100644 --- a/extensions/vscode/src/views/logs.ts +++ b/extensions/vscode/src/views/logs.ts @@ -34,6 +34,11 @@ import { restoreMsgToStatusSuffix, ProductType, } from "src/api"; +import { + ConnectAPI, + ContentID, + type JobLogEntry, +} from "@posit-dev/connect-api"; import { Commands, Views } from "src/constants"; import { ErrorMessageActionIds, @@ -278,6 +283,10 @@ export class LogsViewProvider { } } +export type CredentialResolver = ( + serverUrl: string, +) => { url: string; apiKey: string } | undefined; + /** * Tree data provider for the Logs view. */ @@ -286,6 +295,10 @@ export class LogsTreeDataProvider implements TreeDataProvider { private publishingStage!: LogStage; private treeView?: TreeView; + // Tracked per-deployment so we can fetch job logs on validation failure + private trackedContentId: string | undefined; + private trackedServerUrl: string | undefined; + /** * Reveal helper that checks visibility immediately before calling reveal. * This prevents race conditions where the view is closed between the start @@ -310,16 +323,20 @@ export class LogsTreeDataProvider implements TreeDataProvider { /** * Creates an instance of LogsTreeDataProvider. * @constructor - * @param {ExtensionContext} context = The VSCode Extension's runtime context + * @param {ExtensionContext} context - The VSCode Extension's runtime context * @param {EventStream} stream - The event stream to listen to. + * @param {CredentialResolver} credentialResolver - Resolves server URL to credentials for Connect API calls. */ constructor( private readonly context: ExtensionContext, private readonly stream: EventStream, + private readonly credentialResolver?: CredentialResolver, ) {} private resetStages() { this.stages = createStagesMap(); + this.trackedContentId = undefined; + this.trackedServerUrl = undefined; this.publishingStage = createLogStage( "Publishing", @@ -332,6 +349,103 @@ export class LogsTreeDataProvider implements TreeDataProvider { ); } + /** + * Fetches the latest failed job's error log from Connect and appends + * the tail lines as synthetic log events under the given stage. + */ + private async fetchJobErrorLog(stage: LogStage): Promise { + if (!this.trackedContentId || !this.trackedServerUrl) { + return; + } + if (!this.credentialResolver) { + return; + } + + const credential = this.credentialResolver(this.trackedServerUrl); + if (!credential) { + return; + } + + try { + const connectApi = new ConnectAPI({ + url: credential.url, + apiKey: credential.apiKey, + rejectUnauthorized: extensionSettings.verifyCertificates(), + }); + const contentId = ContentID(this.trackedContentId); + const { data: jobs } = await connectApi.getJobs(contentId); + + // Find the most recent failed job (status != 0, latest by start_time) + const failedJobs = jobs + .filter((j) => j.status !== 0) + .sort( + (a, b) => + new Date(b.start_time).getTime() - new Date(a.start_time).getTime(), + ); + const latestFailedJob = failedJobs[0]; + if (!latestFailedJob) { + return; + } + + const { data: logEntries } = await connectApi.getJobLog( + contentId, + latestFailedJob.key, + ); + + if (logEntries.length === 0) { + return; + } + + // Store full log for "open full log" action + this.lastJobLogEntries = logEntries; + + // Append tail lines as synthetic events under the stage + const tailCount = 10; + const tail = logEntries.slice(-tailCount); + if (logEntries.length > tailCount) { + stage.events.push( + this.syntheticLogEvent( + `--- Showing last ${tailCount} of ${logEntries.length} job log lines (click to view full log) ---`, + true, + ), + ); + } + for (const entry of tail) { + stage.events.push(this.syntheticLogEvent(entry.data)); + } + + this.refresh(); + this.revealLatestLog("publish/validateDeployment"); + } catch (error) { + console.error("Failed to fetch job error log:", error); + } + } + + private lastJobLogEntries: JobLogEntry[] = []; + + private syntheticLogEvent( + message: string, + jobLogAction = false, + ): EventStreamMessage { + return { + type: "publish/validateDeployment/log", + time: new Date().toISOString(), + data: { + message, + level: "ERROR", + ...(jobLogAction ? { jobLogAction: "true" } : {}), + }, + }; + } + + public static async openJobErrorLog( + logEntries: JobLogEntry[], + ): Promise { + const content = logEntries.map((e) => `[${e.source}] ${e.data}`).join("\n"); + const document = await workspace.openTextDocument({ content }); + await window.showTextDocument(document); + } + private registerEvents() { // Reset events when a new publish starts this.stream.register("publish/start", (msg: EventStreamMessage) => { @@ -344,6 +458,7 @@ export class LogsTreeDataProvider implements TreeDataProvider { stage.status = LogStageStatus.notApplicable; } }); + this.trackedServerUrl = msg.data.server; this.publishingStage.inactiveLabel = `Publish "${msg.data.title}" to ${msg.data.server}`; this.publishingStage.activeLabel = `Publishing "${msg.data.title}" to ${msg.data.server}`; this.publishingStage.status = LogStageStatus.inProgress; @@ -453,6 +568,24 @@ export class LogsTreeDataProvider implements TreeDataProvider { } }); + // Track contentId from deployment creation events + this.stream.register( + "publish/createNewDeployment/success", + (msg: EventStreamMessage) => { + if (msg.data.contentId) { + this.trackedContentId = msg.data.contentId; + } + }, + ); + this.stream.register( + "publish/createDeployment/start", + (msg: EventStreamMessage) => { + if (msg.data.contentId) { + this.trackedContentId = msg.data.contentId; + } + }, + ); + Array.from(this.stages.keys()).forEach((stageName) => { this.stream.register( `${stageName}/start`, @@ -488,7 +621,7 @@ export class LogsTreeDataProvider implements TreeDataProvider { this.stream.register( `${stageName}/failure`, - (msg: EventStreamMessage) => { + async (msg: EventStreamMessage) => { const stage = this.stages.get(stageName); if (stage) { if (msg.data.canceled === "true") { @@ -500,6 +633,11 @@ export class LogsTreeDataProvider implements TreeDataProvider { } this.refresh(); this.revealFailure(); + + // Fetch job error log when content validation fails + if (stageName === "publish/validateDeployment" && stage) { + await this.fetchJobErrorLog(stage); + } }, ); @@ -729,6 +867,11 @@ export class LogsTreeDataProvider implements TreeDataProvider { await env.openExternal(uri); }, ), + commands.registerCommand(Commands.Logs.ViewJobLog, async () => { + if (this.lastJobLogEntries.length > 0) { + await LogsTreeDataProvider.openJobErrorLog(this.lastJobLogEntries); + } + }), ); } } @@ -854,6 +997,14 @@ export class LogsTreeLogItem extends TreeItem { this.iconPath = new ThemeIcon("debug-stackframe-dot"); } + // Allow synthetic job log header lines to open the full log + if (msg.data.jobLogAction) { + this.command = { + title: "View Full Job Log", + command: Commands.Logs.ViewJobLog, + }; + } + // Prefer logs urls when a validate deployment failure or publish failure const isFailureType = msg.type === "publish/validateDeployment/failure" || @@ -863,7 +1014,7 @@ export class LogsTreeLogItem extends TreeItem { ? msg.data.logsUrl || msg.data.dashboardUrl : msg.data.dashboardUrl; - if (url) { + if (!msg.data.jobLogAction && url) { this.command = { title: "View", command: Commands.Logs.Visit, diff --git a/packages/connect-api/src/client.test.ts b/packages/connect-api/src/client.test.ts index b4f15c8ec5..cd8fe92aa4 100644 --- a/packages/connect-api/src/client.test.ts +++ b/packages/connect-api/src/client.test.ts @@ -936,6 +936,108 @@ describe("validateDeployment", () => { }); }); +// --------------------------------------------------------------------------- +// getJobs +// --------------------------------------------------------------------------- + +describe("getJobs", () => { + const contentId = ContentID("content-123"); + + it("returns the list of jobs", async () => { + const jobs = [ + { + id: "1", + key: "job-key-1", + app_id: "42", + app_guid: contentId, + variant_id: "0", + status: 0, + hostname: "host1", + cluster: null, + image: null, + tag: "build_jupyter", + exit_code: 0, + run_as: "rstudio-connect", + queue_time: null, + start_time: "2024-01-01T00:00:00Z", + end_time: "2024-01-01T00:01:00Z", + }, + ]; + mockRequest.mockResolvedValue(jsonResponse(jobs)); + + const client = createClient(); + const { data } = await client.getJobs(contentId); + + expect(data).toEqual(jobs); + }); + + it("calls the correct URL", async () => { + mockRequest.mockResolvedValue(jsonResponse([])); + + const client = createClient(); + await client.getJobs(contentId); + + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe(`/__api__/v1/content/${contentId}/jobs`); + }); + + it("throws on non-2xx", async () => { + mockRequest.mockResolvedValue(textResponse("not found", 404, "Not Found")); + + const client = createClient(); + await expect(client.getJobs(contentId)).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// getJobLog +// --------------------------------------------------------------------------- + +describe("getJobLog", () => { + const contentId = ContentID("content-123"); + const jobKey = "job-key-1"; + + it("returns the log entries", async () => { + const logEntries = [ + { + source: "stderr", + timestamp: "2024-01-01T00:00:00Z", + data: "Error: module not found", + }, + { + source: "stdout", + timestamp: "2024-01-01T00:00:01Z", + data: "Starting application...", + }, + ]; + mockRequest.mockResolvedValue(jsonResponse(logEntries)); + + const client = createClient(); + const { data } = await client.getJobLog(contentId, jobKey); + + expect(data).toEqual(logEntries); + }); + + it("calls the correct URL", async () => { + mockRequest.mockResolvedValue(jsonResponse([])); + + const client = createClient(); + await client.getJobLog(contentId, jobKey); + + const call = mockRequest.mock.calls[0][0]; + expect(call.url).toBe( + `/__api__/v1/content/${contentId}/jobs/${jobKey}/log`, + ); + }); + + it("throws on non-2xx", async () => { + mockRequest.mockResolvedValue(textResponse("not found", 404, "Not Found")); + + const client = createClient(); + await expect(client.getJobLog(contentId, jobKey)).rejects.toThrow(); + }); +}); + // --------------------------------------------------------------------------- // getIntegrations // --------------------------------------------------------------------------- diff --git a/packages/connect-api/src/client.ts b/packages/connect-api/src/client.ts index 2dd141451a..2be89067ea 100644 --- a/packages/connect-api/src/client.ts +++ b/packages/connect-api/src/client.ts @@ -15,6 +15,9 @@ import type { ContentID, DeployOutput, Integration, + JobDTO, + JobLogEntry, + JobLogResponse, PyInfo, QuartoInfo, RInfo, @@ -256,6 +259,30 @@ export class ConnectAPI { }); } + /** Lists jobs for a content item. */ + async getJobs(contentId: ContentID): Promise> { + return this.client.get(`/__api__/v1/content/${contentId}/jobs`); + } + + /** + * Fetches log entries for a specific job. + * The Connect API returns `{ entries: [...] }`, but this method + * unwraps it so callers receive `{ data: JobLogEntry[] }`. + */ + async getJobLog( + contentId: ContentID, + jobKey: string, + ): Promise<{ data: JobLogEntry[] }> { + const response = await this.client.get( + `/__api__/v1/content/${contentId}/jobs/${jobKey}/log`, + ); + // Handle both shapes: `{ entries: [...] }` (Connect API) and plain array (tests / future) + const data = Array.isArray(response.data) + ? response.data + : response.data.entries; + return { data }; + } + /** Retrieves OAuth integrations from the server. */ async getIntegrations(): Promise> { return this.client.get("/__api__/v1/oauth/integrations"); diff --git a/packages/connect-api/src/index.ts b/packages/connect-api/src/index.ts index b7f3bc02b6..457bbec49a 100644 --- a/packages/connect-api/src/index.ts +++ b/packages/connect-api/src/index.ts @@ -2,7 +2,7 @@ export { ConnectAPI } from "./client.js"; -export { ContentID, BundleID, TaskID, UserID, GUID } from "./types.js"; +export { ContentID, BundleID, TaskID, UserID, GUID, JobKey } from "./types.js"; export type { AllSettings, @@ -14,6 +14,9 @@ export type { DeployOutput, EnvVar, Integration, + JobDTO, + JobLogEntry, + JobLogResponse, LicenseStatus, PyInfo, PyInstallation, diff --git a/packages/connect-api/src/types.ts b/packages/connect-api/src/types.ts index 5192a4dccd..bd4c46f49c 100644 --- a/packages/connect-api/src/types.ts +++ b/packages/connect-api/src/types.ts @@ -182,6 +182,41 @@ export interface TaskDTO { last: number; } +// --------------------------------------------------------------------------- +// Job types +// --------------------------------------------------------------------------- + +export type JobKey = string & { readonly __brand: "JobKey" }; +export const JobKey = (key: string) => key as JobKey; + +export interface JobDTO { + id: string; + key: JobKey; + app_id: string; + app_guid: string; + variant_id: string; + status: number; // 0 = success, non-zero = failure + hostname: string; + cluster: string | null; + image: string | null; + tag: string; + exit_code: number; + run_as: string; + queue_time: string | null; + start_time: string; + end_time: string | null; +} + +export interface JobLogEntry { + source: string; + timestamp: string; + data: string; +} + +export interface JobLogResponse { + entries: JobLogEntry[]; +} + // --------------------------------------------------------------------------- // Environment variable type // ---------------------------------------------------------------------------