From d6e41761ab2a61705398c969e516f6e447b7b3d5 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Tue, 24 Mar 2026 15:58:44 -0700 Subject: [PATCH] feat: surface content error logs when deployment validation fails When content validation fails after deployment, fetch the latest failed job's error log from Connect and display the tail 10 lines in the logs tree view. Clicking the header line opens the full log in a new editor. Adds getJobs() and getJobLog() methods to the ConnectAPI client, tracks contentId and serverUrl during publish, and resolves credentials to make direct API calls from the extension. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/package.json | 10 + extensions/vscode/src/constants.ts | 1 + extensions/vscode/src/extension.ts | 17 +- extensions/vscode/src/views/logs.test.ts | 396 +++++++++++++++++++++++ extensions/vscode/src/views/logs.ts | 157 ++++++++- packages/connect-api/src/client.test.ts | 102 ++++++ packages/connect-api/src/client.ts | 27 ++ packages/connect-api/src/index.ts | 5 +- packages/connect-api/src/types.ts | 35 ++ 9 files changed, 745 insertions(+), 5 deletions(-) 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 // ---------------------------------------------------------------------------