Skip to content

Commit 03fd7f0

Browse files
feat(automation): schedule flow trigger — auto-launch flows on cron/interval/once (#1423)
The time-based sibling of plugin-trigger-record-change. The automation engine already parses a flow's start node into a `schedule` binding (flow.type === 'schedule' or a start-node `config.schedule` descriptor) — shipped with the record-change trigger PR — so this is purely the concrete trigger, with no engine change. New plugin @objectstack/plugin-trigger-schedule: - ScheduleTrigger implements the engine's FlowTrigger extension point and delegates timing to the platform IJobService (the 'job' service), staying adapter-agnostic: the job service selects a cron-capable adapter (DbJobAdapter / CronJobAdapter) for cron and the interval adapter otherwise. - normalizeSchedule accepts the canonical JobSchedule plus shorthands (bare cron string, { cron }/{ expression }, { every }/{ intervalMs }, { at }). - On fire the flow runs with event:'schedule' + params:{ jobId, flowName, schedule }; the engine's start-condition gate still applies. - Error-isolated (a flow failure never crashes the job runner); per-flow job name so stop() cancels exactly one flow; job service resolved lazily per bind so adapter upgrades (interval → durable Db) are picked up; graceful degrade when automation/job service absent (kernel:ready + getService). Depends on com.objectstack.service.job so its adapter upgrade runs first. Tests: 17 (normalizeSchedule shapes/shorthands/rejects, schedule/cancel, config.schedule fallback, fire→context, error isolation, idempotency, plugin wiring + graceful degrade + lazy resolution). Builds clean (full DTS). Adds the package to the changeset fixed group. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 6e4b3f2 commit 03fd7f0

10 files changed

Lines changed: 785 additions & 0 deletions

File tree

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@objectstack/plugin-sharing",
3939
"@objectstack/plugin-webhooks",
4040
"@objectstack/plugin-trigger-record-change",
41+
"@objectstack/plugin-trigger-schedule",
4142
"@objectstack/connector-mcp",
4243
"@objectstack/connector-rest",
4344
"@objectstack/connector-slack",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/plugin-trigger-schedule": minor
3+
---
4+
5+
Schedule flow trigger — auto-launch flows on a cron/interval/once schedule.
6+
7+
The sibling of `@objectstack/plugin-trigger-record-change`: it completes the
8+
*time-based* arm of the automation engine's `FlowTrigger` extension point. The
9+
engine already parses a flow's start node into a `schedule` binding
10+
(`flow.type === 'schedule'` or a start-node `config.schedule` descriptor); this
11+
plugin registers the concrete `schedule` trigger and delegates timing to the
12+
platform `IJobService` (the `'job'` service), so it stays adapter-agnostic — the
13+
job service selects a cron-capable adapter (durable `DbJobAdapter` /
14+
`CronJobAdapter`) for cron schedules and the interval adapter otherwise.
15+
16+
- `normalizeSchedule` accepts the canonical `JobSchedule` plus shorthands (a
17+
bare cron string, `{ cron }` / `{ expression }`, `{ every }` / `{ intervalMs }`,
18+
`{ at }`).
19+
- When a job fires, the flow runs with `event: 'schedule'` and
20+
`params: { jobId, flowName, schedule }`; the engine's start-condition gate
21+
still applies.
22+
- Error-isolated (a flow failure never crashes the job runner); per-flow job
23+
name so `stop()` cancels exactly one flow; the job service is resolved lazily
24+
per bind so adapter upgrades are picked up; graceful degrade when the
25+
automation or job service is absent.
26+
27+
No engine change required — the `schedule` binding shipped with the
28+
record-change trigger PR.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# @objectstack/plugin-trigger-schedule
2+
3+
Auto-launch ObjectStack flows on a schedule (cron / interval / once).
4+
5+
The automation engine ships the `FlowTrigger` extension point and the wiring
6+
that turns a flow's `start` node into a normalized trigger binding — but the
7+
*concrete* schedule trigger lives here, as a plugin. It delegates timing to the
8+
platform `IJobService` (the `'job'` service), so it stays adapter-agnostic: the
9+
job service selects a cron-capable adapter (e.g. the durable `DbJobAdapter` or
10+
`CronJobAdapter`) for cron schedules and the interval adapter for the rest.
11+
12+
This is the sibling of `@objectstack/plugin-trigger-record-change` — same
13+
engine baseline, a different event source.
14+
15+
## What it does
16+
17+
A flow whose `start` node declares a schedule:
18+
19+
```ts
20+
{
21+
type: 'start',
22+
config: {
23+
schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' },
24+
condition: "...", // optional start-condition gate
25+
},
26+
}
27+
// or simply: a flow with `type: 'schedule'` and a start-node schedule descriptor
28+
```
29+
30+
auto-launches on that schedule — no manual `engine.execute()`. When it fires,
31+
the flow runs with `event: 'schedule'` and `params: { jobId, flowName, schedule }`
32+
in its context.
33+
34+
### Schedule shapes
35+
36+
`normalizeSchedule` accepts the canonical `JobSchedule` plus shorthands:
37+
38+
| Input | Normalized |
39+
| ---------------------------------------------- | ---------------------------------------- |
40+
| `{ type: 'cron', expression, timezone? }` | cron |
41+
| `'0 1 * * *'` (bare string) | `{ type: 'cron', expression: '0 1 * * *' }` |
42+
| `{ cron }` / `{ expression }` | cron |
43+
| `{ type: 'interval', intervalMs }` / `{ every }` | interval |
44+
| `{ type: 'once', at }` / `{ at }` | once |
45+
46+
## Usage
47+
48+
```ts
49+
import { AutomationServicePlugin } from '@objectstack/service-automation';
50+
import { JobServicePlugin } from '@objectstack/service-job';
51+
import { ScheduleTriggerPlugin } from '@objectstack/plugin-trigger-schedule';
52+
53+
kernel
54+
.use(new AutomationServicePlugin()) // engine + flows
55+
.use(new JobServicePlugin()) // the 'job' service (cron/interval/db)
56+
.use(new ScheduleTriggerPlugin()); // ← makes schedule flows live
57+
```
58+
59+
Depends on the job service plugin (`com.objectstack.service.job`) so its
60+
`kernel:ready` adapter upgrade runs first; the job service is nonetheless
61+
resolved lazily per bind, so adapter upgrades are always picked up. If the
62+
automation or job service is unavailable, the plugin logs a warning and no-ops
63+
rather than failing startup.
64+
65+
## Error isolation
66+
67+
A flow that throws during a scheduled run is logged and swallowed — it never
68+
crashes the job runner.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@objectstack/plugin-trigger-schedule",
3+
"version": "7.3.0",
4+
"license": "Apache-2.0",
5+
"description": "Schedule flow trigger for ObjectStack — auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run --passWithNoTests"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.9.1",
25+
"typescript": "^6.0.3",
26+
"vitest": "^4.1.7"
27+
},
28+
"keywords": [
29+
"objectstack",
30+
"plugin",
31+
"automation",
32+
"flow",
33+
"trigger",
34+
"schedule",
35+
"cron"
36+
],
37+
"author": "ObjectStack",
38+
"repository": {
39+
"type": "git",
40+
"url": "https://github.com/objectstack-ai/framework.git",
41+
"directory": "packages/plugins/plugin-trigger-schedule"
42+
},
43+
"homepage": "https://objectstack.ai/docs",
44+
"bugs": "https://github.com/objectstack-ai/framework/issues",
45+
"publishConfig": {
46+
"access": "public"
47+
},
48+
"files": [
49+
"dist",
50+
"README.md"
51+
],
52+
"engines": {
53+
"node": ">=18.0.0"
54+
}
55+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
export { ScheduleTriggerPlugin } from './plugin.js';
4+
export { ScheduleTrigger, normalizeSchedule } from './schedule-trigger.js';
5+
export type {
6+
FlowTrigger,
7+
FlowTriggerBinding,
8+
JobServiceSurface,
9+
TriggerLogger,
10+
} from './schedule-trigger.js';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import { ScheduleTrigger } from './schedule-trigger.js';
5+
import type { FlowTrigger, JobServiceSurface } from './schedule-trigger.js';
6+
7+
/**
8+
* The slice of the automation engine this plugin needs: register a trigger on
9+
* its `FlowTrigger` extension point. Declared structurally so the plugin does
10+
* not take a build dependency on `@objectstack/service-automation`.
11+
*/
12+
interface AutomationTriggerRegistry {
13+
registerTrigger(trigger: FlowTrigger): void;
14+
unregisterTrigger?(type: string): void;
15+
}
16+
17+
/**
18+
* ScheduleTriggerPlugin
19+
*
20+
* Makes schedule-triggered flows actually fire. The automation engine ships the
21+
* `FlowTrigger` wiring (it parses each flow's start node — `flow.type ===
22+
* 'schedule'` or a start-node `config.schedule` descriptor — into a binding and
23+
* calls `trigger.start(...)`), but the *concrete* schedule trigger lives here as
24+
* a plugin and delegates timing to the platform `IJobService` (the `'job'`
25+
* service). This mirrors the connector / record-change split (engine baseline +
26+
* trigger plugin).
27+
*
28+
* With this plugin (and a job service) installed, a flow whose start node
29+
* declares `config: { schedule: { type: 'cron', expression: '0 1 * * *' } }`
30+
* auto-launches on that schedule — no manual `engine.execute()`.
31+
*
32+
* Depends on the job service plugin so its `kernel:ready` upgrade (to the
33+
* durable DbJobAdapter) runs before ours; the job service is nonetheless
34+
* resolved lazily per `start()` so we always use its current adapter.
35+
*/
36+
export class ScheduleTriggerPlugin implements Plugin {
37+
name = 'com.objectstack.trigger.schedule';
38+
type = 'standard';
39+
version = '7.3.0';
40+
dependencies = ['com.objectstack.service.job'];
41+
42+
async init(ctx: PluginContext): Promise<void> {
43+
ctx.logger.info('Schedule trigger plugin initialized');
44+
}
45+
46+
async start(ctx: PluginContext): Promise<void> {
47+
// The automation service + job service are resolvable once the kernel is
48+
// ready (kernel:ready fires after AutomationServicePlugin.start() has
49+
// pulled flows in and after the job service upgrades its adapter).
50+
ctx.hook('kernel:ready', async () => {
51+
const automation = this.resolveService<AutomationTriggerRegistry>(ctx, 'automation');
52+
if (!automation || typeof automation.registerTrigger !== 'function') {
53+
ctx.logger.warn(
54+
'ScheduleTriggerPlugin: automation service not available — schedule trigger NOT installed',
55+
);
56+
return;
57+
}
58+
59+
// Probe once for a clear startup warning; the trigger re-resolves
60+
// lazily on each start() so adapter upgrades are always picked up.
61+
if (!this.resolveService<JobServiceSurface>(ctx, 'job')) {
62+
ctx.logger.warn(
63+
'ScheduleTriggerPlugin: job service not available — scheduled flows will not run until one is registered',
64+
);
65+
}
66+
67+
const trigger = new ScheduleTrigger(
68+
() => this.resolveService<JobServiceSurface>(ctx, 'job'),
69+
ctx.logger,
70+
);
71+
automation.registerTrigger(trigger);
72+
ctx.logger.info('ScheduleTriggerPlugin: schedule trigger registered');
73+
});
74+
}
75+
76+
private resolveService<T>(ctx: PluginContext, name: string): T | null {
77+
try {
78+
return ctx.getService<T>(name) ?? null;
79+
} catch {
80+
return null;
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)