From 07900050d2be0a2dbe88873f34929ece2800e024 Mon Sep 17 00:00:00 2001 From: dada-sudo Date: Thu, 12 Nov 2026 12:33:26 +0100 Subject: [PATCH] feat(sdk): read the log of a build A failed build carries a one-line reason; its log says the rest. The API already serves it at GET /api/cloud/builds/{buildId}/logs, under the same contract as a service's logs: server-sent log events with a cursor, a tail of the newest lines when the read does not follow. - spec: streamBuildLogs, in its own part file (builds.yaml) - CloudApi.buildLogs(buildId, { follow, limit, lastEventId, onOpen }) - the request shapes move to logs.ts; a service's log read is unchanged Co-Authored-By: Claude --- sdk/openapi/builds.yaml | 54 +++++++++++ sdk/openapi/cloud.openapi.yaml | 1 + sdk/src/api.ts | 39 +++++--- sdk/src/generated/cloud-api.ts | 85 +++++++++++++++++ sdk/src/index.ts | 1 + sdk/src/logs.ts | 39 ++++++++ sdk/test/builds.test.ts | 163 +++++++++++++++++++++++++++++++++ 7 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 sdk/openapi/builds.yaml create mode 100644 sdk/src/logs.ts create mode 100644 sdk/test/builds.test.ts diff --git a/sdk/openapi/builds.yaml b/sdk/openapi/builds.yaml new file mode 100644 index 0000000..6dd7d8f --- /dev/null +++ b/sdk/openapi/builds.yaml @@ -0,0 +1,54 @@ +paths: + logs: + get: + operationId: streamBuildLogs + description: | + The output of one build as server-sent events, for a member who may observe the + build's project. Same contract as a service's logs. + + Each line is a `log` event: its data is `{ at, level, message, instance }` and its id + is the cursor to resume from. Without `follow` the read is a tail: the newest lines, + oldest first, then an `end` event. A read from the start opens with one line naming + how the build runs (`Build recipe: ...`), which has no cursor and does not count + towards `limit`. An `error` event carrying a `code` ends a stream the platform could + not serve. + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: buildId + required: true + schema: { type: string } + - in: query + name: follow + description: Keeps the stream open and delivers the lines the build writes next. + schema: { type: boolean } + - in: query + name: limit + description: The most lines the read returns before it ends. A tail holds 500 at most. + schema: { type: integer, minimum: 1 } + - in: header + name: Last-Event-ID + description: The id of the last event received. The read resumes after it, oldest first. + schema: { type: string } + responses: + '200': + description: Server-sent log events + content: + text/event-stream: + schema: { type: string } + '400': + description: A parameter the API cannot read, such as a `limit` that is not a positive integer. + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '404': + description: '`cloud.errors.builds.not_found`: no such build, a build of another workspace or of a project the member may not observe, or one whose service was removed.' + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + '429': + description: The member or the workspace already holds every live follow it may open. `Retry-After` says how long to wait. A read without `follow` is never refused this way. + content: + application/json: + schema: { $ref: './cloud.openapi.yaml#/components/schemas/Error' } + default: { $ref: './cloud.openapi.yaml#/components/responses/Error' } diff --git a/sdk/openapi/cloud.openapi.yaml b/sdk/openapi/cloud.openapi.yaml index 997c159..690b0cc 100644 --- a/sdk/openapi/cloud.openapi.yaml +++ b/sdk/openapi/cloud.openapi.yaml @@ -474,6 +474,7 @@ paths: text/event-stream: schema: { type: string } default: { $ref: '#/components/responses/Error' } + /api/cloud/builds/{buildId}/logs: { $ref: 'builds.yaml#/paths/logs' } /api/cloud/deployments/{deploymentId}: get: operationId: readDeployment diff --git a/sdk/src/api.ts b/sdk/src/api.ts index 7ffc789..3e034d8 100644 --- a/sdk/src/api.ts +++ b/sdk/src/api.ts @@ -4,6 +4,7 @@ import { ProjectDeploymentsApi } from "./deployments.js"; import { EnvironmentsApi, ProjectRepositoryApi } from "./environments.js"; import type { components, paths } from "./generated/cloud-api.js"; import { isRecord, isString } from "./guards.js"; +import { type BuildLogRequest, type LogRequest, logReadOf } from "./logs.js"; import { acceptedOf, dataOf, isApiErrorBody, refusal } from "./result.js"; import { parseSse, type SseEvent } from "./sse.js"; @@ -60,14 +61,6 @@ export interface PublishedValue { publishedKey: string; } -/** How a log read is shaped: one bounded page, or a tail from a cursor. */ -export interface LogRequest { - follow: boolean; - lastEventId?: string; - /** Called once the API accepted the read, before its first frame. */ - onOpen?: () => void; -} - const SOURCE_MIME_TYPE = "application/x-tar"; const SOURCE_FILENAME = "source.tar"; @@ -383,14 +376,30 @@ export class CloudApi { } logs(serviceId: string, request: LogRequest): AsyncGenerator { - const accept = { Accept: "text/event-stream" }; - const headers = request.lastEventId - ? { ...accept, "Last-Event-ID": request.lastEventId } - : accept; - const query = request.follow ? "?follow=true" : ""; + const { follow, lastEventId } = request; + const read = logReadOf({ follow, lastEventId }); return this.stream( - `/api/cloud/services/${encodeURIComponent(serviceId)}/logs${query}`, - headers, + `/api/cloud/services/${encodeURIComponent(serviceId)}/logs${read.query}`, + read.headers, + undefined, + request.onOpen, + ); + } + + /** + * The output of one build, as the `log` events of its stream, then `end`. + * Without `follow` it is the newest lines, oldest first. A build that is + * unknown, of another workspace, or whose service was removed answers 404 + * `cloud.errors.builds.not_found`. + */ + buildLogs( + buildId: string, + request: BuildLogRequest, + ): AsyncGenerator { + const read = logReadOf(request); + return this.stream( + `/api/cloud/builds/${encodeURIComponent(buildId)}/logs${read.query}`, + read.headers, undefined, request.onOpen, ); diff --git a/sdk/src/generated/cloud-api.ts b/sdk/src/generated/cloud-api.ts index 31eb1ed..faaf0bb 100644 --- a/sdk/src/generated/cloud-api.ts +++ b/sdk/src/generated/cloud-api.ts @@ -486,6 +486,33 @@ export interface paths { patch?: never; trace?: never; }; + "/api/cloud/builds/{buildId}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description The output of one build as server-sent events, for a member who may observe the + * build's project. Same contract as a service's logs. + * + * Each line is a `log` event: its data is `{ at, level, message, instance }` and its id + * is the cursor to resume from. Without `follow` the read is a tail: the newest lines, + * oldest first, then an `end` event. A read from the start opens with one line naming + * how the build runs (`Build recipe: ...`), which has no cursor and does not count + * towards `limit`. An `error` event carrying a `code` ends a stream the platform could + * not serve. + */ + get: operations["streamBuildLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cloud/deployments/{deploymentId}": { parameters: { query?: never; @@ -1932,6 +1959,64 @@ export interface operations { default: components["responses"]["Error"]; }; }; + streamBuildLogs: { + parameters: { + query?: { + /** @description Keeps the stream open and delivers the lines the build writes next. */ + follow?: boolean; + /** @description The most lines the read returns before it ends. A tail holds 500 at most. */ + limit?: number; + }; + header?: { + /** @description The id of the last event received. The read resumes after it, oldest first. */ + "Last-Event-ID"?: string; + }; + path: { + buildId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server-sent log events */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + /** @description A parameter the API cannot read, such as a `limit` that is not a positive integer. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description `cloud.errors.builds.not_found`: no such build, a build of another workspace or of a project the member may not observe, or one whose service was removed. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The member or the workspace already holds every live follow it may open. `Retry-After` says how long to wait. A read without `follow` is never refused this way. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; readDeployment: { parameters: { query?: never; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index a0bf278..c665d21 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -3,5 +3,6 @@ export * from "./api.js"; export * from "./deployments.js"; export * from "./environments.js"; export type { components, operations, paths } from "./generated/cloud-api.js"; +export type { BuildLogRequest, LogRequest } from "./logs.js"; export type * from "./resources.js"; export * from "./sse.js"; diff --git a/sdk/src/logs.ts b/sdk/src/logs.ts new file mode 100644 index 0000000..a74ac70 --- /dev/null +++ b/sdk/src/logs.ts @@ -0,0 +1,39 @@ +/** How a log read is shaped: one bounded page, or a tail from a cursor. */ +export interface LogRequest { + follow: boolean; + lastEventId?: string; + /** Called once the API accepted the read, before its first frame. */ + onOpen?: () => void; +} + +/** How one build's log is read. */ +export interface BuildLogRequest extends LogRequest { + /** + * The most lines the read returns before it ends. Without `follow` and + * without `lastEventId` the read is a tail, the newest lines oldest first, + * and the API returns 500 at most. + */ + limit?: number; +} + +/** A log read as it goes on the wire. */ +interface LogRead { + /** The query string, with its leading `?`, or empty. */ + query: string; + headers: Record; +} + +/** The query string and headers a log read is sent with. */ +export function logReadOf(request: BuildLogRequest): LogRead { + const query = new URLSearchParams(); + if (request.follow) query.set("follow", "true"); + if (request.limit !== undefined) query.set("limit", String(request.limit)); + const text = query.toString(); + const accept = { Accept: "text/event-stream" }; + return { + query: text === "" ? "" : `?${text}`, + headers: request.lastEventId + ? { ...accept, "Last-Event-ID": request.lastEventId } + : accept, + }; +} diff --git a/sdk/test/builds.test.ts b/sdk/test/builds.test.ts new file mode 100644 index 0000000..a2b0572 --- /dev/null +++ b/sdk/test/builds.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { CloudApi, CloudApiError, type SseEvent } from "../src/index.js"; + +interface Sent { + url: string; + method: string; + authorization: string | null; + accept: string | null; + lastEventId: string | null; +} + +/** A `fetch` that records each request and answers every one with `answer`. */ +function recorder(answer: () => Response) { + const sent: Sent[] = []; + const send: typeof fetch = async (input, init) => { + const request = new Request(input, init); + sent.push({ + url: request.url, + method: request.method, + authorization: request.headers.get("authorization"), + accept: request.headers.get("accept"), + lastEventId: request.headers.get("last-event-id"), + }); + return answer(); + }; + return { sent, send }; +} + +function eventStream(frames: readonly string[]): Response { + return new Response(frames.map((frame) => `${frame}\n\n`).join(""), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +async function eventsOf(stream: AsyncGenerator): Promise { + const events: SseEvent[] = []; + for await (const event of stream) events.push(event); + return events; +} + +const BUILD = "https://api.antelopejs.cloud/api/cloud/builds/b%2F1/logs"; +const RECIPE = { + at: "2026-01-02T03:04:05.000Z", + level: "info", + message: "Build recipe: AntelopeJS", + instance: null, +}; +const FAILURE = { + at: "2026-01-02T03:05:00.000Z", + level: "error", + message: "ERR_PNPM_FETCH_404", + instance: "build-1", +}; + +describe("the log of a build", () => { + test("a bounded read asks the encoded build for its newest lines and yields them in order, then the end", async () => { + const { sent, send } = recorder(() => + eventStream([ + ": open", + `id: \nevent: log\ndata: ${JSON.stringify(RECIPE)}`, + `id: c-2\nevent: log\ndata: ${JSON.stringify(FAILURE)}`, + "event: end\ndata: {}", + ]), + ); + const api = new CloudApi({ token: "t-1", fetch: send }); + + const events = await eventsOf( + api.buildLogs("b/1", { follow: false, limit: 200 }), + ); + + assert.deepEqual(sent, [ + { + url: `${BUILD}?limit=200`, + method: "GET", + authorization: "Bearer t-1", + accept: "text/event-stream", + lastEventId: null, + }, + ]); + assert.deepEqual(events, [ + { event: "log", data: RECIPE, id: "" }, + { event: "log", data: FAILURE, id: "c-2" }, + { event: "end", data: {}, id: undefined }, + ]); + }); + + test("a follow resumes after the last event received, with no limit unless one is given", async () => { + const { sent, send } = recorder(() => eventStream([])); + const api = new CloudApi({ token: "t", fetch: send }); + + await api.buildLogs("b/1", { follow: true, lastEventId: "c-2" }).next(); + await api.buildLogs("b/1", { follow: true, limit: 50 }).next(); + await api.buildLogs("b/1", { follow: false }).next(); + + assert.deepEqual( + sent.map((request) => `${request.url} ${request.lastEventId}`), + [ + `${BUILD}?follow=true c-2`, + `${BUILD}?follow=true&limit=50 null`, + `${BUILD} null`, + ], + ); + }); + + test("the caller hears that the read was accepted before its first line", async () => { + const { send } = recorder(() => + eventStream([`id: c-2\nevent: log\ndata: ${JSON.stringify(FAILURE)}`]), + ); + const api = new CloudApi({ token: "t", fetch: send }); + const order: string[] = []; + + const stream = api.buildLogs("b/1", { + follow: false, + onOpen: () => order.push("open"), + }); + for await (const event of stream) order.push(event.event); + + assert.deepEqual(order, ["open", "log"]); + }); + + test("a build the member cannot read is a CloudApiError carrying the code, and nothing is yielded", async () => { + const { send } = recorder( + () => + new Response(JSON.stringify("cloud.errors.builds.not_found"), { + status: 404, + headers: { "content-type": "application/json" }, + }), + ); + const api = new CloudApi({ token: "t", fetch: send }); + let opened = false; + + await assert.rejects( + api + .buildLogs("b/1", { follow: false, onOpen: () => (opened = true) }) + .next(), + (error) => { + assert.ok(error instanceof CloudApiError); + assert.equal(error.status, 404); + assert.equal(error.code, "cloud.errors.builds.not_found"); + return true; + }, + ); + assert.equal(opened, false); + }); + + test("a service's log read is sent as before: follow only, never a limit", async () => { + const { sent, send } = recorder(() => eventStream([])); + const api = new CloudApi({ token: "t", fetch: send }); + + await api.logs("s/1", { follow: true, lastEventId: "c-9" }).next(); + await api.logs("s/1", { follow: false }).next(); + + assert.deepEqual( + sent.map((request) => `${request.url} ${request.lastEventId}`), + [ + "https://api.antelopejs.cloud/api/cloud/services/s%2F1/logs?follow=true c-9", + "https://api.antelopejs.cloud/api/cloud/services/s%2F1/logs null", + ], + ); + }); +});