Skip to content

Commit 3192292

Browse files
feat(web): deploy schedules become cron rows addressed at the agent's run (CL-8534) (#925)
* test(web): pin five-field cron validation and routine schedule shape (CL-8534) Reintroduce coverage for a deploy package's cron string ahead of restoring the schedule field, and update the routines fixture for the new schedule column. * feat(web): deploy schedules become cron rows addressed at the agent's run (CL-8534) A deployed agent's optional five-field cron schedule now creates a @corbits/cron row addressed at that deploy's run, since Interchange's own schedule trigger is reserved but never fires. The Workflows page reads the schedule back by joining GET /cron against each deployment's run address, replacing the old "manual" placeholder. * fix(web): cron rows use the deployment id as the run address (CL-8534)
1 parent a62ed2e commit 3192292

9 files changed

Lines changed: 156 additions & 20 deletions

agents/myra/src/system-prompt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export const ASSISTANT_SYSTEM_PROMPT =
2424
"package in your working tree, then reply with its two files as " +
2525
"fenced code blocks, each labelled with its filename on the line " +
2626
'above the fence — a "package.json" plus a "definition.json" holding {"name", ' +
27-
'"description", "systemPrompt"} — a one-line summary of what it ' +
27+
'"description", "systemPrompt", and an optional five-field cron ' +
28+
'"schedule" for a routine} — a one-line summary of what it ' +
2829
"does, and a note to press Deploy. Workbench renders and deploys the " +
2930
"package itself from those two files, so send exactly them and " +
3031
"never try to deploy anything yourself. New capabilities, " +

apps/web/src/agent-deploy.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,40 @@ export type NewAgentInput = {
208208
* redeploying or re-joining an existing agent) — used verbatim instead
209209
* of being re-derived from `name`, so the asset name stays stable. */
210210
readonly slug?: string;
211+
/** A five-field cron expression: on success, a `@corbits/cron` schedule
212+
* row is created addressed at this deploy's run, so the ticker mails it
213+
* on that cadence. */
214+
readonly schedule?: string;
211215
};
212216

217+
function cronPath(tenantId: string): string {
218+
return `/api/tenants/${encodeURIComponent(tenantId)}/cron`;
219+
}
220+
221+
/** Creates a `@corbits/cron` schedule row addressed at a deployed agent's
222+
* run — the only way an agent fires on a cadence, since Interchange's
223+
* `schedule` trigger is reserved but unimplemented. */
224+
async function scheduleAgentRun(
225+
tenantId: string,
226+
expression: string,
227+
runAddress: string,
228+
fetchImpl: typeof fetch,
229+
): Promise<void> {
230+
const created = await fetchImpl(cronPath(tenantId), {
231+
method: "POST",
232+
headers: { "content-type": "application/json" },
233+
body: JSON.stringify({
234+
expression,
235+
toAddress: runAddress,
236+
subject: "Scheduled run",
237+
body: "This is your scheduled run. Do the work your definition describes and reply with the result.",
238+
}),
239+
});
240+
if (!created.ok) {
241+
throw new AgentDeployError(`scheduling this agent failed: ${await readErrorBody(created)}`);
242+
}
243+
}
244+
213245
export type DeployedAgent = typeof WorkflowDeploymentResponse.infer;
214246

