experimental-ash 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/docs/internals/schedule.md +37 -26
  3. package/dist/docs/public/schedules.md +61 -68
  4. package/dist/src/channel/schedule.d.ts +45 -32
  5. package/dist/src/channel/schedule.js +57 -44
  6. package/dist/src/chunks/authored-module-loader-XcFLnl49.js +2 -0
  7. package/dist/src/chunks/{dev-authored-source-watcher-B5J6JK7p.js → dev-authored-source-watcher-CG6kri3T.js} +1 -1
  8. package/dist/src/chunks/{host-ByUKG--q.js → host-CIU0NATc.js} +6 -6
  9. package/dist/src/chunks/paths-CvbqpwTh.js +88 -0
  10. package/dist/src/chunks/{prewarm-BLFoNX7E.js → prewarm-C_Vd0JR7.js} +2 -2
  11. package/dist/src/cli/commands/info.js +1 -1
  12. package/dist/src/cli/run.js +1 -1
  13. package/dist/src/compiler/manifest.d.ts +6 -24
  14. package/dist/src/compiler/manifest.js +2 -8
  15. package/dist/src/compiler/normalize-channel.d.ts +0 -8
  16. package/dist/src/compiler/normalize-channel.js +0 -27
  17. package/dist/src/compiler/normalize-manifest.js +2 -10
  18. package/dist/src/compiler/normalize-schedule.d.ts +6 -12
  19. package/dist/src/compiler/normalize-schedule.js +9 -32
  20. package/dist/src/evals/cli/eval.js +1 -1
  21. package/dist/src/evals/runner/discover.js +1 -1
  22. package/dist/src/internal/application/package.js +1 -1
  23. package/dist/src/internal/authored-definition/core.d.ts +3 -2
  24. package/dist/src/internal/authored-definition/core.js +20 -10
  25. package/dist/src/internal/authored-module-loader.d.ts +0 -6
  26. package/dist/src/internal/authored-module-loader.js +11 -72
  27. package/dist/src/internal/nitro/routes/agent-info/build-agent-info-response.js +3 -1
  28. package/dist/src/internal/nitro/routes/runtime-stack.d.ts +0 -11
  29. package/dist/src/internal/nitro/routes/runtime-stack.js +0 -25
  30. package/dist/src/internal/nitro/routes/schedule-task.d.ts +3 -3
  31. package/dist/src/internal/nitro/routes/schedule-task.js +41 -11
  32. package/dist/src/public/definitions/schedule.d.ts +47 -36
  33. package/dist/src/public/definitions/schedule.js +10 -25
  34. package/dist/src/public/helpers/markdown.d.ts +6 -6
  35. package/dist/src/public/helpers/markdown.js +8 -8
  36. package/dist/src/public/schedules/index.d.ts +1 -1
  37. package/dist/src/public/schedules/index.js +1 -1
  38. package/dist/src/runtime/schedules/resolve-schedule.js +5 -5
  39. package/dist/src/runtime/types.d.ts +7 -10
  40. package/package.json +1 -1
  41. package/dist/src/chunks/authored-module-loader-Pt_g8xX2.js +0 -3
  42. package/dist/src/chunks/paths-CFoo44rU.js +0 -88
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7304b4d: Replace the schedule `channel: receive(slack, args)` declarative form with
8
+ a `run` handler that receives the same `args.receive(channel, …)` route
9
+ handlers got in the previous release. Schedules now author one of:
10
+
11
+ - `markdown: "..."` — fire-and-forget agent invocation (channel-less,
12
+ output discarded). Markdown frontmatter form (`.md`) maps here.
13
+ - `async run({ receive, waitUntil, appAuth }) { ... }` — full handler.
14
+ `receive(channel, ...)` is the same cross-channel surface route
15
+ handlers use; `waitUntil(promise)` extends the cron task lifetime.
16
+
17
+ Migration:
18
+
19
+ ```ts
20
+ // before
21
+ export default defineSchedule({
22
+ cron: "0 9 * * 1-5",
23
+ markdown: "Post the digest.",
24
+ channel: receive(slack, { channelId: "C0123ABC" }),
25
+ });
26
+
27
+ // after
28
+ export default defineSchedule({
29
+ cron: "0 9 * * 1-5",
30
+ async run({ receive, waitUntil, appAuth }) {
31
+ waitUntil(
32
+ receive(slack, {
33
+ message: "Post the digest.",
34
+ args: { channelId: "C0123ABC" },
35
+ auth: appAuth,
36
+ })
37
+ );
38
+ },
39
+ });
40
+ ```
41
+
42
+ Pure-markdown schedules (both `.md` form and `.ts` form with `markdown`)
43
+ are unchanged.
44
+
45
+ Removed: the `receive(channel, args)` schedule helper, the
46
+ `ChannelReceiveRef` type, the `ChannelRouteIdentityMap` compile-time
47
+ plumbing, the `ash-channel-identity` Rolldown plugin, and the
48
+ `channel`/`schedule.channel` manifest entry. Authored schedule
49
+ identity is now derived entirely at runtime from the channel value the
50
+ handler imports, matching how `args.receive(channel, …)` already works
51
+ in route handlers.
52
+
3
53
  ## 0.17.0
