Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agents/myra/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, " +
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;

/**
Expand Down Expand Up @@ -285,5 +317,15 @@ 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,
// 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,
);
}
return parsed;
}
19 changes: 18 additions & 1 deletion apps/web/src/chat/deployable-package.test.ts
Original file line number Diff line number Diff line change
@@ -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."}`;
Expand Down Expand Up @@ -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\`\`\``;
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/chat/deployable-package.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/chat/message-attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,23 +51,32 @@ 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 (
<div className="chat-deploy-card">
<div className="chat-deploy-card-text">
<span className="chat-deploy-card-name">{pkg.name}</span>
{pkg.description === undefined ? null : (
<span className="chat-deploy-card-note">{pkg.description}</span>
)}
{sentence !== null ? <span className="chat-deploy-card-note">{sentence}</span> : null}
{pkg.schedule !== undefined && !scheduleValid ? (
<span className="chat-deploy-card-error">
{`This package's schedule ("${pkg.schedule}") isn't a valid five-field cron string.`}
</span>
) : null}
</div>
{deployed === undefined ? (
<Button
variant="primary"
size="sm"
disabled={deploy.isPending}
disabled={deploy.isPending || !scheduleValid}
onClick={() =>
deploy.mutate({
name: pkg.name,
systemPrompt: pkg.systemPrompt,
...(pkg.schedule !== undefined ? { schedule: pkg.schedule } : {}),
})
}
>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/insights-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/pages/routine-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { GlobalRoutineRow } from "../global-routines";
import { Link } from "../navigation";
import { WORKFLOWS_PATH_PREFIX } from "../path-ids";
import { StageTopBar } from "../shell/stage-top-bar";
import { scheduleSentence } from "./routines-page";

const RunsPageSchema = paginatedSchema(WorkflowRunResponse);
type RunRow = typeof WorkflowRunResponse.infer;
Expand Down Expand Up @@ -197,13 +198,15 @@ export function RoutineDetailPage({
readonly onRunNow: () => Promise<void>;
}) {
const enabled = row.definition.status === "deployed";
const sentence = scheduleSentence(row.definition.schedule);
return (
<div className="flex h-full min-h-0 flex-col">
<StageTopBar
crumbs={[
{ label: "Workflows", href: WORKFLOWS_PATH_PREFIX },
{ label: row.definition.name },
]}
subtitle={sentence}
actions={
<div className="flex items-center gap-2">
<Button
Expand All @@ -223,6 +226,7 @@ export function RoutineDetailPage({
<div>
<h1 className="m-0 text-xl font-semibold">{row.definition.name}</h1>
<p className="mt-2 text-sm text-[var(--ui-fg-muted)]">{row.tenantName}</p>
<p className="mt-4 text-lg">{sentence}</p>
</div>
<RoutineRunsSection tenantId={row.tenantId} definitionId={row.definition.definitionId} />
</div>
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/pages/routines-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -45,6 +53,7 @@ export function GlobalRoutinesList({
<TableHeader>
<TableRow>
<TableHead>Routine</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>On</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
Expand All @@ -69,6 +78,9 @@ export function GlobalRoutinesList({
<span className="text-xs text-[var(--ui-fg-muted)]">{row.tenantName}</span>
</span>
</TableCell>
<TableCell>
<span className="text-sm">{scheduleSentence(row.definition.schedule)}</span>
</TableCell>
<TableCell>
<Switch
checked={enabled}
Expand Down
71 changes: 56 additions & 15 deletions apps/web/src/routines-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@
// stock reads `vendor/intx/hub-api/src/routes/workflows.ts` exposes.
//
// The `schedule` trigger is reserved on Interchange but unimplemented — no
// scheduler fires it — so this reads deployments as plain workflows, with
// no schedule concept. 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.
// scheduler fires it — so a schedule here is a `@corbits/cron` row addressed
// at the deployment's live run, joined in from `GET /cron` by that address
// (the same `run_<id>@<domain>` 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";
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({
Expand All @@ -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();
Expand All @@ -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<readonly CronSchedule[]> {
const parsed = await fetchJSON(cronPath(tenantId), CronSchedulesResponse);
return parsed.schedules;
}

async function fetchJSON<T>(path: string, schema: (data: unknown) => T | type.errors): Promise<T> {
const response = await fetch(path, { headers: { accept: "application/json" } });
if (response.status === 401) throw new UnauthenticatedError();
Expand Down Expand Up @@ -72,25 +102,36 @@ function isAgentAssetName(name: string): boolean {
export async function listScheduledWorkflows(
tenantId: string,
): Promise<readonly ScheduledWorkflowDefinition[]> {
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. */
Expand Down
Loading