Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/run-nested-automations-by-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Let a manual automation run target a resource path, including the generic `run-automation-now` action and manage-automations `run-now` tool, so automations nested under `jobs/` (such as per-factory jobs) can be run immediately instead of failing with "A valid automation name is required." Preserve application-owned frontmatter when automation status is written back after a run, and dispatch local runs back to the inbound request host when present.
12 changes: 6 additions & 6 deletions packages/core/src/client/agent-page/use-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
export type ManageAutomationInput = ManageJobInput;

export interface RunAutomationNowInput {
name: string;
name?: string;
path?: string;
scope: "personal" | "organization";
}

Expand Down Expand Up @@ -213,13 +214,12 @@
onSuccess: (_result, variables) => {
const scope =
variables.scope === "organization" ? "organization" : "personal";
const name =
variables.name ??
variables.path?.replace(/^jobs\//, "").replace(/\.md$/, "");
queryClient.invalidateQueries({
queryKey: [
"action",
"list-automation-runs",
{ scope, name: variables.name },
],
queryKey: ["action", "list-automation-runs", { scope, name }],
});

Check warning on line 222 in packages/core/src/client/agent-page/use-jobs.ts

View workflow job for this annotation

GitHub Actions / Lint & format

typescript(no-floating-promises)

Promises must be awaited, add void operator to ignore.
queryClient.invalidateQueries({
queryKey: ["action", "list-automations", { scope }],
});
Expand Down
20 changes: 13 additions & 7 deletions packages/core/src/jobs/actions/run-automation-now.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import { queueAutomationRunNow } from "../run-now.js";

export default defineAction({
description:
"Run one personal or organization automation immediately. This is an explicit send/run action and may perform the automation's real side effects.",
"Run one personal or organization automation immediately. This is an explicit send/run action and may perform the automation's real side effects. Pass a flat `name` or a nested `path` such as jobs/factories/<id>/factory-slack-feedback.md — not both.",
agentTool: false,
schema: z.object({
name: z.string().min(1),
scope: z.enum(["personal", "organization"]).default("personal"),
}),
run: async ({ name, scope }, ctx) => {
schema: z
.object({
name: z.string().min(1).optional(),
path: z.string().min(1).optional(),
scope: z.enum(["personal", "organization"]).default("personal"),
})
.refine((value) => Boolean(value.name) !== Boolean(value.path), {
message: "Specify either an automation name or a path, not both.",
}),
run: async ({ name, path, scope }, ctx) => {
if (!ctx?.userEmail) throw new Error("Not authenticated.");
return queueAutomationRunNow({
userEmail: ctx.userEmail,
orgId: ctx.orgId,
appId: ctx.appId,
scope,
name,
...(path ? { path } : { name }),
requestHeaders: ctx.requestHeaders,
});
},
});
24 changes: 24 additions & 0 deletions packages/core/src/jobs/frontmatter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ describe("job resource frontmatter", () => {
});
});

it("preserves application-owned fields during a scheduler rewrite", () => {
const content = `---
schedule: "0 9 * * *"
enabled: true
triggerType: schedule
domain: "factory"
factoryId: enzo-test-factory-3
displayName: My Slack triage
---

Run the automation.`;
const parsed = parseJobResource(content);
const rewrittenMeta = {
...parsed.meta,
lastRun: "2026-08-21T17:30:01.097Z",
};

const rewritten = buildJobResourceContent(rewrittenMeta, parsed.body);

expect(rewritten).toContain("factoryId: enzo-test-factory-3");
expect(rewritten).toContain("displayName: My Slack triage");
expect(rewritten).toContain('lastRun: "2026-08-21T17:30:01.097Z"');
});

it("distinguishes legacy jobs from explicit scheduled automations", () => {
const legacy = `---
schedule: "0 9 * * *"
Expand Down
67 changes: 60 additions & 7 deletions packages/core/src/jobs/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,48 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?([\s\S]*)$/;
const DELEGATED_POLICY_ID_RE = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
const EXECUTION_ID_RE = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
const REMOTE_ID_RE = /^[a-z0-9][a-z0-9@+._:/-]{0,511}$/i;
const EXTRA_FRONTMATTER_LINES = Symbol("extraFrontmatterLines");
const KNOWN_FRONTMATTER_FIELDS = new Set([
"schedule",
"enabled",
"timezone",
"createdBy",
"orgId",
"runAs",
"lastRun",
"lastCheck",
"lastStatus",
"lastError",
"nextRun",
"originScopeId",
"deliveryPlatform",
"deliveryDestination",
"deliveryThreadRef",
"deliveryTenantId",
"model",
"maxIterations",
"maxRunInputTokens",
"mcpTools",
"triggerType",
"event",
"condition",
"mode",
"domain",
"appId",
"executionHostId",
"executionEngine",
"executionCwd",
"remoteRequestId",
"remoteCommandId",
"remoteRunId",
"remoteAutomationRunId",
"remoteAdvanceSchedule",
"delegatedPolicyId",
]);

type JobFrontmatterWithExtras = JobFrontmatter & {
[EXTRA_FRONTMATTER_LINES]?: string[];
};

function assertBoundedFrontmatterValue(
value: string | undefined,
Expand Down Expand Up @@ -333,15 +375,23 @@ export function parseJobResource(content: string): ParsedJobResource {
};
}

const meta: JobFrontmatter = { schedule: "", enabled: true };
const meta: JobFrontmatterWithExtras = { schedule: "", enabled: true };
const extraLines: string[] = [];
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
parseKnownField(
meta,
line.slice(0, colonIdx).trim(),
line.slice(colonIdx + 1),
);
if (colonIdx === -1) {
if (line.trim()) extraLines.push(line);
continue;
}
const key = line.slice(0, colonIdx).trim();
if (!KNOWN_FRONTMATTER_FIELDS.has(key)) {
extraLines.push(line);
continue;
}
parseKnownField(meta, key, line.slice(colonIdx + 1));
}
if (extraLines.length) {
meta[EXTRA_FRONTMATTER_LINES] = extraLines;
}

return {
Expand Down Expand Up @@ -471,6 +521,9 @@ export function buildJobResourceContent(
if (meta.mcpTools?.length) {
lines.push(`mcpTools: ${JSON.stringify(meta.mcpTools)}`);
}
lines.push(
...((meta as JobFrontmatterWithExtras)[EXTRA_FRONTMATTER_LINES] ?? []),
);
lines.push("---", "", body);
return lines.join("\n");
}
167 changes: 167 additions & 0 deletions packages/core/src/jobs/run-now.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const canUpdateAutomationResourceMock = vi.hoisted(() => vi.fn());
const resourceGetByPathMock = vi.hoisted(() => vi.fn());
const startAutomationRunMock = vi.hoisted(() => vi.fn());
const listUnclaimedAutomationRunsMock = vi.hoisted(() => vi.fn());
const fireInternalDispatchMock = vi.hoisted(() => vi.fn());

vi.mock("../automations/service.js", () => ({
canUpdateAutomationResource: canUpdateAutomationResourceMock,
}));

vi.mock("../resources/store.js", () => ({
organizationResourceOwner: (orgId: string) => `__organization__:${orgId}`,
resourceGetByPath: resourceGetByPathMock,
}));

vi.mock("./run-history.js", () => ({
startAutomationRun: startAutomationRunMock,
listUnclaimedAutomationRuns: listUnclaimedAutomationRunsMock,
}));

vi.mock("../server/self-dispatch.js", () => ({
fireInternalDispatch: fireInternalDispatchMock,
}));

vi.mock("../db/client.js", () => ({ isLocalDatabase: () => true }));

vi.mock("../agent/durable-background.js", () => ({
AGENT_CHAT_BACKGROUND_RUN_FIELD: "__backgroundRun",
dispatchPathTargetsNetlifyBackgroundFunction: () => false,
resolveAgentChatProcessRunDispatchPath: () => "/_agent-native/process-run",
}));

import { queueAutomationRunNow } from "./run-now.js";

function resourceAt(path: string) {
return {
id: "resource-1",
path,
owner: "__organization__:org-1",
content: `---\ndomain: factory\n---\nObserve the channel.\n`,
};
}

const organizationRun = {
userEmail: "alice@example.com",
orgId: "org-1",
appId: "factory",
scope: "organization" as const,
};

beforeEach(() => {
vi.clearAllMocks();
canUpdateAutomationResourceMock.mockResolvedValue(true);
listUnclaimedAutomationRunsMock.mockResolvedValue([]);
startAutomationRunMock.mockResolvedValue("history-1");
fireInternalDispatchMock.mockResolvedValue(undefined);
});

describe("queueAutomationRunNow", () => {
// Per-factory automations live at jobs/factories/<factoryId>/<name>.md, so
// their name contains a slash and cannot round-trip through `name`.
it("queues a nested automation by path", async () => {
const path = "jobs/factories/enzo-test-factory-3/factory-slack-feedback.md";
resourceGetByPathMock.mockResolvedValue(resourceAt(path));

const result = await queueAutomationRunNow({ ...organizationRun, path });

expect(resourceGetByPathMock).toHaveBeenCalledWith(
"__organization__:org-1",
path,
);
expect(startAutomationRunMock).toHaveBeenCalledWith(
expect.objectContaining({
automation: "factories/enzo-test-factory-3/factory-slack-feedback",
path,
}),
);
expect(result).toEqual({
queued: true,
runId: "history-1",
automationRunId: "history-1",
});
});

it("still resolves a flat name to its jobs/ path", async () => {
resourceGetByPathMock.mockResolvedValue(resourceAt("jobs/digest.md"));

await queueAutomationRunNow({ ...organizationRun, name: "digest" });

expect(resourceGetByPathMock).toHaveBeenCalledWith(
"__organization__:org-1",
"jobs/digest.md",
);
expect(startAutomationRunMock).toHaveBeenCalledWith(
expect.objectContaining({ automation: "digest", path: "jobs/digest.md" }),
);
});

it("dispatches new and recovered runs to the inbound local host", async () => {
const requestHeaders = new Headers({ host: "localhost:8080" });
resourceGetByPathMock.mockResolvedValue(resourceAt("jobs/digest.md"));
listUnclaimedAutomationRunsMock.mockResolvedValue([
{ id: "history-stale" },
]);

await queueAutomationRunNow({
...organizationRun,
name: "digest",
requestHeaders,
});

expect(fireInternalDispatchMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
taskId: "history-stale",
event: { headers: requestHeaders },
}),
);
expect(fireInternalDispatchMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
taskId: "history-1",
event: { headers: requestHeaders },
}),
);
});

it("rejects a name that carries a path separator", async () => {
await expect(
queueAutomationRunNow({
...organizationRun,
name: "factories/f3/factory-slack-channel",
}),
).rejects.toThrow("A valid automation name is required.");
expect(resourceGetByPathMock).not.toHaveBeenCalled();
});

it("rejects a path that escapes the jobs/ tree", async () => {
await expect(
queueAutomationRunNow({
...organizationRun,
path: "jobs/../secrets.md",
}),
).rejects.toThrow("A valid automation path is required.");
expect(resourceGetByPathMock).not.toHaveBeenCalled();
});

it("rejects a path outside jobs/", async () => {
await expect(
queueAutomationRunNow({ ...organizationRun, path: "secrets/keys.md" }),
).rejects.toThrow("A valid automation path is required.");
expect(resourceGetByPathMock).not.toHaveBeenCalled();
});

it("refuses to guess when both a name and a path are given", async () => {
await expect(
queueAutomationRunNow({
...organizationRun,
name: "digest",
path: "jobs/other.md",
}),
).rejects.toThrow("Specify either an automation name or a path, not both.");
expect(resourceGetByPathMock).not.toHaveBeenCalled();
});
});
Loading
Loading