4
54
 
5
55
  ### Minor Changes
@@ -1,16 +1,18 @@
1
1
  # Schedules
2
2
 
3
3
  Schedules let an authored agent run on a cron cadence. The runtime owns the
4
- dispatch contract; the channel layer (when present) owns delivery.
4
+ dispatch contract; channels (when an author hands work off to one) own
5
+ delivery.
5
6
 
6
7
  ## Authoring shape
7
8
 
8
9
  Each schedule is a single file under `schedules/`:
9
10
 
10
11
  - `schedules/<name>.{ts,cts,mts,js,cjs,mjs}` — TypeScript module with a default
11
- export of `defineSchedule({ cron, markdown, channel? })`.
12
+ export of `defineSchedule({ cron, markdown? | run? })`. Exactly one of
13
+ `markdown` (fire-and-forget) or `run` (handler) is provided.
12
14
  - `schedules/<name>.md` — markdown with frontmatter (`cron:`) and the body as
13
- the prompt. The markdown form does not support a channel.
15
+ the prompt. Always fire-and-forget; markdown form cannot declare `run`.
14
16
 
15
17
  Recursive nesting is supported. The schedule name is derived from the relative
16
18
  path under `schedules/` minus the file extension
@@ -28,40 +30,45 @@ to the unified `discoverNamedSourceDirectory` walker with
28
30
  hydrates each `ScheduleSourceRef` into a `CompiledScheduleDefinition`:
29
31
 
30
32
  - TypeScript modules go through `loadModuleBackedDefinition` and
31
- `normalizeScheduleDefinition` (rejects unknown keys).
33
+ `normalizeScheduleDefinition` (rejects unknown keys; enforces the
34
+ `markdown` / `run` exclusivity rule).
32
35
  - Markdown sources have already been lowered at discovery time; the compiler
33
36
  re-runs `normalizeScheduleDefinition` for type safety.
34
- - When `channel` is present, the compiler resolves the imported route to a
35
- channel name through the `channelIdentityMap` built when channels were
36
- loaded. Plain `{ name, args }` literals are accepted as a test-fixture
37
- fallback.
38
- - The compiled `markdown` field is `definition.markdown.trim()`.
37
+ - The compiled entry carries `hasRun: boolean` plus an optional `markdown`
38
+ string. The schedule's TypeScript module (for `hasRun` schedules) is
39
+ loaded at dispatch time via the standard module map; the compiler does
40
+ not bake the handler into the manifest.
39
41
  - The schedule name is derived from the logical path
40
42
  (`stripLogicalPathExtension(...).replace(/^schedules\//, "")`).
41
43
 
42
44
  ## Runtime
43
45
 
44
- `ScheduleDispatcher.trigger` (`packages/ash/src/channel/schedule.ts`) takes
45
- two paths:
46
+ `ScheduleDispatcher.trigger` (`packages/ash/src/channel/schedule.ts`) builds
47
+ `ScheduleHandlerArgs` (a subset of `RouteHandlerArgs`) against the
48
+ request-scoped channel bundle and routes on the schedule shape:
46
49
 
47
- - **With a channel:** resolve the channel by name, call its `receive()` with
48
- `{ message: markdown, args, auth: APP_AUTH }`. The channel handles delivery
49
- through its normal code path.
50
- - **Without a channel:** start a session via `runtime.run({...})` directly with
51
- a minimal `{ kind: "schedule" }` adapter, `auth: APP_AUTH`, `mode: "task"`,
52
- and the schedule's `markdown` as the seed message. Output is discarded; the
53
- agent can still call tools, log, or hit backends.
50
+ - **Handler (`run`):** invoke `definition.run(args)`. The handler decides
51
+ control flow; `args.receive(channel, …)` hands the work off to a channel,
52
+ `args.waitUntil(promise)` keeps the Nitro task open until the promise
53
+ settles. `appAuth` is the framework app principal pre-built for
54
+ convenience.
55
+ - **Markdown:** the dispatcher synthesizes a channel-less run by calling
56
+ `runtime.run({ adapter: SCHEDULE_ADAPTER, mode: "task", auth: APP_AUTH,
57
+ input: { message: markdown } })`. Output is discarded; the agent can still
58
+ call tools, log, or hit backends.
54
59
 
55
60
  Both branches use the framework app principal: `principalId: "ash:app"`,
56
61
  `principalType: "runtime"`.
57
62
 
58
- ## Why markdown form omits channel
63
+ `SCHEDULE_ADAPTER` is registered in `FRAMEWORK_ADAPTERS`
64
+ (`runtime/channels/registry.ts`) so the adapter registry can rehydrate
65
+ `{ kind: "schedule" }` at every workflow step boundary.
59
66
 
60
- A typed `receive(channel, args)` reference depends on importing the channel
61
- module so the route's args type is in scope. Markdown frontmatter has no such
62
- import boundary args would have to be opaque `Record<string, unknown>`,
63
- losing the type safety that makes `receive()` worth using. The simple,
64
- type-safe answer is: if you need a channel, write a `.ts` schedule.
67
+ ## Why markdown form omits `run`
68
+
69
+ Markdown frontmatter has no JavaScript scope; declaring a handler requires
70
+ TypeScript anyway. Markdown stays the path for fire-and-forget prompts;
71
+ `.ts` schedules cover everything else.
65
72
 
