diff --git a/.changeset/schedule-list-runs.md b/.changeset/schedule-list-runs.md new file mode 100644 index 00000000..f7bb1b7c --- /dev/null +++ b/.changeset/schedule-list-runs.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Add GET /api/v1/schedules/{schedule_id}/runs to list a schedule's runs (newest `scheduled_for` first), with the same creator-or-admin access as other schedule routes. diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index aeed990c..273b52ef 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1551,6 +1551,20 @@ ], "type": "object" }, + "ListScheduleRunsResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ScheduleRun" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ListSchedulesResponse": { "properties": { "data": { @@ -2914,6 +2928,65 @@ ], "type": "object" }, + "ScheduleRun": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schedule_id": { + "type": "string" + }, + "scheduled_for": { + "format": "date-time", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ScheduleRunStatus" + }, + "triggered_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "schedule_id", + "name", + "scheduled_for", + "status", + "triggered_by", + "triggered_at", + "created_at", + "updated_at" + ], + "type": "object" + }, + "ScheduleRunStatus": { + "enum": [ + "scheduled", + "triggered", + "failed" + ], + "type": "string" + }, "ScheduleStatus": { "default": "active", "enum": [ @@ -5481,7 +5554,7 @@ } } }, - "description": "The schedule was modified concurrently (usually the controller advancing it). Retry." + "description": "The name is already taken for this agent, or the schedule was modified concurrently (retry)." } }, "summary": "Create a schedule", @@ -5531,6 +5604,16 @@ } }, "description": "Unauthenticated." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." } }, "summary": "Delete a schedule", @@ -5569,6 +5652,16 @@ }, "description": "The schedule." }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." + }, "404": { "content": { "application/json": { @@ -5636,6 +5729,16 @@ }, "description": "Invalid cron." }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." + }, "404": { "content": { "application/json": { @@ -5654,7 +5757,7 @@ } } }, - "description": "The schedule was modified concurrently (usually the controller advancing it). Retry." + "description": "The name is already taken for this agent, or the schedule was modified concurrently (retry)." } }, "summary": "Update a schedule", @@ -5667,6 +5770,65 @@ "x-fern-sdk-method-name": "update" } }, + "/api/v1/schedules/{schedule_id}/runs": { + "get": { + "description": "List runs of a schedule, newest `scheduled_for` first. Only the schedule creator (or an admin) may list its runs.", + "parameters": [ + { + "description": "Immutable schedule identifier.", + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "description": "Immutable schedule identifier.", + "maxLength": 64, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListScheduleRunsResponse" + } + } + }, + "description": "Runs of the schedule." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Not found." + } + }, + "summary": "List runs of a schedule", + "tags": [ + "Schedules" + ], + "x-fern-sdk-group-name": [ + "schedules" + ], + "x-fern-sdk-method-name": "list_runs" + } + }, "/api/v1/sessions": { "get": { "description": "List the caller's sessions (newest first by default), token-paginated. Results are scoped to the authenticated identity via the session store's `created_by` filter (not a client query param). Optional `agent_id` filters to sessions bound to that named agent. Pass `page_token` to fetch the next page, keeping the other query params constant.", diff --git a/docs/openapi.json b/docs/openapi.json index aeed990c..273b52ef 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1551,6 +1551,20 @@ ], "type": "object" }, + "ListScheduleRunsResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ScheduleRun" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ListSchedulesResponse": { "properties": { "data": { @@ -2914,6 +2928,65 @@ ], "type": "object" }, + "ScheduleRun": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schedule_id": { + "type": "string" + }, + "scheduled_for": { + "format": "date-time", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ScheduleRunStatus" + }, + "triggered_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "schedule_id", + "name", + "scheduled_for", + "status", + "triggered_by", + "triggered_at", + "created_at", + "updated_at" + ], + "type": "object" + }, + "ScheduleRunStatus": { + "enum": [ + "scheduled", + "triggered", + "failed" + ], + "type": "string" + }, "ScheduleStatus": { "default": "active", "enum": [ @@ -5481,7 +5554,7 @@ } } }, - "description": "The schedule was modified concurrently (usually the controller advancing it). Retry." + "description": "The name is already taken for this agent, or the schedule was modified concurrently (retry)." } }, "summary": "Create a schedule", @@ -5531,6 +5604,16 @@ } }, "description": "Unauthenticated." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." } }, "summary": "Delete a schedule", @@ -5569,6 +5652,16 @@ }, "description": "The schedule." }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." + }, "404": { "content": { "application/json": { @@ -5636,6 +5729,16 @@ }, "description": "Invalid cron." }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." + }, "404": { "content": { "application/json": { @@ -5654,7 +5757,7 @@ } } }, - "description": "The schedule was modified concurrently (usually the controller advancing it). Retry." + "description": "The name is already taken for this agent, or the schedule was modified concurrently (retry)." } }, "summary": "Update a schedule", @@ -5667,6 +5770,65 @@ "x-fern-sdk-method-name": "update" } }, + "/api/v1/schedules/{schedule_id}/runs": { + "get": { + "description": "List runs of a schedule, newest `scheduled_for` first. Only the schedule creator (or an admin) may list its runs.", + "parameters": [ + { + "description": "Immutable schedule identifier.", + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "description": "Immutable schedule identifier.", + "maxLength": 64, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListScheduleRunsResponse" + } + } + }, + "description": "Runs of the schedule." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "The caller is not the schedule creator." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Not found." + } + }, + "summary": "List runs of a schedule", + "tags": [ + "Schedules" + ], + "x-fern-sdk-group-name": [ + "schedules" + ], + "x-fern-sdk-method-name": "list_runs" + } + }, "/api/v1/sessions": { "get": { "description": "List the caller's sessions (newest first by default), token-paginated. Results are scoped to the authenticated identity via the session store's `created_by` filter (not a client query param). Optional `agent_id` filters to sessions bound to that named agent. Pass `page_token` to fetch the next page, keeping the other query params constant.", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index fe2cd928..ef2c9ff7 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -1082,6 +1082,69 @@ await client.schedules.delete("schedule_id"); + + + + +
client.schedules.listRuns(schedule_id) -> TrueForge.ListScheduleRunsResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List runs of a schedule, newest `scheduled_for` first. Only the schedule creator (or an admin) may list its runs. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.schedules.listRuns("schedule_id"); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**schedule_id:** `string` — Immutable schedule identifier. + +
+
+ +
+
+ +**requestOptions:** `SchedulesClient.RequestOptions` + +
+
+
+
+ +
diff --git a/packages/trueforge-sdk/src/api/resources/schedules/client/Client.ts b/packages/trueforge-sdk/src/api/resources/schedules/client/Client.ts index e8ffbebb..420656b6 100644 --- a/packages/trueforge-sdk/src/api/resources/schedules/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/schedules/client/Client.ts @@ -233,6 +233,7 @@ export class SchedulesClient { * @param {string} schedule_id - Immutable schedule identifier. * @param {SchedulesClient.RequestOptions} requestOptions - Request-specific configuration. * + * @throws {@link TrueForge.ForbiddenError} * @throws {@link TrueForge.NotFoundError} * @throws {@link errors.TrueForgeError} * @throws {@link errors.TrueForgeTimeoutError} @@ -287,6 +288,17 @@ export class SchedulesClient { if (_response.error.reason === "status-code") { switch (_response.error.statusCode) { + case 403: + throw new TrueForge.ForbiddenError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); case 404: throw new TrueForge.NotFoundError( serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { @@ -323,6 +335,7 @@ export class SchedulesClient { * @param {SchedulesClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link TrueForge.BadRequestError} + * @throws {@link TrueForge.ForbiddenError} * @throws {@link TrueForge.NotFoundError} * @throws {@link TrueForge.ConflictError} * @throws {@link errors.TrueForgeError} @@ -408,6 +421,17 @@ export class SchedulesClient { }), _response.rawResponse, ); + case 403: + throw new TrueForge.ForbiddenError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); case 404: throw new TrueForge.NotFoundError( serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { @@ -454,6 +478,7 @@ export class SchedulesClient { * @param {SchedulesClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link TrueForge.UnauthorizedError} + * @throws {@link TrueForge.ForbiddenError} * @throws {@link errors.TrueForgeError} * @throws {@link errors.TrueForgeTimeoutError} * @@ -518,6 +543,17 @@ export class SchedulesClient { }), _response.rawResponse, ); + case 403: + throw new TrueForge.ForbiddenError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); default: throw new errors.TrueForgeError({ statusCode: _response.error.statusCode, @@ -534,4 +570,104 @@ export class SchedulesClient { "/api/v1/schedules/{schedule_id}", ); } + + /** + * List runs of a schedule, newest `scheduled_for` first. Only the schedule creator (or an admin) may list its runs. + * + * @param {string} schedule_id - Immutable schedule identifier. + * @param {SchedulesClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.ForbiddenError} + * @throws {@link TrueForge.NotFoundError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.schedules.listRuns("schedule_id") + */ + public listRuns( + schedule_id: string, + requestOptions?: SchedulesClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__listRuns(schedule_id, requestOptions)); + } + + private async __listRuns( + schedule_id: string, + requestOptions?: SchedulesClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `api/v1/schedules/${core.url.encodePathParam(schedule_id)}/runs`, + ), + method: "GET", + headers: _headers, + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: serializers.ListScheduleRunsResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 403: + throw new TrueForge.ForbiddenError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "GET", + "/api/v1/schedules/{schedule_id}/runs", + ); + } } diff --git a/packages/trueforge-sdk/src/api/types/ListScheduleRunsResponse.ts b/packages/trueforge-sdk/src/api/types/ListScheduleRunsResponse.ts new file mode 100644 index 00000000..c38fec21 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/ListScheduleRunsResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface ListScheduleRunsResponse { + data: TrueForge.ScheduleRun[]; +} diff --git a/packages/trueforge-sdk/src/api/types/ScheduleRun.ts b/packages/trueforge-sdk/src/api/types/ScheduleRun.ts new file mode 100644 index 00000000..318a21af --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/ScheduleRun.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface ScheduleRun { + createdAt: Date; + id: string; + name: string; + scheduleId: string; + scheduledFor: Date; + status: TrueForge.ScheduleRunStatus; + triggeredAt: Date | null; + triggeredBy: string; + updatedAt: Date; +} diff --git a/packages/trueforge-sdk/src/api/types/ScheduleRunStatus.ts b/packages/trueforge-sdk/src/api/types/ScheduleRunStatus.ts new file mode 100644 index 00000000..ebf97bad --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/ScheduleRunStatus.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export const ScheduleRunStatus = { + Scheduled: "scheduled", + Triggered: "triggered", + Failed: "failed", +} as const; +export type ScheduleRunStatus = (typeof ScheduleRunStatus)[keyof typeof ScheduleRunStatus]; diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index 0405043d..ee41d22d 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -76,6 +76,7 @@ export * from "./ListAvailableSkillsResponse.js"; export * from "./ListMcpServersResponse.js"; export * from "./ListMcpServerToolsResponse.js"; export * from "./ListModelProvidersResponse.js"; +export * from "./ListScheduleRunsResponse.js"; export * from "./ListSchedulesResponse.js"; export * from "./ListSessionEventsResponse.js"; export * from "./ListSessionsOrder.js"; @@ -135,6 +136,8 @@ export * from "./SandboxCreatedEvent.js"; export * from "./SandboxProviderManifest.js"; export * from "./Schedule.js"; export * from "./ScheduleManifest.js"; +export * from "./ScheduleRun.js"; +export * from "./ScheduleRunStatus.js"; export * from "./ScheduleStatus.js"; export * from "./Session.js"; export * from "./SessionAgent.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/ListScheduleRunsResponse.ts b/packages/trueforge-sdk/src/serialization/types/ListScheduleRunsResponse.ts new file mode 100644 index 00000000..893ebea3 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ListScheduleRunsResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { ScheduleRun } from "./ScheduleRun.js"; + +export const ListScheduleRunsResponse: core.serialization.ObjectSchema< + serializers.ListScheduleRunsResponse.Raw, + TrueForge.ListScheduleRunsResponse +> = core.serialization.object({ + data: core.serialization.list(ScheduleRun), +}); + +export declare namespace ListScheduleRunsResponse { + export interface Raw { + data: ScheduleRun.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/ScheduleRun.ts b/packages/trueforge-sdk/src/serialization/types/ScheduleRun.ts new file mode 100644 index 00000000..267664ec --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ScheduleRun.ts @@ -0,0 +1,33 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { ScheduleRunStatus } from "./ScheduleRunStatus.js"; + +export const ScheduleRun: core.serialization.ObjectSchema = + core.serialization.object({ + createdAt: core.serialization.property("created_at", core.serialization.date()), + id: core.serialization.string(), + name: core.serialization.string(), + scheduleId: core.serialization.property("schedule_id", core.serialization.string()), + scheduledFor: core.serialization.property("scheduled_for", core.serialization.date()), + status: ScheduleRunStatus, + triggeredAt: core.serialization.property("triggered_at", core.serialization.date().nullable()), + triggeredBy: core.serialization.property("triggered_by", core.serialization.string()), + updatedAt: core.serialization.property("updated_at", core.serialization.date()), + }); + +export declare namespace ScheduleRun { + export interface Raw { + created_at: string; + id: string; + name: string; + schedule_id: string; + scheduled_for: string; + status: ScheduleRunStatus.Raw; + triggered_at?: string | null; + triggered_by: string; + updated_at: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/ScheduleRunStatus.ts b/packages/trueforge-sdk/src/serialization/types/ScheduleRunStatus.ts new file mode 100644 index 00000000..4da7aae0 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ScheduleRunStatus.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const ScheduleRunStatus: core.serialization.Schema< + serializers.ScheduleRunStatus.Raw, + TrueForge.ScheduleRunStatus +> = core.serialization.enum_(["scheduled", "triggered", "failed"]); + +export declare namespace ScheduleRunStatus { + export type Raw = "scheduled" | "triggered" | "failed"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index 0405043d..ee41d22d 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -76,6 +76,7 @@ export * from "./ListAvailableSkillsResponse.js"; export * from "./ListMcpServersResponse.js"; export * from "./ListMcpServerToolsResponse.js"; export * from "./ListModelProvidersResponse.js"; +export * from "./ListScheduleRunsResponse.js"; export * from "./ListSchedulesResponse.js"; export * from "./ListSessionEventsResponse.js"; export * from "./ListSessionsOrder.js"; @@ -135,6 +136,8 @@ export * from "./SandboxCreatedEvent.js"; export * from "./SandboxProviderManifest.js"; export * from "./Schedule.js"; export * from "./ScheduleManifest.js"; +export * from "./ScheduleRun.js"; +export * from "./ScheduleRunStatus.js"; export * from "./ScheduleStatus.js"; export * from "./Session.js"; export * from "./SessionAgent.js"; diff --git a/packages/trueforge-sdk/tests/wire/schedules.test.ts b/packages/trueforge-sdk/tests/wire/schedules.test.ts index 63eb1a55..dc549b39 100644 --- a/packages/trueforge-sdk/tests/wire/schedules.test.ts +++ b/packages/trueforge-sdk/tests/wire/schedules.test.ts @@ -211,6 +211,25 @@ describe("SchedulesClient", () => { const rawResponseBody = { error: { message: "message" } }; + server + .mockEndpoint() + .get("/api/v1/schedules/schedule_id") + .respondWith() + .statusCode(403) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.schedules.get("schedule_id"); + }).rejects.toThrow(TrueForgeTypes.ForbiddenError); + }); + + test("get (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + server .mockEndpoint() .get("/api/v1/schedules/schedule_id") @@ -306,6 +325,32 @@ describe("SchedulesClient", () => { const rawRequestBody = { manifest: { cron: "cron", task: "x" }, name: "xy" }; const rawResponseBody = { error: { message: "message" } }; + server + .mockEndpoint() + .put("/api/v1/schedules/schedule_id") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(403) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.schedules.update("schedule_id", { + manifest: { + cron: "cron", + task: "x", + }, + name: "xy", + }); + }).rejects.toThrow(TrueForgeTypes.ForbiddenError); + }); + + test("update (4)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + const rawRequestBody = { manifest: { cron: "cron", task: "x" }, name: "xy" }; + const rawResponseBody = { error: { message: "message" } }; + server .mockEndpoint() .put("/api/v1/schedules/schedule_id") @@ -326,7 +371,7 @@ describe("SchedulesClient", () => { }).rejects.toThrow(TrueForgeTypes.NotFoundError); }); - test("update (4)", async () => { + test("update (5)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); const rawRequestBody = { manifest: { cron: "cron", task: "x" }, name: "xy" }; @@ -388,4 +433,107 @@ describe("SchedulesClient", () => { return await client.schedules.delete("schedule_id"); }).rejects.toThrow(TrueForgeTypes.UnauthorizedError); }); + + test("delete (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .delete("/api/v1/schedules/schedule_id") + .respondWith() + .statusCode(403) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.schedules.delete("schedule_id"); + }).rejects.toThrow(TrueForgeTypes.ForbiddenError); + }); + + test("list_runs (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { + data: [ + { + created_at: "2024-01-15T09:30:00Z", + id: "id", + name: "name", + schedule_id: "schedule_id", + scheduled_for: "2024-01-15T09:30:00Z", + status: "scheduled", + triggered_at: "2024-01-15T09:30:00Z", + triggered_by: "triggered_by", + updated_at: "2024-01-15T09:30:00Z", + }, + ], + }; + + server + .mockEndpoint() + .get("/api/v1/schedules/schedule_id/runs") + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.schedules.listRuns("schedule_id"); + expect(response).toEqual({ + data: [ + { + createdAt: new Date("2024-01-15T09:30:00.000Z"), + id: "id", + name: "name", + scheduleId: "schedule_id", + scheduledFor: new Date("2024-01-15T09:30:00.000Z"), + status: "scheduled", + triggeredAt: new Date("2024-01-15T09:30:00.000Z"), + triggeredBy: "triggered_by", + updatedAt: new Date("2024-01-15T09:30:00.000Z"), + }, + ], + }); + }); + + test("list_runs (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/api/v1/schedules/schedule_id/runs") + .respondWith() + .statusCode(403) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.schedules.listRuns("schedule_id"); + }).rejects.toThrow(TrueForgeTypes.ForbiddenError); + }); + + test("list_runs (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/api/v1/schedules/schedule_id/runs") + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.schedules.listRuns("schedule_id"); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); + }); }); diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index 6b3078f2..028c9c45 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -5,12 +5,19 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import type { Context } from 'hono'; import type { UserContext } from '../auth/identity'; import type { IAgentStore } from '../db/agentStore'; -import { ScheduleRunConflictError, type IScheduleStore, type ScheduleRecord } from '../db/scheduleStore'; +import { + ScheduleNameConflictError, + ScheduleRunConflictError, + type IScheduleStore, + type ScheduleRecord, + type ScheduleRunRecord, +} from '../db/scheduleStore'; import type { WithTransaction } from '../db/transaction'; import { createScheduleRoute, deleteScheduleRoute, getScheduleRoute, + listScheduleRunsRoute, listSchedulesRoute, putScheduleRoute, } from '../routes/scheduleRoutes'; @@ -20,6 +27,7 @@ import { SCHEDULE_MIN_INTERVAL_SECONDS, type Schedule, type ScheduleManifest, + type ScheduleRun, } from '../schemas/schedule'; import { TENANT_ID } from './sessions'; @@ -42,6 +50,20 @@ function toWireSchedule(record: ScheduleRecord): Schedule { }; } +function toWireScheduleRun(record: ScheduleRunRecord): ScheduleRun { + return { + id: record.id, + schedule_id: record.schedule_id, + name: record.name, + scheduled_for: record.scheduled_for, + status: record.status, + triggered_by: record.triggered_by, + triggered_at: record.triggered_at, + created_at: record.created_at, + updated_at: record.updated_at, + }; +} + export function validateManifest(manifest: Pick, from: Date = new Date()): void { // Reject if the cron has no upcoming trigger time (impossible / exhausted calendar). // 5-field cron has no year, so a valid expression always recurs — no one-shot check. @@ -72,16 +94,46 @@ export function validateManifest(manifest: Pick(deps: SchedulesRouterDeps) { const listHandler: RouteHandler = async c => { const { agent_name: agentName } = c.req.valid('query'); + const user = deps.resolveUserContext(c); + // Admins see every schedule; a regular user is scoped to their own via the + // store's `created_by` filter (never a client-supplied param). const records = await deps.scheduleStore.listSchedules({ tenant_id: TENANT_ID, agent_name: agentName, + created_by: user.role === 'admin' ? undefined : user.userRef, }); return c.json({ data: records.map(toWireSchedule) }, 200); }; + const listRunsHandler: RouteHandler = async c => { + const { schedule_id: scheduleId } = c.req.valid('param'); + const schedule = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + if (schedule === undefined) { + return c.json({ error: { message: `Schedule not found: ${scheduleId}` } }, 404); + } + if (!canAccessSchedule(deps.resolveUserContext(c), schedule.created_by)) { + return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); + } + const records = await deps.scheduleStore.listRuns({ tenant_id: TENANT_ID, schedule_id: scheduleId }); + return c.json({ data: records.map(toWireScheduleRun) }, 200); + }; + const createHandler: RouteHandler = async c => { const body = c.req.valid('json'); const user = deps.resolveUserContext(c); @@ -110,6 +162,9 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); - const record = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId, forUpdate: false }); + const record = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); if (record === undefined) { return c.json({ error: { message: `Schedule not found: ${scheduleId}` } }, 404); } + if (!canAccessSchedule(deps.resolveUserContext(c), record.created_by)) { + return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); + } return c.json({ data: toWireSchedule(record) }, 200); }; @@ -135,7 +193,8 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); @@ -143,6 +202,14 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps { @@ -159,6 +226,9 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps(deps: SchedulesRouterDeps = async c => { const { schedule_id: scheduleId } = c.req.valid('param'); + const record = await deps.scheduleStore.getSchedule({ tenant_id: TENANT_ID, id: scheduleId }); + if (record === undefined) { + return c.json({}, 200); + } + if (!canAccessSchedule(deps.resolveUserContext(c), record.created_by)) { + return c.json({ error: { message: FORBIDDEN_SCHEDULE_ACCESS } }, 403); + } await deps.scheduleStore.deleteSchedule({ tenant_id: TENANT_ID, id: scheduleId }); return c.json({}, 200); }; const router = new OpenAPIHono(); router.openapi(listSchedulesRoute, listHandler); + router.openapi(listScheduleRunsRoute, listRunsHandler); router.openapi(createScheduleRoute, createHandler); router.openapi(getScheduleRoute, getHandler); router.openapi(putScheduleRoute, putHandler); diff --git a/packages/trueforge/src/controller/scheduleDispatch.ts b/packages/trueforge/src/controller/scheduleDispatch.ts index b86afd7e..8b80daf9 100644 --- a/packages/trueforge/src/controller/scheduleDispatch.ts +++ b/packages/trueforge/src/controller/scheduleDispatch.ts @@ -4,11 +4,10 @@ import { type IScheduleStore, type ScheduleDispatchItem, type ScheduleRunRecord, - type ScheduleRunStatus, } from '../db/scheduleStore'; import type { WithTransaction } from '../db/transaction'; import { nextTriggerAfter } from '../runtime/cron'; -import { InvalidCronError } from '../schemas/schedule'; +import { InvalidCronError, type ScheduleRunStatus } from '../schemas/schedule'; import type { ControlLoop } from './Controller'; /** @@ -51,7 +50,7 @@ async function finishScheduledRun(params: { }): Promise { const { store, withTransaction, run, status, now } = params; await withTransaction(async txn => { - const latest = await store.getSchedule({ tenant_id: run.tenant_id, id: run.schedule_id, forUpdate: true }, txn); + const latest = await store.getScheduleForUpdate({ tenant_id: run.tenant_id, id: run.schedule_id }, txn); const updated = await store.updateRunStatus({ tenant_id: run.tenant_id, id: run.id, status }, txn); if (updated === undefined) { @@ -155,7 +154,6 @@ export async function dispatchScheduledRuns(params: { const schedule = await store.getSchedule({ tenant_id: run.tenant_id, id: run.schedule_id, - forUpdate: false, }); // Only a deleted schedule stops a row here. `paused` deliberately does NOT: // status decides whether the schedule gains a NEW row, never whether an diff --git a/packages/trueforge/src/db/postgres/migrations/20260828_000001_schedule_name_uq.ts b/packages/trueforge/src/db/postgres/migrations/20260828_000001_schedule_name_uq.ts new file mode 100644 index 00000000..7976f3b6 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260828_000001_schedule_name_uq.ts @@ -0,0 +1,23 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Names a schedule uniquely within its agent: UNIQUE (tenant_id, agent_name, name). + * + * `schedule.name` is a slug-shaped identifier (same `NameSchema` as agents), so it + * behaves like an addressable key rather than a free-text label. Uniqueness is scoped + * per agent — two agents may each own a `daily-report`, one agent may not own two. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await db.schema + .createIndex('schedule_name_uq') + .on('schedule') + .columns(['tenant_id', 'agent_name', 'name']) + .unique() + .execute(); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`DROP INDEX IF EXISTS schedule_name_uq`.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/schedule-store/PostgresScheduleStore.ts b/packages/trueforge/src/db/postgres/schedule-store/PostgresScheduleStore.ts index c28a2aad..6557db7f 100644 --- a/packages/trueforge/src/db/postgres/schedule-store/PostgresScheduleStore.ts +++ b/packages/trueforge/src/db/postgres/schedule-store/PostgresScheduleStore.ts @@ -4,6 +4,7 @@ import { nextTriggerAfter } from '../../../runtime/cron'; import { cronRunName, parseStoredScheduleManifest, + ScheduleNameConflictError, ScheduleRunConflictError, shouldSyncPendingRun, type CreateScheduleInput, @@ -13,6 +14,7 @@ import { type GetScheduledRunForInput, type GetScheduleInput, type IScheduleStore, + type ListRunsInput, type ListScheduledRunsInput, type ListSchedulesInput, type ScheduleRecord, @@ -97,15 +99,26 @@ export class PostgresScheduleStore implements IScheduleStore): Promise { const db = transaction ?? this.#db; - let query = db + const row = await db .selectFrom('schedule') .selectAll() .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id); - if (input.forUpdate === true) { - query = query.forUpdate(); - } - const row = await query.executeTakeFirst(); + .where('id', '=', input.id) + .executeTakeFirst(); + return row === undefined ? undefined : toScheduleRecord(row); + } + + async getScheduleForUpdate( + input: GetScheduleInput, + transaction: Transaction, + ): Promise { + const row = await transaction + .selectFrom('schedule') + .selectAll() + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .forUpdate() + .executeTakeFirst(); return row === undefined ? undefined : toScheduleRecord(row); } @@ -114,22 +127,33 @@ export class PostgresScheduleStore implements IScheduleStore, ): Promise { const db = transaction ?? this.#db; - const row = await db - .insertInto('schedule') - .values({ - id: ulid().toLowerCase(), - tenant_id: input.tenant_id, - agent_name: input.agent_name, - name: input.name, - manifest: json(input.manifest), - // Column mirrors the manifest so the dispatch scan and API reads share one value. - status: input.manifest.status, - created_by: input.created_by, - created_at: now(), - updated_at: now(), - }) - .returningAll() - .executeTakeFirstOrThrow(); + let row; + try { + row = await db + .insertInto('schedule') + .values({ + id: ulid().toLowerCase(), + tenant_id: input.tenant_id, + agent_name: input.agent_name, + name: input.name, + manifest: json(input.manifest), + // Column mirrors the manifest so the dispatch scan and API reads share one value. + status: input.manifest.status, + created_by: input.created_by, + created_at: now(), + updated_at: now(), + }) + .returningAll() + .executeTakeFirstOrThrow(); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ScheduleNameConflictError( + { tenant_id: input.tenant_id, agent_name: input.agent_name, name: input.name }, + { cause: error }, + ); + } + throw error; + } const schedule = toScheduleRecord(row); const pendingRun = await this.#syncPendingRun(schedule, input.runFrom, transaction); return { schedule, pendingRun }; @@ -141,19 +165,33 @@ export class PostgresScheduleStore implements IScheduleStore { // Lock first: this transaction writes the schedule AND its runs, and every such // transaction must take the schedule lock before touching a run row. - const previous = await this.getSchedule({ tenant_id: input.tenant_id, id: input.id, forUpdate: true }, transaction); + const previous = + transaction !== undefined + ? await this.getScheduleForUpdate({ tenant_id: input.tenant_id, id: input.id }, transaction) + : await this.getSchedule({ tenant_id: input.tenant_id, id: input.id }); if (previous === undefined) { return undefined; } const db = transaction ?? this.#db; - const row = await db - .updateTable('schedule') - .set({ name: input.name, manifest: json(input.manifest), status: input.manifest.status, updated_at: now() }) - .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id) - .returningAll() - .executeTakeFirst(); + let row; + try { + row = await db + .updateTable('schedule') + .set({ name: input.name, manifest: json(input.manifest), status: input.manifest.status, updated_at: now() }) + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .returningAll() + .executeTakeFirst(); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ScheduleNameConflictError( + { tenant_id: input.tenant_id, agent_name: previous.agent_name, name: input.name }, + { cause: error }, + ); + } + throw error; + } if (row === undefined) { return undefined; } @@ -180,10 +218,26 @@ export class PostgresScheduleStore implements IScheduleStore): Promise { + const db = transaction ?? this.#db; + const rows = await db + .selectFrom('schedule_run') + .selectAll() + .where('tenant_id', '=', input.tenant_id) + .where('schedule_id', '=', input.schedule_id) + .orderBy('scheduled_for', 'desc') + .orderBy('id') + .execute(); + return rows.map(toRunRecord); + } + async getRun(input: GetRunInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; const row = await db @@ -223,7 +277,7 @@ export class PostgresScheduleStore implements IScheduleStore` or `manual-` — one name per trigger time, which is what makes @@ -37,7 +33,7 @@ export interface ScheduleRecord { tenant_id: string; /** Immutable FK to `agent.name` (with tenant); agent version resolves at run time. */ agent_name: string; - /** Display label; not unique. */ + /** Slug-shaped label, unique per agent (`schedule_name_uq`). */ name: string; manifest: ScheduleManifest; status: ScheduleStatus; @@ -89,13 +85,18 @@ export interface ListSchedulesInput { tenant_id: string; /** When set, only schedules bound to this agent name are returned. */ agent_name?: string | undefined; + created_by?: string | undefined; +} + +/** User-facing run listing, scoped to one schedule. */ +export interface ListRunsInput { + tenant_id: string; + schedule_id: string; } export interface GetScheduleInput { tenant_id: string; id: string; - /** Whether to take a row lock on the schedule (`SELECT ... FOR UPDATE`). */ - forUpdate: boolean | undefined; } export interface CreateScheduleInput { @@ -132,6 +133,7 @@ export interface CreateScheduleRunInput { scheduled_for: Date; triggered_by: string; status: ScheduleRunStatus; + triggered_at?: Date | null; } export interface ListScheduledRunsInput { @@ -163,6 +165,24 @@ export class ScheduleRunConflictError extends Error { } } +/** Schedule name already taken for this agent — violates `schedule_name_uq`. */ +export class ScheduleNameConflictError extends Error { + readonly tenant_id: string; + readonly agent_name: string; + readonly schedule_name: string; + + constructor( + { tenant_id, agent_name, name }: { tenant_id: string; agent_name: string; name: string }, + options?: ErrorOptions, + ) { + super(`Schedule name already exists for agent ${agent_name}: ${name}`, options); + this.name = 'ScheduleNameConflictError'; + this.tenant_id = tenant_id; + this.agent_name = agent_name; + this.schedule_name = name; + } +} + /** * Pending run is replaced only when `status`, `cron`, or `timezone` change. * `name` and `task` edits leave the pending row alone. @@ -181,6 +201,11 @@ export function shouldSyncPendingRun( export interface IScheduleStore { // --- schedule --- getSchedule(input: GetScheduleInput, transaction?: TTransaction): Promise; + /** + * Load one schedule while holding a row lock for the lifetime of `transaction`. + * Postgres: `SELECT … FOR UPDATE`. SQLite: plain read under a write txn. + */ + getScheduleForUpdate(input: GetScheduleInput, transaction: TTransaction): Promise; /** * Inserts a schedule (`status` mirrors `manifest.status`) and syncs the pending run * in the same call: active adds the next run from `runFrom`; paused adds nothing. @@ -222,4 +247,8 @@ export interface IScheduleStore { * Triggered / terminal rows are never returned. */ listScheduledRuns(input: ListScheduledRunsInput, transaction?: TTransaction): Promise; + /** + * Runs of one schedule (any status), newest `scheduled_for` first. + */ + listRuns(input: ListRunsInput, transaction?: TTransaction): Promise; } diff --git a/packages/trueforge/src/db/sqlite/migrations/20260828_000001_schedule_name_uq.ts b/packages/trueforge/src/db/sqlite/migrations/20260828_000001_schedule_name_uq.ts new file mode 100644 index 00000000..6137b48d --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260828_000001_schedule_name_uq.ts @@ -0,0 +1,24 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Names a schedule uniquely within its agent: UNIQUE (tenant_id, agent_name, name). + * Mirrors db/postgres/migrations/20260828_000001_schedule_name_uq.ts. + * + * A plain CREATE INDEX needs no table rebuild, so no `PRAGMA foreign_keys` toggle. + * Kysely's SQLite migrator does not wrap migrations, so schema-touching steps + * run inside `db.transaction()`. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql` + CREATE UNIQUE INDEX schedule_name_uq + ON schedule (tenant_id, agent_name, name) + `.execute(trx); + }); +} + +export async function down(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`DROP INDEX IF EXISTS schedule_name_uq`.execute(trx); + }); +} diff --git a/packages/trueforge/src/db/sqlite/schedule-store/SqliteScheduleStore.ts b/packages/trueforge/src/db/sqlite/schedule-store/SqliteScheduleStore.ts index 5369c4e6..d9958523 100644 --- a/packages/trueforge/src/db/sqlite/schedule-store/SqliteScheduleStore.ts +++ b/packages/trueforge/src/db/sqlite/schedule-store/SqliteScheduleStore.ts @@ -1,10 +1,11 @@ import type { ExpressionBuilder, Kysely, Transaction } from 'kysely'; import { ulid } from 'ulid'; import { nextTriggerAfter } from '../../../runtime/cron'; -import type { ScheduleManifest, ScheduleStatus } from '../../../schemas/schedule'; +import type { ScheduleManifest, ScheduleRunStatus, ScheduleStatus } from '../../../schemas/schedule'; import { cronRunName, parseStoredScheduleManifest, + ScheduleNameConflictError, ScheduleRunConflictError, shouldSyncPendingRun, type CreateScheduleInput, @@ -14,11 +15,11 @@ import { type GetScheduledRunForInput, type GetScheduleInput, type IScheduleStore, + type ListRunsInput, type ListScheduledRunsInput, type ListSchedulesInput, type ScheduleRecord, type ScheduleRunRecord, - type ScheduleRunStatus, type ScheduleWriteResult, type UpdateScheduleInput, type UpdateScheduleRunStatusInput, @@ -95,11 +96,6 @@ export class SqliteScheduleStore implements IScheduleStore this.#db = db; } - /** - * `input.forUpdate` is intentionally ignored: SQLite has no `SELECT ... FOR UPDATE` - * and does not need one — a write transaction holds the whole database, so the - * lock ordering Postgres needs is already guaranteed. - */ async getSchedule(input: GetScheduleInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; const row = await db @@ -111,28 +107,50 @@ export class SqliteScheduleStore implements IScheduleStore return row === undefined ? undefined : toScheduleRecord(row); } + /** + * SQLite has no `SELECT ... FOR UPDATE`. A write transaction still serializes + * the lock-ordering Postgres needs. + */ + async getScheduleForUpdate( + input: GetScheduleInput, + transaction: Transaction, + ): Promise { + return this.getSchedule(input, transaction); + } + async createScheduleAndRun( input: CreateScheduleInput, transaction?: Transaction, ): Promise { const db = transaction ?? this.#db; const timestamp = nowIso(); - const row = await db - .insertInto('schedule') - .values({ - id: ulid().toLowerCase(), - tenant_id: input.tenant_id, - agent_name: input.agent_name, - name: input.name, - manifest: jsonbBind(input.manifest), - // Column mirrors the manifest so the dispatch scan and API reads share one value. - status: input.manifest.status, - created_by: input.created_by, - created_at: timestamp, - updated_at: timestamp, - }) - .returning(scheduleColumns) - .executeTakeFirstOrThrow(); + let row; + try { + row = await db + .insertInto('schedule') + .values({ + id: ulid().toLowerCase(), + tenant_id: input.tenant_id, + agent_name: input.agent_name, + name: input.name, + manifest: jsonbBind(input.manifest), + // Column mirrors the manifest so the dispatch scan and API reads share one value. + status: input.manifest.status, + created_by: input.created_by, + created_at: timestamp, + updated_at: timestamp, + }) + .returning(scheduleColumns) + .executeTakeFirstOrThrow(); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ScheduleNameConflictError( + { tenant_id: input.tenant_id, agent_name: input.agent_name, name: input.name }, + { cause: error }, + ); + } + throw error; + } const schedule = toScheduleRecord(row); const pendingRun = await this.#syncPendingRun(schedule, input.runFrom, transaction); return { schedule, pendingRun }; @@ -146,24 +164,38 @@ export class SqliteScheduleStore implements IScheduleStore // transaction must take the schedule lock before touching a run row (see the // lock-ordering note in `controller/scheduleDispatch.ts`). A no-op here; Postgres // is where it matters. - const previous = await this.getSchedule({ tenant_id: input.tenant_id, id: input.id, forUpdate: true }, transaction); + const previous = + transaction !== undefined + ? await this.getScheduleForUpdate({ tenant_id: input.tenant_id, id: input.id }, transaction) + : await this.getSchedule({ tenant_id: input.tenant_id, id: input.id }); if (previous === undefined) { return undefined; } const db = transaction ?? this.#db; - const row = await db - .updateTable('schedule') - .set({ - name: input.name, - manifest: jsonbBind(input.manifest), - status: input.manifest.status, - updated_at: nowIso(), - }) - .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id) - .returning(scheduleColumns) - .executeTakeFirst(); + let row; + try { + row = await db + .updateTable('schedule') + .set({ + name: input.name, + manifest: jsonbBind(input.manifest), + status: input.manifest.status, + updated_at: nowIso(), + }) + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .returning(scheduleColumns) + .executeTakeFirst(); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ScheduleNameConflictError( + { tenant_id: input.tenant_id, agent_name: previous.agent_name, name: input.name }, + { cause: error }, + ); + } + throw error; + } if (row === undefined) { return undefined; } @@ -224,10 +256,26 @@ export class SqliteScheduleStore implements IScheduleStore if (input.agent_name !== undefined) { query = query.where('agent_name', '=', input.agent_name); } + if (input.created_by !== undefined) { + query = query.where('created_by', '=', input.created_by); + } const rows = await query.orderBy('created_at', 'desc').orderBy('id').execute(); return rows.map(toScheduleRecord); } + async listRuns(input: ListRunsInput, transaction?: Transaction): Promise { + const db = transaction ?? this.#db; + const rows = await db + .selectFrom('schedule_run') + .select(RUN_COLUMNS) + .where('tenant_id', '=', input.tenant_id) + .where('schedule_id', '=', input.schedule_id) + .orderBy('scheduled_for', 'desc') + .orderBy('id') + .execute(); + return rows.map(toRunRecord); + } + async getRun(input: GetRunInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; const row = await db @@ -268,7 +316,7 @@ export class SqliteScheduleStore implements IScheduleStore scheduled_for: input.scheduled_for.toISOString(), status: input.status, triggered_by: input.triggered_by, - triggered_at: null, + triggered_at: input.triggered_at?.toISOString() ?? null, created_at: timestamp, updated_at: timestamp, }) diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 0a3972c4..3237924d 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -25,10 +25,9 @@ import type { ColumnType, Generated, JSONColumnType } from 'kysely'; import type { McpServerManifest } from '../../schemas/mcpServer'; import type { ModelProviderManifest } from '../../schemas/modelProvider'; import type { SandboxBuildMetadata, SandboxBuildStatus, SandboxProviderManifest } from '../../schemas/sandboxProvider'; -import type { ScheduleManifest, ScheduleStatus } from '../../schemas/schedule'; +import type { ScheduleManifest, ScheduleRunStatus, ScheduleStatus } from '../../schemas/schedule'; import type { SkillManifest } from '../../schemas/skill'; import type { OAuthClient, OAuthPendingAuthorizationData, OAuthServer, OAuthToken } from '../mcpServerStore'; -import type { ScheduleRunStatus } from '../scheduleStore'; /** * Trace-level state for one thread at one turn (`turn_thread.checkpoint`). diff --git a/packages/trueforge/src/routes/scheduleRoutes.ts b/packages/trueforge/src/routes/scheduleRoutes.ts index c70fedea..7fa082ec 100644 --- a/packages/trueforge/src/routes/scheduleRoutes.ts +++ b/packages/trueforge/src/routes/scheduleRoutes.ts @@ -9,6 +9,7 @@ import { CreateScheduleRequestSchema, DeleteScheduleResponseSchema, GetScheduleResponseSchema, + ListScheduleRunsResponseSchema, ListSchedulesResponseSchema, UpdateScheduleRequestSchema, } from '../schemas/schedule'; @@ -47,6 +48,34 @@ export const listSchedulesRoute = createRoute({ }, }); +export const listScheduleRunsRoute = createRoute({ + method: 'get', + path: '/{schedule_id}/runs', + tags: [OpenApiTag.SCHEDULES], + summary: 'List runs of a schedule', + description: + 'List runs of a schedule, newest `scheduled_for` first. Only the schedule creator (or an admin) may list its runs.', + 'x-fern-sdk-group-name': ['schedules'], + 'x-fern-sdk-method-name': 'list_runs', + request: { + params: ScheduleIdParamsSchema, + }, + responses: { + 200: { + content: { 'application/json': { schema: ListScheduleRunsResponseSchema } }, + description: 'Runs of the schedule.', + }, + 403: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'The caller is not the schedule creator.', + }, + 404: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Not found.', + }, + }, +}); + export const createScheduleRoute = createRoute({ method: 'post', path: '/', @@ -72,7 +101,7 @@ export const createScheduleRoute = createRoute({ }, 409: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'The schedule was modified concurrently (usually the controller advancing it). Retry.', + description: 'The name is already taken for this agent, or the schedule was modified concurrently (retry).', }, }, }); @@ -93,6 +122,10 @@ export const getScheduleRoute = createRoute({ content: { 'application/json': { schema: GetScheduleResponseSchema } }, description: 'The schedule.', }, + 403: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'The caller is not the schedule creator.', + }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, description: 'Not found.', @@ -124,9 +157,13 @@ export const putScheduleRoute = createRoute({ content: { 'application/json': { schema: RequestErrorResponseSchema } }, description: 'Invalid cron.', }, + 403: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'The caller is not the schedule creator.', + }, 409: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'The schedule was modified concurrently (usually the controller advancing it). Retry.', + description: 'The name is already taken for this agent, or the schedule was modified concurrently (retry).', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -151,6 +188,10 @@ export const deleteScheduleRoute = createRoute({ content: { 'application/json': { schema: DeleteScheduleResponseSchema } }, description: 'Deleted.', }, + 403: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'The caller is not the schedule creator.', + }, 401: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, description: 'Unauthenticated.', diff --git a/packages/trueforge/src/schemas/schedule.ts b/packages/trueforge/src/schemas/schedule.ts index d6523765..b02acd34 100644 --- a/packages/trueforge/src/schemas/schedule.ts +++ b/packages/trueforge/src/schemas/schedule.ts @@ -60,6 +60,11 @@ export const TimezoneSchema = z */ const IsoTimestamp = z.iso.datetime().openapi({ type: 'string', format: 'date-time' }); +const NullableIsoTimestamp = z.iso + .datetime() + .nullable() + .openapi({ type: ['string', 'null'], format: 'date-time' }); + export const ScheduleTaskSchema = z .string() .trim() @@ -117,8 +122,38 @@ export const GetScheduleResponseSchema = z.object({ data: ScheduleSchema }).open export const ListSchedulesResponseSchema = z.object({ data: z.array(ScheduleSchema) }).openapi('ListSchedulesResponse'); export const DeleteScheduleResponseSchema = z.object({}).openapi('DeleteScheduleResponse'); +/** + * Run lifecycle. + * - `scheduled` the one pending run; at most one per schedule, enforced by + * `schedule_run_pending_uq` + * - `triggered` taken by dispatch via `updateRunStatus` + * - `failed` errored, or hand-off to the executor failed + */ +export const ScheduleRunStatusSchema = z.enum(['scheduled', 'triggered', 'failed']).openapi('ScheduleRunStatus'); + +export const ScheduleRunSchema = z + .object({ + id: z.string(), + schedule_id: z.string(), + name: z.string(), + scheduled_for: IsoTimestamp, + status: ScheduleRunStatusSchema, + triggered_by: z.string(), + triggered_at: NullableIsoTimestamp, + created_at: IsoTimestamp, + updated_at: IsoTimestamp, + }) + .strict() + .openapi('ScheduleRun'); + +export const ListScheduleRunsResponseSchema = z + .object({ data: z.array(ScheduleRunSchema) }) + .openapi('ListScheduleRunsResponse'); + export type ScheduleStatus = z.infer; +export type ScheduleRunStatus = z.infer; export type ScheduleManifest = z.infer; export type Schedule = z.infer; +export type ScheduleRun = z.infer; export type CreateScheduleRequest = z.infer; export type UpdateScheduleRequest = z.infer; diff --git a/packages/trueforge/tests/db/scheduleDispatchContractSuite.ts b/packages/trueforge/tests/db/scheduleDispatchContractSuite.ts index b62f3c78..42bed510 100644 --- a/packages/trueforge/tests/db/scheduleDispatchContractSuite.ts +++ b/packages/trueforge/tests/db/scheduleDispatchContractSuite.ts @@ -328,7 +328,7 @@ export function runScheduleDispatchContractSuite(deps: { expect(first).toEqual({ dispatched: 1, failed: 0 }); expect(await store.getRun({ tenant_id: TENANT, id: run.id })).toBeUndefined(); - expect(await store.getSchedule({ tenant_id: TENANT, id: schedule.id, forUpdate: false })).toEqual( + expect(await store.getSchedule({ tenant_id: TENANT, id: schedule.id })).toEqual( expect.objectContaining({ id: schedule.id, status: 'paused' }), ); expect(await store.getScheduledRunFor({ tenant_id: TENANT, schedule_id: schedule.id })).toBeUndefined(); diff --git a/packages/trueforge/tests/db/scheduleStoreContractSuite.ts b/packages/trueforge/tests/db/scheduleStoreContractSuite.ts index 95bf531c..ab67e9ca 100644 --- a/packages/trueforge/tests/db/scheduleStoreContractSuite.ts +++ b/packages/trueforge/tests/db/scheduleStoreContractSuite.ts @@ -1,6 +1,6 @@ import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import type { IAgentStore } from '../../src/db/agentStore'; -import { cronRunName, type IScheduleStore } from '../../src/db/scheduleStore'; +import { cronRunName, ScheduleNameConflictError, type IScheduleStore } from '../../src/db/scheduleStore'; import { nextTriggerAfter } from '../../src/runtime/cron'; import { ScheduleManifestSchema, type ScheduleManifest } from '../../src/schemas/schedule'; @@ -173,6 +173,71 @@ export function runScheduleStoreContractSuite(deps: { expect(await store.getScheduledRunFor({ tenant_id: TENANT, schedule_id: schedule.id })).toEqual(first); }); + it('rejects a second schedule with the same name under one agent', async () => { + const store = deps.getScheduleStore(); + const agent = await seedAgent(); + const create = (name: string) => + store.createScheduleAndRun({ + tenant_id: TENANT, + agent_name: agent.name, + name, + manifest: manifest({ status: 'paused' }), + created_by: USER, + runFrom: new Date(), + }); + + await create('daily-report'); + await expect(create('daily-report')).rejects.toBeInstanceOf(ScheduleNameConflictError); + }); + + it('allows the same schedule name under different agents', async () => { + const store = deps.getScheduleStore(); + const [first, second] = [await seedAgent(), await seedAgent()]; + const create = (agentName: string) => + store.createScheduleAndRun({ + tenant_id: TENANT, + agent_name: agentName, + name: 'daily-report', + manifest: manifest({ status: 'paused' }), + created_by: USER, + runFrom: new Date(), + }); + + await create(first.name); + await expect(create(second.name)).resolves.toBeDefined(); + }); + + it('rejects renaming a schedule onto a name already taken for the agent', async () => { + const store = deps.getScheduleStore(); + const agent = await seedAgent(); + await store.createScheduleAndRun({ + tenant_id: TENANT, + agent_name: agent.name, + name: 'taken', + manifest: manifest({ status: 'paused' }), + created_by: USER, + runFrom: new Date(), + }); + const { schedule: other } = await store.createScheduleAndRun({ + tenant_id: TENANT, + agent_name: agent.name, + name: 'free', + manifest: manifest({ status: 'paused' }), + created_by: USER, + runFrom: new Date(), + }); + + await expect( + store.updateScheduleAndRun({ + tenant_id: TENANT, + id: other.id, + name: 'taken', + manifest: manifest({ status: 'paused' }), + runFrom: new Date(), + }), + ).rejects.toBeInstanceOf(ScheduleNameConflictError); + }); + it('updating cron while paused leaves no pending run', async () => { const store = deps.getScheduleStore(); const agent = await seedAgent(); @@ -298,7 +363,7 @@ export function runScheduleStoreContractSuite(deps: { await store.deleteSchedule({ tenant_id: TENANT, id: schedule.id }); - expect(await store.getSchedule({ tenant_id: TENANT, id: schedule.id, forUpdate: false })).toBeUndefined(); + expect(await store.getSchedule({ tenant_id: TENANT, id: schedule.id })).toBeUndefined(); expect(await store.getRun({ tenant_id: TENANT, id: historical.id })).toBeUndefined(); expect(await store.getRun({ tenant_id: TENANT, id: pending.id })).toBeUndefined(); expect(await store.getScheduledRunFor({ tenant_id: TENANT, schedule_id: schedule.id })).toBeUndefined(); @@ -333,7 +398,7 @@ export function runScheduleStoreContractSuite(deps: { await deps.getAgentStore().deleteAgent({ tenant_id: TENANT, id: agent.id }); - expect(await store.getSchedule({ tenant_id: TENANT, id: schedule.id, forUpdate: false })).toBeUndefined(); + expect(await store.getSchedule({ tenant_id: TENANT, id: schedule.id })).toBeUndefined(); expect(await store.getScheduledRunFor({ tenant_id: TENANT, schedule_id: schedule.id })).toBeUndefined(); if (pendingRun === undefined) { throw new Error('expected pending run before agent delete'); @@ -462,6 +527,63 @@ export function runScheduleStoreContractSuite(deps: { expect(indexNewer).toBeLessThan(indexOlder); }); + it('listRuns returns newest scheduled_for first and filters by schedule_id', async () => { + const store = deps.getScheduleStore(); + const agentA = await seedAgent(); + const agentB = await seedAgent(); + const olderSlot = new Date('2026-08-27T10:00:00.000Z'); + const newerSlot = new Date('2026-08-27T12:00:00.000Z'); + + const { schedule: scheduleA } = await store.createScheduleAndRun({ + tenant_id: TENANT, + agent_name: agentA.name, + name: 'runs-a', + manifest: manifest({ status: 'paused' }), + created_by: USER, + runFrom: new Date(), + }); + const older = await store.createRun({ + tenant_id: TENANT, + schedule_id: scheduleA.id, + name: cronRunName(olderSlot), + scheduled_for: olderSlot, + status: 'triggered', + triggered_by: USER, + }); + const newer = await store.createRun({ + tenant_id: TENANT, + schedule_id: scheduleA.id, + name: cronRunName(newerSlot), + scheduled_for: newerSlot, + status: 'scheduled', + triggered_by: USER, + }); + + const { schedule: scheduleB } = await store.createScheduleAndRun({ + tenant_id: TENANT, + agent_name: agentB.name, + name: 'runs-b', + manifest: manifest({ status: 'paused' }), + created_by: USER, + runFrom: new Date(), + }); + const otherScheduleRun = await store.createRun({ + tenant_id: TENANT, + schedule_id: scheduleB.id, + name: cronRunName(newerSlot), + scheduled_for: newerSlot, + status: 'scheduled', + triggered_by: USER, + }); + + const forA = await store.listRuns({ tenant_id: TENANT, schedule_id: scheduleA.id }); + expect(forA.map(row => row.id)).toEqual([newer.id, older.id]); + expect(forA.every(row => row.schedule_id === scheduleA.id)).toBe(true); + + const forB = await store.listRuns({ tenant_id: TENANT, schedule_id: scheduleB.id }); + expect(forB.map(row => row.id)).toEqual([otherScheduleRun.id]); + }); + it('updateRunStatus stamps triggered_at only for triggered; returns undefined when gone', async () => { const store = deps.getScheduleStore(); const agent = await seedAgent(); diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts new file mode 100644 index 00000000..efe2c9de --- /dev/null +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -0,0 +1,153 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; +import { createSchedulesRouter } from '../../../src/apis/schedules'; +import { TENANT_ID } from '../../../src/apis/sessions'; +import type { UserContext } from '../../../src/auth/identity'; +import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; +import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; +import { createSqliteDb } from '../../../src/db/sqlite/client'; +import { SqliteScheduleStore } from '../../../src/db/sqlite/schedule-store/SqliteScheduleStore'; +import { ListScheduleRunsResponseSchema, ListSchedulesResponseSchema } from '../../../src/schemas/schedule'; + +const ALICE: UserContext = { userRef: 'alice', role: 'user' }; +const BOB: UserContext = { userRef: 'bob', role: 'user' }; +const ADMIN: UserContext = { userRef: 'root', role: 'admin' }; + +const scheduleBody = { + agent_name: 'reporter', + name: 'daily-report', + manifest: { task: 'Say hi', cron: '0 13 * * *', timezone: 'UTC' }, +}; + +async function setup() { + const db = createSqliteDb(':memory:'); + await migrateSqliteToLatest(db); + const agentStore = new SqliteAgentStore(db); + const scheduleStore = new SqliteScheduleStore(db); + await agentStore.createAgent({ + tenant_id: TENANT_ID, + name: 'reporter', + manifest: AgentSpecSchema.parse({ model: { name: 'test-provider/test-model' }, instructions: 'test' }), + }); + + let current: UserContext = ALICE; + const app = new OpenAPIHono(); + app.route( + '/', + createSchedulesRouter({ + scheduleStore, + agentStore, + withTransaction: callback => db.transaction().execute(callback), + resolveUserContext: () => current, + }), + ); + + const asUser = (user: UserContext) => { + current = user; + }; + const postJson = (path: string, method: string, body: unknown) => + app.request(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + + return { app, asUser, postJson, agentStore }; +} + +describe('schedule RBAC — creator-scoped, admin sees all', () => { + it("hides another user's schedule from get, update, delete, and list", async () => { + const { app, asUser, postJson } = await setup(); + + asUser(ALICE); + const created = await postJson('/', 'POST', scheduleBody); + expect(created.status).toBe(201); + const { id } = ((await created.json()) as { data: { id: string } }).data; + + asUser(BOB); + expect((await app.request(`/${id}`)).status).toBe(403); + expect((await postJson(`/${id}`, 'PUT', { name: 'renamed', manifest: scheduleBody.manifest })).status).toBe(403); + expect((await app.request(`/${id}`, { method: 'DELETE' })).status).toBe(403); + + const bobList = await app.request('/'); + expect(bobList.status).toBe(200); + expect(ListSchedulesResponseSchema.parse(await bobList.json()).data).toEqual([]); + + expect((await app.request(`/${id}/runs`)).status).toBe(403); + }); + + it('lets the creator see and manage their own schedule', async () => { + const { app, asUser, postJson } = await setup(); + + asUser(ALICE); + const created = await postJson('/', 'POST', scheduleBody); + const { id } = ((await created.json()) as { data: { id: string } }).data; + + expect((await app.request(`/${id}`)).status).toBe(200); + const aliceList = await app.request('/'); + expect(ListSchedulesResponseSchema.parse(await aliceList.json()).data).toHaveLength(1); + const aliceRuns = await app.request(`/${id}/runs`); + expect(aliceRuns.status).toBe(200); + expect(ListScheduleRunsResponseSchema.parse(await aliceRuns.json()).data).toEqual([ + expect.objectContaining({ schedule_id: id }), + ]); + expect((await app.request(`/${id}`, { method: 'DELETE' })).status).toBe(200); + }); + + it('does not leak existence: a missing schedule is 404, not 403', async () => { + const { app, asUser } = await setup(); + asUser(BOB); + expect((await app.request('/01jqzz000000000000000nope')).status).toBe(404); + expect((await app.request('/01jqzz000000000000000nope/runs')).status).toBe(404); + }); + + it("lets an admin see and manage any user's schedule", async () => { + const { app, asUser, postJson } = await setup(); + + asUser(ALICE); + const created = await postJson('/', 'POST', scheduleBody); + const { id } = ((await created.json()) as { data: { id: string } }).data; + + asUser(ADMIN); + expect((await app.request(`/${id}`)).status).toBe(200); + + const adminList = await app.request('/'); + expect(ListSchedulesResponseSchema.parse(await adminList.json()).data).toHaveLength(1); + const adminRuns = await app.request(`/${id}/runs`); + expect(ListScheduleRunsResponseSchema.parse(await adminRuns.json()).data).toHaveLength(1); + + const renamed = await postJson(`/${id}`, 'PUT', { name: 'admin-renamed', manifest: scheduleBody.manifest }); + expect(renamed.status).toBe(200); + expect((await app.request(`/${id}`, { method: 'DELETE' })).status).toBe(200); + }); + + it('shows an admin schedules across multiple creators in list', async () => { + const { app, asUser, agentStore, postJson } = await setup(); + // A second agent so both schedules can share the same name without colliding. + await agentStore.createAgent({ + tenant_id: TENANT_ID, + name: 'reporter-two', + manifest: AgentSpecSchema.parse({ model: { name: 'test-provider/test-model' }, instructions: 'test' }), + }); + + asUser(ALICE); + const aliceCreated = await postJson('/', 'POST', scheduleBody); + const aliceId = ((await aliceCreated.json()) as { data: { id: string } }).data.id; + asUser(BOB); + const bobCreated = await postJson('/', 'POST', { ...scheduleBody, agent_name: 'reporter-two' }); + const bobId = ((await bobCreated.json()) as { data: { id: string } }).data.id; + + asUser(ADMIN); + const adminList = await app.request('/'); + expect(ListSchedulesResponseSchema.parse(await adminList.json()).data).toHaveLength(2); + // An admin reaches the runs of a schedule created by anyone. + expect(ListScheduleRunsResponseSchema.parse(await (await app.request(`/${bobId}/runs`)).json()).data).toEqual([ + expect.objectContaining({ schedule_id: bobId }), + ]); + + // A regular user still sees only their own. + asUser(BOB); + const bobList = await app.request('/'); + expect(ListSchedulesResponseSchema.parse(await bobList.json()).data).toHaveLength(1); + expect(ListScheduleRunsResponseSchema.parse(await (await app.request(`/${bobId}/runs`)).json()).data).toHaveLength( + 1, + ); + expect((await app.request(`/${aliceId}/runs`)).status).toBe(403); + }); +});