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);
+ });
+});