215247
/**
@@ -289,5 +321,15 @@ export async function deployAgentSource(
289321
if (parsed instanceof type.errors) {
290322
throw new AgentDeployError(`this deployment came back an unexpected shape: ${parsed.summary}`);
291323
}
324+
if (args.input.schedule !== undefined) {
325+
await scheduleAgentRun(
326+
args.tenantId,
327+
args.input.schedule,
328+
// The deployment id is the top-level run id (already `run_…`), and
329+
// the run address is that id at the tenant domain.
330+
`${parsed.id}@${tenant.domain}`,
331+
fetchImpl,
332+
);
333+
}
292334
return parsed;
293335
}

apps/web/src/chat/deployable-package.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, test } from "bun:test";
22

3-
import { deployablePackageFromBody, resolveMessagePackage } from "./deployable-package";
3+
import {
4+
deployablePackageFromBody,
5+
isFiveFieldCron,
6+
resolveMessagePackage,
7+
} from "./deployable-package";
48

59
const PACKAGE_JSON = `{"name": "echo", "version": "1.0.0"}`;
610
const DEFINITION_JSON = `{"name": "Echo", "systemPrompt": "Echo back what you hear."}`;
@@ -50,6 +54,19 @@ describe("deployablePackageFromBody", () => {
5054
});
5155
});
5256

57+
describe("isFiveFieldCron", () => {
58+
test("accepts exactly five whitespace-separated fields", () => {
59+
expect(isFiveFieldCron("0 9 * * *")).toBe(true);
60+
expect(isFiveFieldCron(" */5 * * * * ")).toBe(true);
61+
});
62+
63+
test("rejects anything else", () => {
64+
expect(isFiveFieldCron("not a cron")).toBe(false);
65+
expect(isFiveFieldCron("* * * *")).toBe(false);
66+
expect(isFiveFieldCron("* * * * * *")).toBe(false);
67+
});
68+
});
69+
5370
describe("resolveMessagePackage", () => {
5471
test("falls back to the body when there are no attachments", () => {
5572
const body = `package.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\`\ndefinition.json\n\`\`\`\n${DEFINITION_JSON}\n\`\`\``;

apps/web/src/chat/deployable-package.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// The one contract between an agent that writes a package and the client
22
// that deploys it: a reply carrying `package.json` plus a
3-
// `definition.json` of {name, systemPrompt, description?} —
3+
// `definition.json` of {name, systemPrompt, description?, schedule?} —
44
// either as mail attachments, or (since `@intx/tools-mail`'s `mail_send`
55
// has no attachments parameter) as two labelled fenced code blocks in the
66
// message body. The client renders the source tree itself
@@ -16,8 +16,16 @@ const AgentDefinition = type({
1616
name: "string",
1717
systemPrompt: "string",
1818
"description?": "string",
19+
"schedule?": "string",
1920
});
2021

22+
/** A cron string this pipeline accepts: exactly five whitespace-separated
23+
* fields. No third-party parser — the fields are validated for shape only,
24+
* `@corbits/cron`'s `isValidCronExpression` is the semantic check. */
25+
export function isFiveFieldCron(schedule: string): boolean {
26+
return schedule.trim().split(/\s+/).length === 5;
27+
}
28+
2129
export type DeployablePackage = typeof AgentDefinition.infer;
2230

2331
export const PACKAGE_MANIFEST_NAME = "package.json";

apps/web/src/chat/message-attachments.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
// agent never calls the hub; anything else is a plain file list.
44

55
import { Button } from "@corbits/react-ui";
6+
import { cronSentence } from "@corbits/workflows/client";
67

78
import { useDeployAgentMutation } from "../agents-api";
89
import { chatPath } from "../chat-path";
910
import { Link } from "../navigation";
10-
import type { DeployablePackage } from "./deployable-package";
11+
import { isFiveFieldCron, type DeployablePackage } from "./deployable-package";
1112
import type { MailAttachment } from "./threads-api";
1213

1314
function errorText(cause: unknown): string {
@@ -50,23 +51,32 @@ function DeployPackageCard({
5051
}) {
5152
const deploy = useDeployAgentMutation(tenantId);
5253
const deployed = deploy.data;
54+
const scheduleValid = pkg.schedule === undefined || isFiveFieldCron(pkg.schedule);
55+
const sentence = pkg.schedule !== undefined && scheduleValid ? cronSentence(pkg.schedule) : null;
5356
return (
5457
<div className="chat-deploy-card">
5558
<div className="chat-deploy-card-text">
5659
<span className="chat-deploy-card-name">{pkg.name}</span>
5760
{pkg.description === undefined ? null : (
5861
<span className="chat-deploy-card-note">{pkg.description}</span>
5962
)}
63+
{sentence !== null ? <span className="chat-deploy-card-note">{sentence}</span> : null}
64+
{pkg.schedule !== undefined && !scheduleValid ? (
65+
<span className="chat-deploy-card-error">
66+
{`This package's schedule ("${pkg.schedule}") isn't a valid five-field cron string.`}
67+
</span>
68+
) : null}
6069
</div>
6170
{deployed === undefined ? (
6271
<Button
6372
variant="primary"
6473
size="sm"
65-
disabled={deploy.isPending}
74+
disabled={deploy.isPending || !scheduleValid}
6675
onClick={() =>
6776
deploy.mutate({
6877
name: pkg.name,
6978
systemPrompt: pkg.systemPrompt,
79+
...(pkg.schedule !== undefined ? { schedule: pkg.schedule } : {}),
7080
})
7181
}
7282
>

apps/web/src/insights-stats.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function scheduled(
3636
tenantId: "t1",
3737
createdAt: "2026-01-01T00:00:00.000Z",
3838
updatedAt: "2026-01-01T00:00:00.000Z",
39+
schedule: "0 9 * * *",
3940
...partial,
4041
};
4142
}

apps/web/src/pages/routine-detail-page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type { GlobalRoutineRow } from "../global-routines";
2929
import { Link } from "../navigation";
3030
import { WORKFLOWS_PATH_PREFIX } from "../path-ids";
3131
import { StageTopBar } from "../shell/stage-top-bar";
32+
import { scheduleSentence } from "./routines-page";
3233

3334
const RunsPageSchema = paginatedSchema(WorkflowRunResponse);
3435
type RunRow = typeof WorkflowRunResponse.infer;
@@ -197,13 +198,15 @@ export function RoutineDetailPage({
197198
readonly onRunNow: () => Promise<void>;
198199
}) {
199200
const enabled = row.definition.status === "deployed";
201+
const sentence = scheduleSentence(row.definition.schedule);
200202
return (
201203
<div className="flex h-full min-h-0 flex-col">
202204
<StageTopBar
203205
crumbs={[
204206
{ label: "Workflows", href: WORKFLOWS_PATH_PREFIX },
205207
{ label: row.definition.name },
206208
]}
209+
subtitle={sentence}
207210
actions={
208211
<div className="flex items-center gap-2">
209212
<Button
@@ -223,6 +226,7 @@ export function RoutineDetailPage({
223226
<div>
224227
<h1 className="m-0 text-xl font-semibold">{row.definition.name}</h1>
225228
<p className="mt-2 text-sm text-[var(--ui-fg-muted)]">{row.tenantName}</p>
229+
<p className="mt-4 text-lg">{sentence}</p>
226230
</div>
227231
<RoutineRunsSection tenantId={row.tenantId} definitionId={row.definition.definitionId} />
228232
</div>

apps/web/src/pages/routines-page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
TableHeader,
1313
TableRow,
1414
} from "@corbits/react-ui";
15+
import { cronSentence } from "@corbits/workflows/client";
1516
import { Clock } from "@/lib/icons";
1617

1718
import { useGlobalRoutines, useRoutineActions } from "../global-routines";
@@ -22,6 +23,13 @@ import { StageTopBar } from "../shell/stage-top-bar";
2223

2324
export type { GlobalRoutineRow } from "../global-routines";
2425

26+
/** A schedule's human sentence, the raw expression when it can't be
27+
* described, or "Not scheduled" when the deployment carries no cron row. */
28+
export function scheduleSentence(schedule: string | null): string {
29+
if (schedule === null) return "Not scheduled";
30+
return cronSentence(schedule) ?? schedule;
31+
}
32+
2533
export function GlobalRoutinesList({
2634
rows,
2735
onToggleEnabled,
@@ -45,6 +53,7 @@ export function GlobalRoutinesList({
4553
<TableHeader>
4654
<TableRow>
4755
<TableHead>Routine</TableHead>
56+
<TableHead>Schedule</TableHead>
4857
<TableHead>On</TableHead>
4958
<TableHead>Actions</TableHead>
5059
</TableRow>
@@ -69,6 +78,9 @@ export function GlobalRoutinesList({
6978
<span className="text-xs text-[var(--ui-fg-muted)]">{row.tenantName}</span>
7079
</span>
7180
</TableCell>
81+
<TableCell>
82+
<span className="text-sm">{scheduleSentence(row.definition.schedule)}</span>
83+
</TableCell>
7284
<TableCell>
7385
<Switch
7486
checked={enabled}

apps/web/src/routines-api.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66
// stock reads `vendor/intx/hub-api/src/routes/workflows.ts` exposes.
77
//
88
// The `schedule` trigger is reserved on Interchange but unimplemented — no
9-
// scheduler fires it — so this reads deployments as plain workflows, with
10-
// no schedule concept. Run-now and pause/resume have
11-
// no backing stock route either (`/deployments` is list/create only; no
12-
// per-deployment PATCH or trigger route exists), so both stay rejected
13-
// promises with a message naming the missing route, same pattern as before.
9+
// scheduler fires it — so a schedule here is a `@corbits/cron` row addressed
10+
// at the deployment's live run, joined in from `GET /cron` by that address
11+
// (the same `run_<id>@<domain>` join `chat/threads-api.ts` does against
12+
// `listTopLevelRuns`). Run-now and pause/resume have no backing stock route
13+
// either (`/deployments` is list/create only; no per-deployment PATCH or
14+
// trigger route exists), so both stay rejected promises with a message
15+
// naming the missing route, same pattern as before.
1416

1517
import { type } from "arktype";
1618
import { useQuery } from "@tanstack/react-query";
1719
import { WorkflowDeploymentResponse } from "@intx/types";
1820
import type { APIQuery } from "@/lib/api-query";
1921
import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query";
2022
import { isAgentDeploySourceAssetName } from "@/agent-deploy";
23+
import { listTopLevelRuns } from "@/agents-api";
2124
import { MYRA_SOURCE_CONFIG } from "@/myra-source";
2225

2326
export const ScheduledWorkflowDefinition = type({
@@ -28,10 +31,27 @@ export const ScheduledWorkflowDefinition = type({
2831
status: "'deployed' | 'stopped'",
2932
createdAt: "string",
3033
updatedAt: "string",
34+
/** The cron expression firing this deployment's live run, or null when no
35+
* `@corbits/cron` row is addressed at it. */
36+
schedule: "string | null",
3137
});
3238

3339
export type ScheduledWorkflowDefinition = typeof ScheduledWorkflowDefinition.infer;
3440

41+
export const CronSchedule = type({
42+
id: "string",
43+
tenantId: "string",
44+
expression: "string",
45+
toAddress: "string",
46+
subject: "string",
47+
body: "string",
48+
createdAt: "string",
49+
});
50+
51+
export type CronSchedule = typeof CronSchedule.infer;
52+
53+
const CronSchedulesResponse = type({ schedules: CronSchedule.array() });
54+
3555
const DeploymentsSchema = WorkflowDeploymentResponse.array();
3656
const WorkflowAssetSchema = type({ id: "string", name: "string" });
3757
const WorkflowAssetsSchema = WorkflowAssetSchema.array();
@@ -44,6 +64,16 @@ function workflowAssetsPath(tenantId: string): string {
4464
return `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`;
4565
}
4666

67+
function cronPath(tenantId: string): string {
68+
return `/api/tenants/${tenantId}/cron`;
69+
}
70+
71+
/** Every cron schedule saved on this tenant. */
72+
export async function listCronSchedules(tenantId: string): Promise<readonly CronSchedule[]> {
73+
const parsed = await fetchJSON(cronPath(tenantId), CronSchedulesResponse);
74+
return parsed.schedules;
75+
}
76+
4777
async function fetchJSON<T>(path: string, schema: (data: unknown) => T | type.errors): Promise<T> {
4878
const response = await fetch(path, { headers: { accept: "application/json" } });
4979
if (response.status === 401) throw new UnauthenticatedError();
@@ -72,25 +102,36 @@ function isAgentAssetName(name: string): boolean {
72102
export async function listScheduledWorkflows(
73103
tenantId: string,
74104
): Promise<readonly ScheduledWorkflowDefinition[]> {
75-
const [deployments, assets] = await Promise.all([
105+
const [deployments, assets, runs, schedules] = await Promise.all([
76106
fetchJSON(deploymentsPath(tenantId), DeploymentsSchema),
77107
fetchJSON(workflowAssetsPath(tenantId), WorkflowAssetsSchema),
108+
listTopLevelRuns(tenantId),
109+
listCronSchedules(tenantId),
78110
]);
79111
const nameByAssetId = new Map(assets.map((asset) => [asset.id, asset.name]));
112+
// A deployment's own id is its anchor run's id (see `chat/threads-api.ts`'s
113+
// `listChatAgents`), so this is the same join that resolves a chat agent's
114+
// live address.
115+
const addressByRunId = new Map(runs.map((run) => [run.id, run.address]));
116+
const expressionByAddress = new Map(schedules.map((row) => [row.toAddress, row.expression]));
80117
return deployments
81118
.filter((deployment) => {
82119
const name = nameByAssetId.get(deployment.definitionAssetId);
83120
return name === undefined || !isAgentAssetName(name);
84121
})
85-
.map((deployment) => ({
86-
definitionId: deployment.id,
87-
assetId: deployment.definitionAssetId,
88-
name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow",
89-
tenantId: deployment.tenantId,
90-
status: deployment.status === "deployed" ? "deployed" : "stopped",
91-
createdAt: deployment.createdAt,
92-
updatedAt: deployment.createdAt,
93-
}));
122+
.map((deployment) => {
123+
const address = addressByRunId.get(deployment.id);
124+
return {
125+
definitionId: deployment.id,
126+
assetId: deployment.definitionAssetId,
127+
name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow",
128+
tenantId: deployment.tenantId,
129+
status: deployment.status === "deployed" ? "deployed" : "stopped",
130+
createdAt: deployment.createdAt,
131+
updatedAt: deployment.createdAt,
132+
schedule: address === undefined ? null : (expressionByAddress.get(address) ?? null),
133+
};
134+
});
94135
}
95136

96137
/** No stock route reruns a deployment on demand yet. */

0 commit comments

Comments
 (0)