Skip to content

Commit ca28c23

Browse files
abhiaiyer91Mastra Code (anthropic/claude-opus-4-7)
andauthored
feat(schedules): extend schema for ownership and richer trigger audit (#16166)
Co-authored-by: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
1 parent 2278554 commit ca28c23

22 files changed

Lines changed: 373 additions & 52 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@mastra/core': minor
3+
'@mastra/libsql': minor
4+
'@mastra/pg': minor
5+
'@mastra/mongodb': minor
6+
'@mastra/server': minor
7+
'@mastra/client-js': minor
8+
'mastra': patch
9+
---
10+
11+
Extend the schedules storage schema to support owned schedules and richer trigger audit. This is a breaking schema change to `mastra_schedules` and `mastra_schedule_triggers`; scheduled workflows are still in alpha so no compat shim is provided.
12+
13+
- `Schedule` gains optional `ownerType` / `ownerId` so a schedule row can be attributed to an owning subsystem (e.g. an agent that owns a heartbeat schedule). Workflow schedules leave both fields unset.
14+
- `ScheduleTrigger.status` is renamed to `outcome` and the type is widened to `ScheduleTriggerOutcome` so future outcome values can be added without another rename.
15+
- `ScheduleTrigger` gains a stable `id` primary key and new `triggerKind`, `parentTriggerId`, and `metadata` fields. `triggerKind` distinguishes `schedule-fire` rows from later `queue-drain` rows (used by upcoming heartbeat work); `parentTriggerId` links related rows; `metadata` carries outcome-specific context.
16+
- The libsql, pg, and mongodb adapters all add the new columns/indexes. Their `@mastra/core` peer dependency is tightened to `>=1.32.0-0 <2.0.0-0` so installing a new storage adapter against an older core (or vice-versa) surfaces a peer-dependency warning at install time instead of silently writing/reading the wrong field.
17+
- Scheduler producer, server schemas/handler, and client SDK types are updated to use the new fields. The `triggers` response on `GET /api/schedules/:id/triggers` now returns `outcome` instead of `status`.
18+
- The bundled Studio (Mastra CLI) is updated to read `outcome` so the schedule detail page keeps polling and rendering publish-failure rows correctly.

client-sdks/client-js/src/route-types.generated.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74324,6 +74324,8 @@ export interface GetBackgroundTasksBackgroundTaskId_RouteContract {
7432474324
export type GetSchedules_QueryParams = {
7432574325
workflowId?: string | undefined;
7432674326
status?: ('active' | 'paused') | undefined;
74327+
ownerType?: string | undefined;
74328+
ownerId?: string | undefined;
7432774329
};
7432874330

7432974331
export type GetSchedules_Response = {
@@ -74370,6 +74372,8 @@ export type GetSchedules_Response = {
7437074372
[key: string]: unknown;
7437174373
}
7437274374
| undefined;
74375+
ownerType?: string | undefined;
74376+
ownerId?: string | undefined;
7437374377
createdAt: number;
7437474378
updatedAt: number;
7437574379
}[];
@@ -74444,6 +74448,8 @@ export type GetSchedulesScheduleId_Response = {
7444474448
[key: string]: unknown;
7444574449
}
7444674450
| undefined;
74451+
ownerType?: string | undefined;
74452+
ownerId?: string | undefined;
7444774453
createdAt: number;
7444874454
updatedAt: number;
7444974455
};
@@ -74478,12 +74484,30 @@ export type GetSchedulesScheduleIdTriggers_QueryParams = {
7447874484

7447974485
export type GetSchedulesScheduleIdTriggers_Response = {
7448074486
triggers: {
74487+
id?: string | undefined;
7448174488
scheduleId: string;
74482-
runId: string;
74489+
runId: string | null;
7448374490
scheduledFireAt: number;
7448474491
actualFireAt: number;
74485-
status: 'published' | 'failed';
74492+
outcome:
74493+
| 'published'
74494+
| 'failed'
74495+
| 'skipped'
74496+
| 'acked'
74497+
| 'alerted'
74498+
| 'deferred'
74499+
| 'appended-from-queue'
74500+
| 'dropped-stale'
74501+
| 'dropped-superseded'
74502+
| 'dropped-busy';
7448674503
error?: string | undefined;
74504+
triggerKind?: ('schedule-fire' | 'queue-drain') | undefined;
74505+
parentTriggerId?: string | undefined;
74506+
metadata?:
74507+
| {
74508+
[key: string]: unknown;
74509+
}
74510+
| undefined;
7448774511
run?:
7448874512
| {
7448974513
status:
@@ -74577,6 +74601,8 @@ export type PostSchedulesScheduleIdPause_Response = {
7457774601
[key: string]: unknown;
7457874602
}
7457974603
| undefined;
74604+
ownerType?: string | undefined;
74605+
ownerId?: string | undefined;
7458074606
createdAt: number;
7458174607
updatedAt: number;
7458274608
};
@@ -74646,6 +74672,8 @@ export type PostSchedulesScheduleIdResume_Response = {
7464674672
[key: string]: unknown;
7464774673
}
7464874674
| undefined;
74675+
ownerType?: string | undefined;
74676+
ownerId?: string | undefined;
7464974677
createdAt: number;
7465074678
updatedAt: number;
7465174679
};

client-sdks/client-js/src/types.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,26 +2554,44 @@ export interface ScheduleResponse {
25542554
lastRunId?: string;
25552555
lastRun?: ScheduleRunSummary;
25562556
metadata?: Record<string, unknown>;
2557+
ownerType?: string;
2558+
ownerId?: string;
25572559
createdAt: number;
25582560
updatedAt: number;
25592561
}
25602562

2561-
export type ScheduleTriggerStatus = 'published' | 'failed';
2563+
export type ScheduleTriggerOutcome =
2564+
| 'published'
2565+
| 'failed'
2566+
| 'skipped'
2567+
| 'acked'
2568+
| 'alerted'
2569+
| 'deferred'
2570+
| 'appended-from-queue'
2571+
| 'dropped-stale'
2572+
| 'dropped-superseded'
2573+
| 'dropped-busy';
2574+
2575+
export type ScheduleTriggerKind = 'schedule-fire' | 'queue-drain';
25622576

25632577
export interface ScheduleTriggerResponse {
2578+
id?: string;
25642579
scheduleId: string;
2565-
runId: string;
2580+
runId: string | null;
25662581
scheduledFireAt: number;
25672582
actualFireAt: number;
2568-
status: ScheduleTriggerStatus;
2583+
outcome: ScheduleTriggerOutcome;
25692584
error?: string;
2585+
triggerKind?: ScheduleTriggerKind;
2586+
parentTriggerId?: string;
2587+
metadata?: Record<string, unknown>;
25702588
run?: ScheduleRunSummary;
25712589
}
25722590

2573-
export interface ListSchedulesParams {
2591+
export type ListSchedulesParams = {
25742592
workflowId?: string;
25752593
status?: ScheduleStatus;
2576-
}
2594+
} & ({ ownerType?: undefined; ownerId?: undefined } | { ownerType: string; ownerId?: string });
25772595

25782596
export interface ListSchedulesResponse {
25792597
schedules: ScheduleResponse[];

packages/core/src/storage/constants.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,14 +586,20 @@ export const TABLE_SCHEMAS: Record<TABLE_NAMES, Record<string, StorageColumn>> =
586586
created_at: { type: 'bigint', nullable: false },
587587
updated_at: { type: 'bigint', nullable: false },
588588
metadata: { type: 'jsonb', nullable: true },
589+
owner_type: { type: 'text', nullable: true },
590+
owner_id: { type: 'text', nullable: true },
589591
},
590592
[TABLE_SCHEDULE_TRIGGERS]: {
593+
id: { type: 'text', nullable: false, primaryKey: true },
591594
schedule_id: { type: 'text', nullable: false },
592-
run_id: { type: 'text', nullable: false, primaryKey: true },
595+
run_id: { type: 'text', nullable: true },
593596
scheduled_fire_at: { type: 'bigint', nullable: false },
594597
actual_fire_at: { type: 'bigint', nullable: false },
595-
status: { type: 'text', nullable: false },
598+
outcome: { type: 'text', nullable: false },
596599
error: { type: 'text', nullable: true },
600+
trigger_kind: { type: 'text', nullable: false },
601+
parent_trigger_id: { type: 'text', nullable: true },
602+
metadata: { type: 'jsonb', nullable: true },
597603
},
598604
[TABLE_CHANNEL_INSTALLATIONS]: {
599605
id: { type: 'text', nullable: false, primaryKey: true },

packages/core/src/storage/domains/schedules/__tests__/inmemory.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ function makeSchedule(overrides: Partial<Schedule> = {}): Schedule {
2525

2626
function makeTrigger(overrides: Partial<ScheduleTrigger> = {}): ScheduleTrigger {
2727
return {
28+
id: overrides.id ?? `tr_${crypto.randomUUID()}`,
2829
scheduleId: overrides.scheduleId ?? 'sched_1',
2930
runId: overrides.runId ?? 'run_1',
3031
scheduledFireAt: overrides.scheduledFireAt ?? Date.now(),
3132
actualFireAt: overrides.actualFireAt ?? Date.now(),
32-
status: overrides.status ?? 'published',
33+
outcome: overrides.outcome ?? 'published',
34+
triggerKind: overrides.triggerKind ?? 'schedule-fire',
3335
error: overrides.error,
3436
};
3537
}

packages/core/src/storage/domains/schedules/base.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ export type ScheduleTarget = {
1717
/** Lifecycle status of a schedule row. */
1818
export type ScheduleStatus = 'active' | 'paused';
1919

20+
/**
21+
* Polymorphic owner of a schedule. Workflow schedules created via
22+
* `createWorkflow({ schedule })` leave both fields null. Heartbeat
23+
* schedules created via `agent.setHeartbeat(...)` set
24+
* `ownerType: 'agent'` and `ownerId` to the agent id. Future schedule
25+
* types (tenant-owned, workflow-owned, etc.) can use the same shape
26+
* without a migration.
27+
*/
28+
export type ScheduleOwnerType = 'agent' | (string & {});
29+
2030
/**
2131
* A persisted schedule.
2232
*
@@ -36,25 +46,66 @@ export type Schedule = {
3646
createdAt: number;
3747
updatedAt: number;
3848
metadata?: Record<string, unknown>;
49+
/** Optional owner classification (e.g. 'agent' for heartbeats). */
50+
ownerType?: ScheduleOwnerType;
51+
/** Optional owner identifier paired with `ownerType`. */
52+
ownerId?: string;
3953
};
4054

41-
/** Status of an individual schedule trigger attempt. */
42-
export type ScheduleTriggerStatus = 'published' | 'failed';
55+
/**
56+
* Outcome of an individual schedule trigger attempt.
57+
*
58+
* `published` and `failed` cover scheduler tick fires. Heartbeat
59+
* outcomes (`acked`, `alerted`, `deferred`, `appended-from-queue`,
60+
* `dropped-stale`, `dropped-superseded`, `dropped-busy`, `skipped`)
61+
* are recorded by the heartbeat workflow. `failed` covers any
62+
* unhandled error during a fire or drain.
63+
*/
64+
export type ScheduleTriggerOutcome =
65+
| 'published'
66+
| 'failed'
67+
| 'skipped'
68+
| 'acked'
69+
| 'alerted'
70+
| 'deferred'
71+
| 'appended-from-queue'
72+
| 'dropped-stale'
73+
| 'dropped-superseded'
74+
| 'dropped-busy';
75+
76+
/**
77+
* Distinguishes a tick-loop schedule fire from a deferred drain event.
78+
* Drain rows reference the original fire via `parentTriggerId`.
79+
*/
80+
export type ScheduleTriggerKind = 'schedule-fire' | 'queue-drain';
4381

4482
/** Audit record produced for each trigger attempt. */
4583
export type ScheduleTrigger = {
84+
/** Stable trigger row id. Generated by storage when omitted on write. */
85+
id?: string;
4686
scheduleId: string;
47-
runId: string;
87+
/** May be null for drain rows that have no associated workflow run. */
88+
runId: string | null;
4889
scheduledFireAt: number;
4990
actualFireAt: number;
50-
status: ScheduleTriggerStatus;
91+
outcome: ScheduleTriggerOutcome;
5192
error?: string;
93+
/** Defaults to `'schedule-fire'` when omitted. */
94+
triggerKind?: ScheduleTriggerKind;
95+
/** Pointer back to the originating fire row when `triggerKind === 'queue-drain'`. */
96+
parentTriggerId?: string;
97+
/** Outcome-specific context (alert text, append message id, queue age, etc.). */
98+
metadata?: Record<string, unknown>;
5299
};
53100

54101
/** Filter options for listing schedules. */
55102
export type ScheduleFilter = {
56103
status?: ScheduleStatus;
57104
workflowId?: string;
105+
/** `null` matches schedules with no owner (e.g. workflow-only schedules). */
106+
ownerType?: ScheduleOwnerType | null;
107+
/** `null` matches schedules with no owner id. */
108+
ownerId?: string | null;
58109
};
59110

60111
/** Filter / pagination options for listing trigger history. */
@@ -68,7 +119,7 @@ export type ScheduleTriggerListOptions = {
68119

69120
/** Fields that can be patched via {@link SchedulesStorage.updateSchedule}. */
70121
export type ScheduleUpdate = Partial<
71-
Pick<Schedule, 'cron' | 'timezone' | 'status' | 'nextFireAt' | 'metadata' | 'target'>
122+
Pick<Schedule, 'cron' | 'timezone' | 'status' | 'nextFireAt' | 'metadata' | 'target' | 'ownerType' | 'ownerId'>
72123
>;
73124

74125
/**

packages/core/src/storage/domains/schedules/inmemory.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from 'node:crypto';
12
import type { InMemoryDB } from '../inmemory-db';
23
import type { Schedule, ScheduleFilter, ScheduleTrigger, ScheduleTriggerListOptions, ScheduleUpdate } from './base';
34
import { SchedulesStorage } from './base';
@@ -41,6 +42,12 @@ export class InMemorySchedulesStorage extends SchedulesStorage {
4142
if (filter?.workflowId) {
4243
rows = rows.filter(r => r.target.type === 'workflow' && r.target.workflowId === filter.workflowId);
4344
}
45+
if (filter?.ownerType !== undefined) {
46+
rows = rows.filter(r => (r.ownerType ?? null) === filter.ownerType);
47+
}
48+
if (filter?.ownerId !== undefined) {
49+
rows = rows.filter(r => (r.ownerId ?? null) === filter.ownerId);
50+
}
4451
rows.sort((a, b) => a.createdAt - b.createdAt);
4552
return rows.map(clone);
4653
}
@@ -106,7 +113,12 @@ export class InMemorySchedulesStorage extends SchedulesStorage {
106113
}
107114

108115
async recordTrigger(trigger: ScheduleTrigger): Promise<void> {
109-
this.db.scheduleTriggers.push(clone(trigger));
116+
const stored: ScheduleTrigger = {
117+
...trigger,
118+
id: trigger.id ?? randomUUID(),
119+
triggerKind: trigger.triggerKind ?? 'schedule-fire',
120+
};
121+
this.db.scheduleTriggers.push(clone(stored));
110122
}
111123

112124
async listTriggers(scheduleId: string, opts?: ScheduleTriggerListOptions): Promise<ScheduleTrigger[]> {

packages/core/src/workflows/scheduler/scheduler.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('WorkflowScheduler', () => {
6363

6464
const triggers = await store.listTriggers(created.id);
6565
expect(triggers).toHaveLength(1);
66-
expect(triggers[0]!.status).toBe('published');
66+
expect(triggers[0]!.outcome).toBe('published');
6767
});
6868

6969
it('skips paused schedules', async () => {
@@ -163,7 +163,7 @@ describe('WorkflowScheduler', () => {
163163

164164
const triggers = await store.listTriggers('sched-fail');
165165
expect(triggers).toHaveLength(1);
166-
expect(triggers[0]!.status).toBe('failed');
166+
expect(triggers[0]!.outcome).toBe('failed');
167167
expect(triggers[0]!.error).toBe('boom');
168168
expect(onError).toHaveBeenCalledOnce();
169169
expect(onError.mock.calls[0]![1]).toEqual({ scheduleId: 'sched-fail' });

packages/core/src/workflows/scheduler/scheduler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export class WorkflowScheduler extends MastraBase {
193193
return;
194194
}
195195

196-
let triggerStatus: ScheduleTrigger['status'] = 'published';
196+
let triggerStatus: ScheduleTrigger['outcome'] = 'published';
197197
let triggerError: string | undefined;
198198

199199
try {
@@ -215,8 +215,9 @@ export class WorkflowScheduler extends MastraBase {
215215
runId,
216216
scheduledFireAt: schedule.nextFireAt,
217217
actualFireAt,
218-
status: triggerStatus,
218+
outcome: triggerStatus,
219219
error: triggerError,
220+
triggerKind: 'schedule-fire',
220221
});
221222
} catch (err) {
222223
this.logger.error('Failed to record schedule trigger', {

packages/playground/src/domains/schedules/components/schedule-triggers-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export function ScheduleTriggersList({
8383
const driftMs = t.actualFireAt - t.scheduledFireAt;
8484
const driftValue = formatDriftValue(driftMs);
8585
const startedTooltip = `Scheduled ${formatScheduleTimestamp(t.scheduledFireAt)} — published ${formatScheduleTimestamp(t.actualFireAt)} (drift ${driftValue})`;
86-
const isPublishFailure = t.status === 'failed';
86+
const isPublishFailure = t.outcome === 'failed';
8787
const errorMessage = isPublishFailure ? t.error : t.run?.error;
8888
const absDrift = Math.abs(driftMs);
8989
const showDriftWarning = !isPublishFailure && absDrift > DRIFT_WARN_MIN_MS && absDrift <= DRIFT_WARN_MAX_MS;

0 commit comments

Comments
 (0)