66
73
  ## Nitro task wiring
67
74
 
@@ -73,7 +80,8 @@ event name plus a baked-in artifacts config.
73
80
 
74
81
  ## Tests
75
82
 
76
- - `src/channel/schedule.test.ts` — dispatcher behavior in both branches.
83
+ - `src/channel/schedule.test.ts` — dispatcher behavior for both handler and
84
+ markdown branches plus the `waitUntil` accumulator.
77
85
  - `src/discover/agent.integration.test.ts` — discovery shapes including
78
86
  recursive nesting, markdown form, .md/.ts collision, legacy migration
79
87
  diagnostic, and unsupported leaves.
@@ -83,6 +91,9 @@ event name plus a baked-in artifacts config.
83
91
  — runtime resolution and Nitro task registration with mixed module/markdown
84
92
  schedules.
85
93
  - `test/scenarios/compile-agent.scenario.test.ts` — end-to-end compile with
86
- module + markdown schedules including the no-channel form.
94
+ module + markdown schedules in both forms.
95
+ - `test/scenarios/schedule-trigger.scenario.test.ts` — markdown schedule
96
+ end-to-end through the real dispatcher, including the SCHEDULE_ADAPTER
97
+ step-boundary rehydration.
87
98
  - `test/scenarios/runtime-loaders.scenario.test.ts` — runtime artifact
88
99
  loaders round-trip.
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  title: "Schedules"
3
- description: "Define recurring cron jobs that run an agent and optionally deliver the result through a channel."
3
+ description: "Define recurring cron jobs that run an agent and optionally hand off to a channel."
4
4
  ---
5
5
 
6
- Schedules let an agent initiate recurring work — digests, syncs, maintenance, heartbeat checks — on a cron cadence. The agent can either deliver its output through a channel (e.g. Slack) or run fire-and-forget while calling tools, hitting backends, or writing logs.
6
+ Schedules let an agent initiate recurring work — digests, syncs, maintenance, heartbeat checks — on a cron cadence. Each schedule is either a fire-and-forget markdown prompt or a TypeScript handler that decides what to do (typically by handing the work off to a channel).
7
7
 
8
8
  Schedules live under `schedules/` at the root agent package.
9
9
 
@@ -11,62 +11,77 @@ Schedules live under `schedules/` at the root agent package.
11
11
 
12
12
  - schedules are root-only — local subagents cannot declare `schedules/`
13
13
  - a schedule is a single file: `<name>.ts` or `<name>.md`
14
- - the markdown form does not support targeting a channel — use the `.ts` form for that
15
- - the channel argument is **optional**
14
+ - every schedule provides exactly one of `markdown` (fire-and-forget) or `run` (handler)
15
+ - markdown-form (`.md`) schedules cannot declare a handler — use `.ts` for that
16
16
 
17
17
  ## Authoring shapes
18
18
 
19
- ### TypeScript
19
+ ### TypeScript handler (`run`)
20
+
21
+ Use a handler when the schedule should deliver to a channel, branch on conditions, or compute args at fire time. The handler receives a tight subset of `RouteHandlerArgs`:
20
22
 
21
23
  ```ts
22
24
  // agent/schedules/daily-digest.ts
23
- import { defineSchedule, receive } from "experimental-ash/schedules";
25
+ import { defineSchedule } from "experimental-ash/schedules";
24
26
 
25
27
  import slack from "../channels/slack.js";
26
28
 
27
29
  export default defineSchedule({
28
30
  cron: "0 9 * * 1-5",
29
- markdown: "Summarize yesterday's activity and post the digest.",
30
- channel: receive(slack, { channelId: "C0123ABC" }),
31
+ async run({ receive, waitUntil, appAuth }) {
32
+ waitUntil(
33
+ receive(slack, {
34
+ message: "Summarize yesterday's activity and post the digest.",
35
+ args: { channelId: "C0123ABC" },
36
+ auth: appAuth,
37
+ }),
38
+ );
39
+ },
31
40
  });
32
41
  ```
33
42
 
43
+ `ScheduleHandlerArgs`:
44
+
45
+ - `receive(channel, { message, args, auth })` — same contract as a route handler's [`args.receive`](./channels/README.md#cross-channel-hand-off). Hands the work off to a channel's authored `receive` hook.
46
+ - `waitUntil(promise)` — extends the cron task's lifetime past handler return so in-flight work settles before the Nitro task completes.
47
+ - `appAuth` — pre-built APP auth context (`{ authenticator: "app", principalId: "ash:app", principalType: "runtime" }`). Pass to `receive(..., { auth: appAuth })` for schedules that run on behalf of the agent itself.
48
+
34
49
  #### Announce-then-deliver
35
50
 
