From e593cad9a4facbe0a97eec0455d1caf21cf02dbf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 10:16:05 -0700 Subject: [PATCH 1/3] 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. --- apps/web/src/chat/deployable-package.test.ts | 19 ++++++++++++++++++- apps/web/src/insights-stats.test.ts | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/web/src/chat/deployable-package.test.ts b/apps/web/src/chat/deployable-package.test.ts index 5a8a3f127..72dc1b0cf 100644 --- a/apps/web/src/chat/deployable-package.test.ts +++ b/apps/web/src/chat/deployable-package.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { deployablePackageFromBody, resolveMessagePackage } from "./deployable-package"; +import { + deployablePackageFromBody, + isFiveFieldCron, + resolveMessagePackage, +} from "./deployable-package"; const PACKAGE_JSON = `{"name": "echo", "version": "1.0.0"}`; const DEFINITION_JSON = `{"name": "Echo", "systemPrompt": "Echo back what you hear."}`; @@ -50,6 +54,19 @@ describe("deployablePackageFromBody", () => { }); }); +describe("isFiveFieldCron", () => { + test("accepts exactly five whitespace-separated fields", () => { + expect(isFiveFieldCron("0 9 * * *")).toBe(true); + expect(isFiveFieldCron(" */5 * * * * ")).toBe(true); + }); + + test("rejects anything else", () => { + expect(isFiveFieldCron("not a cron")).toBe(false); + expect(isFiveFieldCron("* * * *")).toBe(false); + expect(isFiveFieldCron("* * * * * *")).toBe(false); + }); +}); + describe("resolveMessagePackage", () => { test("falls back to the body when there are no attachments", () => { const body = `package.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\`\ndefinition.json\n\`\`\`\n${DEFINITION_JSON}\n\`\`\``; diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 96f540a3a..37660d90d 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -36,6 +36,7 @@ function scheduled( tenantId: "t1", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", + schedule: "0 9 * * *", ...partial, }; } From 50cfb13eb375434e69ab3959586e9295cbd2fe17 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 10:16:12 -0700 Subject: [PATCH 2/3] 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. --- agents/myra/src/system-prompt.ts | 3 +- apps/web/src/agent-deploy.ts | 40 ++++++++++++ apps/web/src/chat/deployable-package.ts | 10 ++- apps/web/src/chat/message-attachments.tsx | 14 ++++- apps/web/src/pages/routine-detail-page.tsx | 4 ++ apps/web/src/pages/routines-page.tsx | 12 ++++ apps/web/src/routines-api.ts | 71 +++++++++++++++++----- 7 files changed, 135 insertions(+), 19 deletions(-) diff --git a/agents/myra/src/system-prompt.ts b/agents/myra/src/system-prompt.ts index 4c6c43f02..ce16238b3 100644 --- a/agents/myra/src/system-prompt.ts +++ b/agents/myra/src/system-prompt.ts @@ -24,7 +24,8 @@ export const ASSISTANT_SYSTEM_PROMPT = "package in your working tree, then reply with its two files as " + "fenced code blocks, each labelled with its filename on the line " + 'above the fence — a "package.json" plus a "definition.json" holding {"name", ' + - '"description", "systemPrompt"} — a one-line summary of what it ' + + '"description", "systemPrompt", and an optional five-field cron ' + + '"schedule" for a routine} — a one-line summary of what it ' + "does, and a note to press Deploy. Workbench renders and deploys the " + "package itself from those two files, so send exactly them and " + "never try to deploy anything yourself. New capabilities, " + diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index 500e3eb71..94b53681d 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -206,8 +206,40 @@ export type NewAgentInput = { * redeploying or re-joining an existing agent) — used verbatim instead * of being re-derived from `name`, so the asset name stays stable. */ readonly slug?: string; + /** A five-field cron expression: on success, a `@corbits/cron` schedule + * row is created addressed at this deploy's run, so the ticker mails it + * on that cadence. */ + readonly schedule?: string; }; +function cronPath(tenantId: string): string { + return `/api/tenants/${encodeURIComponent(tenantId)}/cron`; +} + +/** Creates a `@corbits/cron` schedule row addressed at a deployed agent's + * run — the only way an agent fires on a cadence, since Interchange's + * `schedule` trigger is reserved but unimplemented. */ +async function scheduleAgentRun( + tenantId: string, + expression: string, + runAddress: string, + fetchImpl: typeof fetch, +): Promise { + const created = await fetchImpl(cronPath(tenantId), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expression, + toAddress: runAddress, + subject: "Scheduled run", + body: "This is your scheduled run. Do the work your definition describes and reply with the result.", + }), + }); + if (!created.ok) { + throw new AgentDeployError(`scheduling this agent failed: ${await readErrorBody(created)}`); + } +} + export type DeployedAgent = typeof WorkflowDeploymentResponse.infer; /** @@ -285,5 +317,13 @@ export async function deployAgentSource( if (parsed instanceof type.errors) { throw new AgentDeployError(`this deployment came back an unexpected shape: ${parsed.summary}`); } + if (args.input.schedule !== undefined) { + await scheduleAgentRun( + args.tenantId, + args.input.schedule, + `run_${parsed.id}@${tenant.domain}`, + fetchImpl, + ); + } return parsed; } diff --git a/apps/web/src/chat/deployable-package.ts b/apps/web/src/chat/deployable-package.ts index 0d79d83c9..8d0f5e8da 100644 --- a/apps/web/src/chat/deployable-package.ts +++ b/apps/web/src/chat/deployable-package.ts @@ -1,6 +1,6 @@ // The one contract between an agent that writes a package and the client // that deploys it: a reply carrying `package.json` plus a -// `definition.json` of {name, systemPrompt, description?} — +// `definition.json` of {name, systemPrompt, description?, schedule?} — // either as mail attachments, or (since `@intx/tools-mail`'s `mail_send` // has no attachments parameter) as two labelled fenced code blocks in the // message body. The client renders the source tree itself @@ -16,8 +16,16 @@ const AgentDefinition = type({ name: "string", systemPrompt: "string", "description?": "string", + "schedule?": "string", }); +/** A cron string this pipeline accepts: exactly five whitespace-separated + * fields. No third-party parser — the fields are validated for shape only, + * `@corbits/cron`'s `isValidCronExpression` is the semantic check. */ +export function isFiveFieldCron(schedule: string): boolean { + return schedule.trim().split(/\s+/).length === 5; +} + export type DeployablePackage = typeof AgentDefinition.infer; export const PACKAGE_MANIFEST_NAME = "package.json"; diff --git a/apps/web/src/chat/message-attachments.tsx b/apps/web/src/chat/message-attachments.tsx index fec3cc620..29ec6c3e0 100644 --- a/apps/web/src/chat/message-attachments.tsx +++ b/apps/web/src/chat/message-attachments.tsx @@ -3,11 +3,12 @@ // agent never calls the hub; anything else is a plain file list. import { Button } from "@corbits/react-ui"; +import { cronSentence } from "@corbits/workflows/client"; import { useDeployAgentMutation } from "../agents-api"; import { chatPath } from "../chat-path"; import { Link } from "../navigation"; -import type { DeployablePackage } from "./deployable-package"; +import { isFiveFieldCron, type DeployablePackage } from "./deployable-package"; import type { MailAttachment } from "./threads-api"; function errorText(cause: unknown): string { @@ -50,6 +51,8 @@ function DeployPackageCard({ }) { const deploy = useDeployAgentMutation(tenantId); const deployed = deploy.data; + const scheduleValid = pkg.schedule === undefined || isFiveFieldCron(pkg.schedule); + const sentence = pkg.schedule !== undefined && scheduleValid ? cronSentence(pkg.schedule) : null; return (
@@ -57,16 +60,23 @@ function DeployPackageCard({ {pkg.description === undefined ? null : ( {pkg.description} )} + {sentence !== null ? {sentence} : null} + {pkg.schedule !== undefined && !scheduleValid ? ( + + {`This package's schedule ("${pkg.schedule}") isn't a valid five-field cron string.`} + + ) : null}
{deployed === undefined ? (
diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 941d54c9a..b466aa99f 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -12,6 +12,7 @@ import { TableHeader, TableRow, } from "@corbits/react-ui"; +import { cronSentence } from "@corbits/workflows/client"; import { Clock } from "@/lib/icons"; import { useGlobalRoutines, useRoutineActions } from "../global-routines"; @@ -22,6 +23,13 @@ import { StageTopBar } from "../shell/stage-top-bar"; export type { GlobalRoutineRow } from "../global-routines"; +/** A schedule's human sentence, the raw expression when it can't be + * described, or "Not scheduled" when the deployment carries no cron row. */ +export function scheduleSentence(schedule: string | null): string { + if (schedule === null) return "Not scheduled"; + return cronSentence(schedule) ?? schedule; +} + export function GlobalRoutinesList({ rows, onToggleEnabled, @@ -45,6 +53,7 @@ export function GlobalRoutinesList({ Routine + Schedule On Actions @@ -69,6 +78,9 @@ export function GlobalRoutinesList({ {row.tenantName} + + {scheduleSentence(row.definition.schedule)} + @` join `chat/threads-api.ts` does against +// `listTopLevelRuns`). Run-now and pause/resume have no backing stock route +// either (`/deployments` is list/create only; no per-deployment PATCH or +// trigger route exists), so both stay rejected promises with a message +// naming the missing route, same pattern as before. import { type } from "arktype"; import { useQuery } from "@tanstack/react-query"; @@ -18,6 +20,7 @@ import { WorkflowDeploymentResponse } from "@intx/types"; import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; import { isAgentDeploySourceAssetName } from "@/agent-deploy"; +import { listTopLevelRuns } from "@/agents-api"; import { MYRA_SOURCE_CONFIG } from "@/myra-source"; export const ScheduledWorkflowDefinition = type({ @@ -28,10 +31,27 @@ export const ScheduledWorkflowDefinition = type({ status: "'deployed' | 'stopped'", createdAt: "string", updatedAt: "string", + /** The cron expression firing this deployment's live run, or null when no + * `@corbits/cron` row is addressed at it. */ + schedule: "string | null", }); export type ScheduledWorkflowDefinition = typeof ScheduledWorkflowDefinition.infer; +export const CronSchedule = type({ + id: "string", + tenantId: "string", + expression: "string", + toAddress: "string", + subject: "string", + body: "string", + createdAt: "string", +}); + +export type CronSchedule = typeof CronSchedule.infer; + +const CronSchedulesResponse = type({ schedules: CronSchedule.array() }); + const DeploymentsSchema = WorkflowDeploymentResponse.array(); const WorkflowAssetSchema = type({ id: "string", name: "string" }); const WorkflowAssetsSchema = WorkflowAssetSchema.array(); @@ -44,6 +64,16 @@ function workflowAssetsPath(tenantId: string): string { return `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`; } +function cronPath(tenantId: string): string { + return `/api/tenants/${tenantId}/cron`; +} + +/** Every cron schedule saved on this tenant. */ +export async function listCronSchedules(tenantId: string): Promise { + const parsed = await fetchJSON(cronPath(tenantId), CronSchedulesResponse); + return parsed.schedules; +} + async function fetchJSON(path: string, schema: (data: unknown) => T | type.errors): Promise { const response = await fetch(path, { headers: { accept: "application/json" } }); if (response.status === 401) throw new UnauthenticatedError(); @@ -72,25 +102,36 @@ function isAgentAssetName(name: string): boolean { export async function listScheduledWorkflows( tenantId: string, ): Promise { - const [deployments, assets] = await Promise.all([ + const [deployments, assets, runs, schedules] = await Promise.all([ fetchJSON(deploymentsPath(tenantId), DeploymentsSchema), fetchJSON(workflowAssetsPath(tenantId), WorkflowAssetsSchema), + listTopLevelRuns(tenantId), + listCronSchedules(tenantId), ]); const nameByAssetId = new Map(assets.map((asset) => [asset.id, asset.name])); + // A deployment's own id is its anchor run's id (see `chat/threads-api.ts`'s + // `listChatAgents`), so this is the same join that resolves a chat agent's + // live address. + const addressByRunId = new Map(runs.map((run) => [run.id, run.address])); + const expressionByAddress = new Map(schedules.map((row) => [row.toAddress, row.expression])); return deployments .filter((deployment) => { const name = nameByAssetId.get(deployment.definitionAssetId); return name === undefined || !isAgentAssetName(name); }) - .map((deployment) => ({ - definitionId: deployment.id, - assetId: deployment.definitionAssetId, - name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow", - tenantId: deployment.tenantId, - status: deployment.status === "deployed" ? "deployed" : "stopped", - createdAt: deployment.createdAt, - updatedAt: deployment.createdAt, - })); + .map((deployment) => { + const address = addressByRunId.get(deployment.id); + return { + definitionId: deployment.id, + assetId: deployment.definitionAssetId, + name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow", + tenantId: deployment.tenantId, + status: deployment.status === "deployed" ? "deployed" : "stopped", + createdAt: deployment.createdAt, + updatedAt: deployment.createdAt, + schedule: address === undefined ? null : (expressionByAddress.get(address) ?? null), + }; + }); } /** No stock route reruns a deployment on demand yet. */ From 09a56fb04fbb5eda2873884f540805eb80959f69 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 10:20:27 -0700 Subject: [PATCH 3/3] fix(web): cron rows use the deployment id as the run address (CL-8534) --- apps/web/src/agent-deploy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index 94b53681d..44a2c8549 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -321,7 +321,9 @@ export async function deployAgentSource( await scheduleAgentRun( args.tenantId, args.input.schedule, - `run_${parsed.id}@${tenant.domain}`, + // The deployment id is the top-level run id (already `run_…`), and + // the run address is that id at the tenant domain. + `${parsed.id}@${tenant.domain}`, fetchImpl, ); }