36
- A schedule can post an anchor card before the agent runs so the thread shows
37
- "what this is about" up front; the agent's reply then lands threaded under
38
- that card. Same `initialPost` field [Slack's `receive`](./channels/README.md#cross-channel-hand-off)
39
- accepts on the route-handler side:
51
+ A handler that hands off to Slack can post an anchor card before the agent runs so the thread shows "what this is about" up front; the agent's reply then lands threaded under that card. Same `initialPost` field [Slack's `receive`](./channels/README.md#cross-channel-hand-off) accepts from a route handler:
40
52
 
41
53
  ```ts
42
54
  // agent/schedules/deploy-digest.ts
43
- import { defineSchedule, receive } from "experimental-ash/schedules";
55
+ import { defineSchedule } from "experimental-ash/schedules";
44
56
  import { Card, CardText } from "experimental-ash/channels/slack";
45
57
 
46
58
  import slack from "../channels/slack.js";
47
59
 
48
60
  export default defineSchedule({
49
61
  cron: "0 17 * * 1-5",
50
- markdown: "Summarize today's production deploys.",
51
- channel: receive(slack, {
52
- channelId: "C0123ABC",
53
- initialPost: {
54
- card: Card({ children: [CardText("Daily Deploy Digest")] }),
55
- fallbackText: "Daily Deploy Digest",
56
- },
57
- }),
62
+ async run({ receive, waitUntil, appAuth }) {
63
+ waitUntil(
64
+ receive(slack, {
65
+ message: "Summarize today's production deploys.",
66
+ args: {
67
+ channelId: "C0123ABC",
68
+ initialPost: {
69
+ card: Card({ children: [CardText("Daily Deploy Digest")] }),
70
+ fallbackText: "Daily Deploy Digest",
71
+ },
72
+ },
73
+ auth: appAuth,
74
+ }),
75
+ );
76
+ },
58
77
  });
59
78
  ```
60
79
 
61
- `threadTs` and `initialPost` are mutually exclusive: pass `threadTs` to join
62
- an existing thread, or `initialPost` to anchor a new one.
80
+ `threadTs` and `initialPost` are mutually exclusive on Slack receive args. The anchor card's `ts` becomes the session's continuation token, so any later `@mention` reply in that thread resumes the same session — a digest that sparks a question can be answered in-thread without spinning up a new session.
63
81
 
64
- The anchor card's `ts` is the session's continuation token, so any later `@mention`
65
- reply in that thread resumes the same session. A nightly digest schedule whose digest
66
- sparks a question can be answered in-thread without spinning up a new session — the
67
- schedule's run and the operator's follow-up share one history.
82
+ ### Markdown (`markdown`)
68
83
 
69
- Without a channel the agent runs and the output is discarded:
84
+ Fire-and-forget: the agent runs the prompt and the output is discarded. The agent is still free to call tools, write to backends, log, etc.
70
85
 
71
86
  ```ts
72
87
  // agent/schedules/heartbeat.ts
@@ -78,7 +93,7 @@ export default defineSchedule({
78
93
  });
79
94
  ```
80
95
 
81
- ### Markdown
96
+ Markdown frontmatter form:
82
97
 
83
98
  ```md
84
99
  ## <!-- agent/schedules/cleanup.md -->
@@ -88,7 +103,7 @@ export default defineSchedule({
88
103
  Sweep stale workflow state.
89
104
  ```
90
105
 
91
- The body is the prompt. The frontmatter accepts `cron` only — markdown-form schedules cannot reference a channel.
106
+ The body is the prompt. The frontmatter accepts `cron` only.
92
107
 
93
108
  ## Recursive nesting
94
109
 
@@ -108,62 +123,40 @@ agent/schedules/
108
123
  ```ts
109
124
  interface ScheduleDefinition {
110
125
  cron: string;
111
- markdown: string;
112
- channel?: ChannelReceiveRef<unknown>;
126
+ markdown?: string;
127
+ run?: (args: ScheduleHandlerArgs) => Promise<void> | void;
113
128
  }
114
- ```
115
-
116
- - `cron` (required) — a cron expression for when the schedule fires
117
- - `markdown` (required) — the prompt the agent receives when the schedule runs
118
- - `channel` (optional) — a typed channel reference produced by `receive(channel, args)`
119
-
120
- The `markdown` field is the prompt body — the same convention used by `defineInstructions({ markdown })` and `defineSkill({ markdown })`.
121
-
122
- ## Channel targeting
123
-
124
- When a schedule should deliver its output through a channel, use the `receive(channel, args)` helper from `experimental-ash/schedules`:
125
129
 
126
- ```ts
127
- import { receive } from "experimental-ash/schedules";
128
- import slack from "../channels/slack.js";
129
-
130
- receive(slack, { channelId: "C0123ABC" });
130
+ interface ScheduleHandlerArgs {
131
+ receive: CrossChannelReceiveFn;
132
+ waitUntil: (task: Promise<unknown>) => void;
133
+ appAuth: SessionAuthContext;
134
+ }
131
135
  ```
132
136
 
133
- The `args` object's shape is inferred from the channel's typed receive route Slack requires `channelId`; other channels define their own args shape. At compile time, Ash validates that the channel reference resolves to an authored channel under `agent/channels/` whose route implements a `receive()` method.
137
+ Exactly one of `markdown` or `run` must be provided. `defineSchedule` is a pass-through that exists to attach TypeScript types it does not validate at authoring time; the compiler enforces the exclusivity rule.
134
138
 
135
139
  ## Runtime behavior
136
140
 
137
141
  When a schedule fires, the dispatcher takes one of two paths:
138
142
 
139
- ### With a channel
143
+ ### Handler form (`run`)
140
144
 
141
- 1. The dispatcher resolves the channel route by name.
142
- 2. It calls `route.receive()` with the schedule's `markdown` and the channel-specific `args`.
143
- 3. The channel builds its adapter, starts a session, and handles output delivery the same way it handles webhook-initiated messages.
145
+ 1. The dispatcher loads the schedule's compiled module and reads `definition.run`.
146
+ 2. It builds `ScheduleHandlerArgs` against the request-scoped channel bundle and invokes `run(args)`.
147
+ 3. The handler decides everything: which channels to target, what auth to use, whether to short-circuit on holidays, etc.
148
+ 4. Any promises the handler passes to `waitUntil` are awaited before the Nitro task settles.
144
149
 
145
- ### Without a channel
150
+ ### Markdown form
146
151
 
147
- 1. The dispatcher starts a session through the runtime directly with a minimal scheduled-task adapter.
152
+ 1. The dispatcher starts a session through the runtime directly with the framework-owned `SCHEDULE_ADAPTER`.
148
153
  2. The agent runs in **task mode** with the app principal.
149
- 3. Output is discarded — but the agent is still free to call tools, write to backends, log, etc.
154
+ 3. Output is discarded.
150
155
 
151
156
  In both cases, the schedule session uses the **app principal**: `getSession().auth.current` and `getSession().auth.initiator` are set to `"ash:app"` with `principalType: "runtime"`.
152
157
 
153
158
  The session goes through the same durable runtime engine as any other Ash session.
154
159
 
155
- ## Migrating from the legacy directory form
156
-
157
- The directory form (`schedules/<name>/schedule.ts` + `schedules/<name>/prompt.md`) is no longer supported. To migrate, collapse each directory into a single file:
158
-
159
- ```text
160
- agent/schedules/daily-digest/schedule.ts → agent/schedules/daily-digest.ts
161
- agent/schedules/daily-digest/prompt.md → inlined as `markdown:` on
162
- the `defineSchedule({...})` call
163
- ```
164
-
165
- Discovery emits a clear migration diagnostic when it sees a legacy directory layout.
166
-
167
160
  ## When to use a schedule
168
161
 
169
162
  Use a schedule when the agent should initiate work on its own cadence:
@@ -1,53 +1,66 @@
1
1
  import type { ChannelAdapter } from "#channel/adapter.js";
2
2
  import { type Session } from "#channel/session.js";
3
- import type { Runtime } from "#channel/types.js";
3
+ import type { Runtime, SessionAuthContext } from "#channel/types.js";
4
+ import type { ScheduleRunHandler } from "#public/definitions/schedule.js";
4
5
  import type { ResolvedChannelDefinition } from "#runtime/types.js";
5
6
  /**
6
- * Durable adapter kind used when a schedule triggers a session that does
7
- * not target a channel.
7
+ * Pre-built application auth context handed to schedules. Schedules
8
+ * run on behalf of the agent itself, not a downstream user.
9
+ */
10
+ export declare const SCHEDULE_APP_AUTH: SessionAuthContext;
11
+ /**
12
+ * Durable adapter kind used when a schedule fires without targeting a
13
+ * channel — the markdown form, and the synthesized run the dispatcher
14
+ * builds for it.
8
15
  *
9
16
  * Framework-owned — authored code never constructs a schedule adapter
10
- * directly. Emitted by {@link ScheduleDispatcher.trigger} when the
11
- * authored schedule has no `channel` field (the only path available to
12
- * markdown schedules, which are forbidden from declaring a channel).
17
+ * directly. Registered in `FRAMEWORK_ADAPTERS`.
13
18
  */
14
19
  export declare const SCHEDULE_ADAPTER_KIND = "schedule";
20
+ export declare const SCHEDULE_ADAPTER: ChannelAdapter;
15
21
  /**
16
- * Framework adapter installed for channel-less schedules.
17
- *
18
- * Carries no behavior — it is a bare discriminator so the runtime adapter
19
- * registry can rehydrate `{ kind: "schedule" }` at every workflow step
20
- * boundary. Registered in `FRAMEWORK_ADAPTERS`
21
- * (`runtime/channels/registry.ts`).
22
+ * Loaded shape of one schedule for the dispatcher. Either `run` is
23
+ * defined (authored handler) or `markdown` is defined (fire-and-forget).
22
24
  */
23
- export declare const SCHEDULE_ADAPTER: ChannelAdapter;
24
- type ScheduleChannel = Pick<ResolvedChannelDefinition, "receive" | "adapter">;
25
- export interface ScheduleTriggerInput {
26
- readonly channel?: {
27
- readonly name: string;
28
- readonly args: Readonly<Record<string, unknown>>;
29
- };
30
- readonly markdown: string;
25
+ export interface ScheduleDispatchInput {
31
26
  readonly scheduleId: string;
27
+ readonly run?: ScheduleRunHandler;
28
+ readonly markdown?: string;
32
29
  }
33
30
  /**
34
- * Dispatcher for scheduled task execution.
31
+ * Dispatches scheduled task execution.
35
32
  *
36
- * When the schedule targets a channel, hands the message off to
37
- * `route.receive(input, { send })` the channel author's normal entry
38
- * point. When the schedule has no channel, starts the session through
39
- * the runtime directly with a minimal schedule adapter. Both branches
40
- * return a `Session` and let the workflow runtime own terminal
41
- * completion, matching the fire-and-forget semantics HTTP routes already
42
- * use.
33
+ * For handler schedules: builds {@link ScheduleHandlerArgs} against the
34
+ * request-scoped channel bundle and invokes the author's `run`. The
35
+ * author owns control flow `args.receive(channel, …)` hands work off
36
+ * to a channel; `args.waitUntil(promise)` extends the task lifetime
37
+ * so the dispatcher awaits in-flight work before settling.
38
+ *
39
+ * For markdown schedules: synthesizes a channel-less run that starts a
40
+ * session with {@link SCHEDULE_ADAPTER} in task mode and the markdown
41
+ * body as the message.
42
+ *
43
+ * Returns a {@link ScheduleDispatchResult} carrying any sessions the
44
+ * handler started (for telemetry / task-result observability) and the
45
+ * `waitUntil` promises the handler registered.
43
46
  */
47
+ export interface ScheduleDispatchResult {
48
+ readonly sessions: readonly Session[];
49
+ readonly waitUntilTasks: readonly Promise<unknown>[];
50
+ }
44
51
  export declare class ScheduleDispatcher {
45
52
  private readonly runtime;
46
- private readonly resolveChannel;
53
+ private readonly channels;
47
54
  constructor(config: {
48
55
  readonly runtime: Runtime;
49
- readonly resolveChannel: (channelName: string) => Promise<ScheduleChannel>;
56
+ readonly channels: readonly ResolvedChannelDefinition[];
50
57
  });
51
- trigger(input: ScheduleTriggerInput): Promise<Session>;
58
+ trigger(input: ScheduleDispatchInput): Promise<ScheduleDispatchResult>;
59
+ private runMarkdown;
52
60
  }
53
- export {};
61
+ /**
62
+ * Convenience: extract a `run` function from one loaded schedule module
63
+ * value, or throw with the file path so misconfigured modules fail
64
+ * obviously instead of crashing deep inside the dispatcher.
65
+ */
66
+ export declare function expectScheduleRun(value: unknown, logicalPath: string, exportName: string | undefined): ScheduleRunHandler;
@@ -1,68 +1,81 @@
1
- import { invokeChannelReceive } from "#channel/cross-channel-receive.js";
1
+ import { createCrossChannelReceiveFn } from "#channel/cross-channel-receive.js";
2
2
  import { createSession } from "#channel/session.js";
3
- const APP_AUTH = {
3
+ import { expectFunction } from "#internal/authored-module.js";
4
+ /**
5
+ * Pre-built application auth context handed to schedules. Schedules
6
+ * run on behalf of the agent itself, not a downstream user.
7
+ */
8
+ export const SCHEDULE_APP_AUTH = {
4
9
  attributes: {},
5
10
  authenticator: "app",
6
11
  principalId: "ash:app",
7
12
  principalType: "runtime",
8
13
  };
9
14
  /**
10
- * Durable adapter kind used when a schedule triggers a session that does
11
- * not target a channel.
15
+ * Durable adapter kind used when a schedule fires without targeting a
16
+ * channel the markdown form, and the synthesized run the dispatcher
17
+ * builds for it.
12
18
  *
13
19
  * Framework-owned — authored code never constructs a schedule adapter
14
- * directly. Emitted by {@link ScheduleDispatcher.trigger} when the
15
- * authored schedule has no `channel` field (the only path available to
16
- * markdown schedules, which are forbidden from declaring a channel).
20
+ * directly. Registered in `FRAMEWORK_ADAPTERS`.
17
21
  */
18
22
  export const SCHEDULE_ADAPTER_KIND = "schedule";
19
- /**
20
- * Framework adapter installed for channel-less schedules.
21
- *
22
- * Carries no behavior — it is a bare discriminator so the runtime adapter
23
- * registry can rehydrate `{ kind: "schedule" }` at every workflow step
24
- * boundary. Registered in `FRAMEWORK_ADAPTERS`
25
- * (`runtime/channels/registry.ts`).
26
- */
27
23
  export const SCHEDULE_ADAPTER = {
28
24
  kind: SCHEDULE_ADAPTER_KIND,
29
25
  };
30
- /**
31
- * Dispatcher for scheduled task execution.
32
- *
33
- * When the schedule targets a channel, hands the message off to
34
- * `route.receive(input, { send })` — the channel author's normal entry
35
- * point. When the schedule has no channel, starts the session through
36
- * the runtime directly with a minimal schedule adapter. Both branches
37
- * return a `Session` and let the workflow runtime own terminal
38
- * completion, matching the fire-and-forget semantics HTTP routes already
39
- * use.
40
- */
41
26
  export class ScheduleDispatcher {
42
27
  runtime;
43
- resolveChannel;
28
+ channels;
44
29
  constructor(config) {
45
30
  this.runtime = config.runtime;
46
- this.resolveChannel = config.resolveChannel;
31
+ this.channels = config.channels;
47
32
  }
48
33
  async trigger(input) {
49
- if (input.channel === undefined) {
50
- const handle = await this.runtime.run({
51
- adapter: SCHEDULE_ADAPTER,
52
- auth: APP_AUTH,
53
- input: { message: input.markdown },
54
- mode: "task",
55
- });
56
- return createSession(handle.sessionId, handle.continuationToken, this.runtime);
34
+ const sessions = [];
35
+ const waitUntilTasks = [];
36
+ const receive = createCrossChannelReceiveFn(this.runtime, this.channels);
37
+ const args = {
38
+ appAuth: SCHEDULE_APP_AUTH,
39
+ receive: async (channel, options) => {
40
+ const session = await receive(channel, options);
41
+ sessions.push(session);
42
+ return session;
43
+ },
44
+ waitUntil(task) {
45
+ waitUntilTasks.push(task);
46
+ },
47
+ };
48
+ if (input.run) {
49
+ await input.run(args);
57
50
  }
58
- const { channel } = input;
59
- const route = await this.resolveChannel(channel.name);
60
- return await invokeChannelReceive({
61
- runtime: this.runtime,
62
- target: { name: channel.name, receive: route.receive, adapter: route.adapter },
63
- input: { message: input.markdown, args: channel.args, auth: APP_AUTH },
64
- describeMissingReceive: () => `Schedule "${input.scheduleId}" targets channel "${channel.name}" but that channel does not implement receive().`,
65
- describeMissingAdapter: () => `Schedule "${input.scheduleId}" targets channel "${channel.name}" but that channel has no adapter — cannot build send().`,
51
+ else if (input.markdown !== undefined) {
52
+ const session = await this.runMarkdown(input.markdown);
53
+ sessions.push(session);
54
+ }
55
+ else {
56
+ throw new Error(`Schedule "${input.scheduleId}" has neither "run" nor "markdown" at least one must be set.`);
57
+ }
58
+ return { sessions, waitUntilTasks };
59
+ }
60
+ async runMarkdown(markdown) {
61
+ const handle = await this.runtime.run({
62
+ adapter: SCHEDULE_ADAPTER,
63
+ auth: SCHEDULE_APP_AUTH,
64
+ input: { message: markdown },
65
+ mode: "task",
66
66
  });
67
+ return createSession(handle.sessionId, handle.continuationToken, this.runtime);
68
+ }
69
+ }
70
+ /**
71
+ * Convenience: extract a `run` function from one loaded schedule module
72
+ * value, or throw with the file path so misconfigured modules fail
73
+ * obviously instead of crashing deep inside the dispatcher.
74
+ */
75
+ export function expectScheduleRun(value, logicalPath, exportName) {
76
+ const definition = value;
77
+ if (definition === null || typeof definition !== "object") {
78
+ throw new Error(`Schedule export "${exportName ?? "default"}" from "${logicalPath}" must be an object.`);
67
79
  }
80
+ return expectFunction(definition.run, `Expected the schedule export "${exportName ?? "default"}" from "${logicalPath}" to export a \`run\` handler function.`);
68
81
  }
@@ -0,0 +1,2 @@
1
+ import{createRequire as e}from"node:module";import{dirname as t,join as n,resolve as r,sep as i}from"node:path";import{createHash as a}from"node:crypto";import{existsSync as o,mkdirSync as s,readFileSync as c,writeFileSync as l}from"node:fs";import{pathToFileURL as u}from"node:url";function d(e,t){return e[t.exportName??`default`]}async function f(e){return typeof e==`function`?await e():e}function p(e,t){if(typeof e!=`object`||!e||Array.isArray(e))throw Error(t);return e}function m(e,t){if(typeof e!=`string`)throw Error(t);return e}function h(e,t){if(typeof e!=`function`)throw Error(t);return e}function g(e,t){let n=p(e,t),r={};for(let[e,i]of Object.entries(n))r[e]=p(i,t);return r}function _(e,t,n){let r=new Set(t);for(let t of Object.keys(e))if(!r.has(t))throw Error(`${n} Unknown key "${t}".`)}function v(e,t,n){let r=e[t];if(r===void 0)return;let i=p(r,n),a={};for(let[e,t]of Object.entries(i))a[e]=m(t,n);return a}let y,b;function x(){return y??=(async()=>await import(u(e(e(import.meta.url).resolve(`nitro/package.json`)).resolve(`rolldown`)).href))(),y}function S(){return b??=(async()=>await import(u(e(e(import.meta.url).resolve(`nitro/package.json`)).resolve(`rolldown/parseAst`)).href))(),b}async function C(e){let{build:t}=await x();return await t(e)}function w(e,t){let n=e.output.filter(e=>e.type===`chunk`),r=n[0];if(r===void 0||n.length!==1)throw Error(`Expected one bundled ${t}.`);return r}const T=[{importLine:`import { fileURLToPath as __ashFileURLToPath } from "node:url";`,declarationLine:`const __filename = __ashFileURLToPath(import.meta.url);`,bindingPattern:/^(?:const|let|var)\s+__filename\b/m},{importLine:`import { dirname as __ashDirname } from "node:path";`,declarationLine:`const __dirname = __ashDirname(__filename);`,bindingPattern:/^(?:const|let|var)\s+__dirname\b/m}],E={importLine:`import { createRequire as __ashCreateRequire } from "node:module";`,declarationLine:`const require = __ashCreateRequire(import.meta.url);`,bindingPattern:/^(?:const|let|var)\s+require\b/m};function D(e,t={}){let n=[...T];t.includeRequire===!0&&n.push(E);let r=[],i=[];for(let t of n)t.bindingPattern.test(e)||(r.push(t.importLine),i.push(t.declarationLine));return i.length===0?``:[...r,...i].join(`
2
+ `)}function O(e={}){return{name:`ash-node-esm-compat-banner`,renderChunk(t){let n=D(t,e);return n===``?null:{code:`${n}\n${t}`}}}}const k=/\.[cm]?[jt]sx?$/,A=n(`node_modules`,`.cache`,`experimental-ash`,`authored-modules`),j=[`.ts`,`.tsx`,`.mts`,`.cts`,`.js`,`.jsx`,`.mjs`,`.cjs`,`.json`],M=new Map;function N(e){let t=r(e),n=M.get(t);if(n!==void 0)return n;let i=(async()=>{try{return await P(e)}finally{M.delete(t)}})();return M.set(t,i),i}async function P(e){return p(k.test(e)?await I(e):await import(F(e)),`Expected "${e}" to export a module namespace object.`)}function F(e){let t=e.replaceAll(`\\`,`/`);return/^[A-Za-z]:\//.test(t)?`file:///${encodeURI(t)}`:t.startsWith(`/`)?`file://${encodeURI(t)}`:t}async function I(e){let t=q(e),r=n(t,`tsconfig.json`),i=R({tsconfigPath:r}),c=w(await C({cwd:t,input:e,platform:`node`,plugins:[O({includeRequire:!0}),L(t),i].filter(e=>e!==null),resolve:{extensions:[...j]},tsconfig:o(r)?r:!1,write:!1,output:{comments:!1,format:`esm`,sourcemap:`inline`}}),`authored module for "${e}"`),u=a(`sha1`).update(e).update(`\0`).update(c.code).digest(`hex`),d=n(t,A),f=n(d,`${u}.mjs`);return o(f)||(s(d,{recursive:!0}),l(f,c.code)),await import(`${F(f)}?v=${u}`)}function L(e){return{name:`ash-package-boundary`,async resolveId(t,n,i){if(!U(t))return;if(W(t))return{external:!0,id:t};let a=n===void 0||n.startsWith(`\0`)?void 0:r(n);if(a!==void 0&&K(a,e)){let e=await this.resolve(t,n,{kind:i.kind,skipSelf:!0});if(e===null||typeof e.id!=`string`||G(e.id))return{external:!0,id:t}}}}}function R(e){let t=z(e);return t.length===0?null:{name:`ash-tsconfig-path-alias`,resolveId(e){for(let n of t){let t=B(n.pattern,e);if(t!==null)for(let e of n.targets){let n=V(e,t);if(n!==null)return n}}}}}function z(e){if(!o(e.tsconfigPath))return[];try{let n=JSON.parse(c(e.tsconfigPath,`utf8`)),i=n.compilerOptions?.paths;if(typeof i!=`object`||!i||Array.isArray(i))return[];let a=typeof n.compilerOptions?.baseUrl==`string`?n.compilerOptions.baseUrl:`.`,o=r(t(e.tsconfigPath),a);return Object.entries(i).flatMap(([e,t])=>{if(!Array.isArray(t))return[];let n=t.flatMap(e=>typeof e==`string`?[r(o,e)]:[]);return n.length===0?[]:[{pattern:e,targets:n}]})}catch{return[]}}function B(e,t){let n=e.indexOf(`*`);if(n===-1)return e===t?``:null;let r=e.slice(0,n),i=e.slice(n+1);return!t.startsWith(r)||!t.endsWith(i)?null:t.slice(r.length,t.length-i.length)}function V(e,t){let r=e.replace(`*`,t),i=H(r);if(i!==null)return i;for(let e of j){let t=H(`${r}${e}`);if(t!==null)return t}for(let e of j){let t=H(n(r,`index${e}`));if(t!==null)return t}return null}function H(e){return o(e)?e:null}function U(e){return!(e.startsWith(`.`)||e.startsWith(`/`)||/^[A-Za-z]:[\\/]/.test(e)||/^(?:node|data|file):/.test(e)||e.startsWith(`@/`))}function W(e){return e===`experimental-ash`||e.startsWith(`experimental-ash/`)}function G(e){return e.replaceAll(`\\`,`/`).includes(`/node_modules/`)}function K(e,t){let n=r(e),a=r(t);return n===a||n.startsWith(`${a}${i}`)}function q(e){let r=t(e);for(;;){if(o(n(r,`package.json`)))return r;let i=t(r);if(i===r)throw Error(`Failed to resolve the authored package root for "${e}".`);r=i}}export{S as a,_ as c,d,v as f,w as i,g as l,O as n,h as o,f as p,C as r,p as s,N as t,m as u};