From 2f9de5e25eefdb21412e25a988ff0f6c8bd6f090 Mon Sep 17 00:00:00 2001 From: Enzo Ames Date: Fri, 21 Aug 2026 18:24:43 -0500 Subject: [PATCH 1/5] Let nested Factory automations poll and run now. Per-factory jobs live under jobs/factories//, so Run now and the governed poll check now treat the path as the automation name and the leaf as the role. Create and Settings share source cards with a sticky unsaved bar, and Activity keeps running automations visible under their display names. Co-authored-by: Cursor --- .changeset/run-nested-automations-by-path.md | 5 + .../src/jobs/actions/run-automation-now.ts | 1 + packages/core/src/jobs/frontmatter.spec.ts | 24 ++ packages/core/src/jobs/frontmatter.ts | 67 +++- packages/core/src/jobs/run-now.spec.ts | 167 ++++++++ packages/core/src/jobs/run-now.ts | 91 ++++- packages/core/src/jobs/scheduler.ts | 5 +- .../core/src/server/self-dispatch.spec.ts | 27 ++ packages/core/src/server/self-dispatch.ts | 2 +- packages/core/src/triggers/actions.ts | 1 + .../factory/actions/create-factory.spec.ts | 8 + templates/factory/actions/create-factory.ts | 18 +- .../factory/actions/list-factory-audit.ts | 31 +- .../actions/list-factory-automations.ts | 3 +- .../factory/actions/run-factory-automation.ts | 10 +- .../actions/save-factory-automation.ts | 7 +- .../components/factory/FactoryAuditView.tsx | 22 +- .../FactorySettingsView.layout.test.ts | 22 ++ .../factory/FactorySettingsView.tsx | 265 +++++++++---- .../factory/FactorySourceSettingsGroup.tsx | 31 ++ .../app/components/factory/NewFactoryForm.tsx | 364 ++++++++++-------- templates/factory/app/i18n/ar-SA.ts | 22 +- templates/factory/app/i18n/de-DE.ts | 22 +- templates/factory/app/i18n/en-US.ts | 22 +- templates/factory/app/i18n/es-ES.ts | 23 +- templates/factory/app/i18n/fr-FR.ts | 23 +- templates/factory/app/i18n/hi-IN.ts | 20 +- templates/factory/app/i18n/ja-JP.ts | 23 +- templates/factory/app/i18n/ko-KR.ts | 22 +- templates/factory/app/i18n/pt-BR.ts | 22 +- templates/factory/app/i18n/zh-CN.ts | 19 +- templates/factory/app/i18n/zh-TW.ts | 19 +- templates/factory/app/routes/factory.tsx | 88 ++++- ...ivity-runs-use-automation-display-names.md | 5 + ...-08-21-create-and-settings-source-cards.md | 5 + ...-keep-automations-visible-while-running.md | 5 + ...-21-nested-factory-automations-can-poll.md | 5 + .../2026-08-21-settings-sticky-unsaved-bar.md | 5 + .../lib/factory-scope-config-row.spec.ts | 91 ++++- templates/factory/server/lib/factory-scope.ts | 19 +- .../lib/require-factory-automation.spec.ts | 74 ++++ .../server/lib/require-factory-automation.ts | 17 +- .../server/plugins/factory-migrations.ts | 35 ++ .../server/plugins/factory-scheduler-job.ts | 10 +- 44 files changed, 1412 insertions(+), 355 deletions(-) create mode 100644 .changeset/run-nested-automations-by-path.md create mode 100644 packages/core/src/jobs/run-now.spec.ts create mode 100644 templates/factory/app/components/factory/FactorySourceSettingsGroup.tsx create mode 100644 templates/factory/changelog/2026-08-21-activity-runs-use-automation-display-names.md create mode 100644 templates/factory/changelog/2026-08-21-create-and-settings-source-cards.md create mode 100644 templates/factory/changelog/2026-08-21-keep-automations-visible-while-running.md create mode 100644 templates/factory/changelog/2026-08-21-nested-factory-automations-can-poll.md create mode 100644 templates/factory/changelog/2026-08-21-settings-sticky-unsaved-bar.md create mode 100644 templates/factory/server/lib/require-factory-automation.spec.ts diff --git a/.changeset/run-nested-automations-by-path.md b/.changeset/run-nested-automations-by-path.md new file mode 100644 index 0000000000..0ac6880f95 --- /dev/null +++ b/.changeset/run-nested-automations-by-path.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Let a manual automation run target a resource path, 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 active development server instead of an inactive legacy port. diff --git a/packages/core/src/jobs/actions/run-automation-now.ts b/packages/core/src/jobs/actions/run-automation-now.ts index bb96f351fc..d8e3f7f4a4 100644 --- a/packages/core/src/jobs/actions/run-automation-now.ts +++ b/packages/core/src/jobs/actions/run-automation-now.ts @@ -19,6 +19,7 @@ export default defineAction({ appId: ctx.appId, scope, name, + requestHeaders: ctx.requestHeaders, }); }, }); diff --git a/packages/core/src/jobs/frontmatter.spec.ts b/packages/core/src/jobs/frontmatter.spec.ts index 2c5f33f9bf..cd15e10c0b 100644 --- a/packages/core/src/jobs/frontmatter.spec.ts +++ b/packages/core/src/jobs/frontmatter.spec.ts @@ -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 * * *" diff --git a/packages/core/src/jobs/frontmatter.ts b/packages/core/src/jobs/frontmatter.ts index 6ee6155b6b..878ec09d23 100644 --- a/packages/core/src/jobs/frontmatter.ts +++ b/packages/core/src/jobs/frontmatter.ts @@ -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, @@ -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 { @@ -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"); } diff --git a/packages/core/src/jobs/run-now.spec.ts b/packages/core/src/jobs/run-now.spec.ts new file mode 100644 index 0000000000..5cb2e809c9 --- /dev/null +++ b/packages/core/src/jobs/run-now.spec.ts @@ -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//.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(); + }); +}); diff --git a/packages/core/src/jobs/run-now.ts b/packages/core/src/jobs/run-now.ts index 46b0816b54..a321f3572c 100644 --- a/packages/core/src/jobs/run-now.ts +++ b/packages/core/src/jobs/run-now.ts @@ -24,7 +24,16 @@ export interface RunAutomationNowInput { orgId?: string | null; appId?: string | null; scope: AutomationScope; - name: string; + /** Inbound request headers used to dispatch back to this exact local host. */ + requestHeaders?: Headers; + /** Flat automation name, resolved as `jobs/.md`. */ + name?: string; + /** + * Full resource path for an automation the caller already resolved. Required + * for automations nested under `jobs/` (e.g. per-factory jobs), whose names + * contain a slash and cannot round-trip through `name`. + */ + path?: string; } export interface QueuedAutomationRun { @@ -33,11 +42,15 @@ export interface QueuedAutomationRun { automationRunId: string; } -async function dispatchAutomationRun(historyId: string): Promise { +async function dispatchAutomationRun( + historyId: string, + requestHeaders?: Headers, +): Promise { const dispatchPath = resolveAgentChatProcessRunDispatchPath(); await fireInternalDispatch({ path: dispatchPath, taskId: historyId, + ...(requestHeaders ? { event: { headers: requestHeaders } } : {}), body: { [AGENT_CHAT_BACKGROUND_RUN_FIELD]: { runId: historyId, @@ -52,6 +65,10 @@ async function dispatchAutomationRun(historyId: string): Promise { }); } +function automationName(path: string): string { + return path.replace(/^jobs\//, "").replace(/\.md$/, ""); +} + function ownerForScope(input: RunAutomationNowInput): string { if (input.scope === "personal") return input.userEmail.trim().toLowerCase(); if (!input.orgId) { @@ -63,17 +80,55 @@ function ownerForScope(input: RunAutomationNowInput): string { return organizationResourceOwner(input.orgId); } -export async function queueAutomationRunNow( - input: RunAutomationNowInput, -): Promise { - const name = input.name.trim(); +/** + * One place decides which resource a run-now request means. Callers that + * already hold the resource pass `path`; callers that only know a flat slug + * pass `name`. Accepting both at once would let two disagreeing identifiers + * silently pick a winner. + */ +function resolveAutomationTarget(input: RunAutomationNowInput): { + path: string; + name: string; +} { + const path = input.path?.trim(); + const name = input.name?.trim(); + if (path && name) { + throw Object.assign( + new Error("Specify either an automation name or a path, not both."), + { statusCode: 400 }, + ); + } + if (path) { + const segments = path.split("/"); + const valid = + segments[0] === "jobs" && + segments.length > 1 && + path.endsWith(".md") && + !path.includes("\\") && + segments.every( + (segment) => segment && segment !== "." && segment !== "..", + ); + if (!valid) { + throw Object.assign(new Error("A valid automation path is required."), { + statusCode: 400, + }); + } + return { path, name: automationName(path) }; + } if (!name || name.includes("/") || name.endsWith(".md")) { throw Object.assign(new Error("A valid automation name is required."), { statusCode: 400, }); } + return { path: `jobs/${name}.md`, name }; +} + +export async function queueAutomationRunNow( + input: RunAutomationNowInput, +): Promise { + const { path, name } = resolveAutomationTarget(input); const owner = ownerForScope(input); - const resource = await resourceGetByPath(owner, `jobs/${name}.md`); + const resource = await resourceGetByPath(owner, path); if (!resource) { throw Object.assign(new Error(`Automation "${name}" not found.`), { statusCode: 404, @@ -99,14 +154,15 @@ export async function queueAutomationRunNow( // A manual-run request is a guaranteed app request even on hosts without a // durable timer. Use it to recover older rows before adding the new one. - await redispatchUnclaimedAutomationRuns({ appId: input.appId }).catch( - (error) => { - console.warn( - "[automations] Could not sweep queued runs before run-now:", - error, - ); - }, - ); + await redispatchUnclaimedAutomationRuns({ + appId: input.appId, + requestHeaders: input.requestHeaders, + }).catch((error) => { + console.warn( + "[automations] Could not sweep queued runs before run-now:", + error, + ); + }); const historyId = await startAutomationRun({ owner: resource.owner, @@ -118,7 +174,7 @@ export async function queueAutomationRunNow( dispatchPending: true, }); try { - await dispatchAutomationRun(historyId); + await dispatchAutomationRun(historyId, input.requestHeaders); } catch (error) { const message = error instanceof Error ? error.message : "Background dispatch failed"; @@ -139,12 +195,13 @@ export async function queueAutomationRunNow( */ export async function redispatchUnclaimedAutomationRuns(options?: { appId?: string | null; + requestHeaders?: Headers; }): Promise { const runs = await listUnclaimedAutomationRuns({ appId: options?.appId }); let attempted = 0; for (const run of runs) { try { - await dispatchAutomationRun(run.id); + await dispatchAutomationRun(run.id, options?.requestHeaders); attempted += 1; } catch (error) { console.error( diff --git a/packages/core/src/jobs/scheduler.ts b/packages/core/src/jobs/scheduler.ts index 4cfeea233c..f96d68c968 100644 --- a/packages/core/src/jobs/scheduler.ts +++ b/packages/core/src/jobs/scheduler.ts @@ -782,9 +782,9 @@ export async function runJobNow( owner: string, name: string, deps: SchedulerDeps, - options: { historyId?: string } = {}, + options: { historyId?: string; path?: string } = {}, ): Promise { - const path = `jobs/${name}.md`; + const path = options.path ?? `jobs/${name}.md`; const resource = await resourceGetByPath(owner, path); if (!resource) throw new Error(`Automation "${name}" not found.`); const { meta, body } = parseJobFrontmatter(resource.content); @@ -814,6 +814,7 @@ export async function runQueuedAutomation( } const result = await runJobNow(queued.owner, queued.automation, deps, { historyId, + path: queued.path, }); return { skipped: false, diff --git a/packages/core/src/server/self-dispatch.spec.ts b/packages/core/src/server/self-dispatch.spec.ts index 7b3c662fd2..fe069f34b7 100644 --- a/packages/core/src/server/self-dispatch.spec.ts +++ b/packages/core/src/server/self-dispatch.spec.ts @@ -13,6 +13,33 @@ describe("fireInternalDispatch", () => { vi.restoreAllMocks(); }); + it("uses the Agent Native dev port when retrying outside a request", () => { + const keys = [ + "DEPLOY_PRIME_URL", + "DEPLOY_URL", + "URL", + "APP_URL", + "VITE_APP_URL", + "BETTER_AUTH_URL", + "VITE_BETTER_AUTH_URL", + "PORT", + ] as const; + const previous = Object.fromEntries( + keys.map((key) => [key, process.env[key]]), + ); + for (const key of keys) delete process.env[key]; + + try { + expect(resolveSelfDispatchBaseUrl()).toBe("http://localhost:8080"); + } finally { + for (const key of keys) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + it("rejects quickly returned non-2xx processor responses", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); globalThis.fetch = vi.fn(async () => ({ diff --git a/packages/core/src/server/self-dispatch.ts b/packages/core/src/server/self-dispatch.ts index 2dbed142f5..19a5f31843 100644 --- a/packages/core/src/server/self-dispatch.ts +++ b/packages/core/src/server/self-dispatch.ts @@ -83,7 +83,7 @@ export function resolveSelfDispatchBaseUrl(event?: any): string { const proto = readHeader(event, "x-forwarded-proto") || "http"; const host = - readHeader(event, "host") || `localhost:${process.env.PORT || 3000}`; + readHeader(event, "host") || `localhost:${process.env.PORT || 8080}`; return withConfiguredAppBasePath(`${proto}://${host}`); } diff --git a/packages/core/src/triggers/actions.ts b/packages/core/src/triggers/actions.ts index 3b520ed0b0..78589470b9 100644 --- a/packages/core/src/triggers/actions.ts +++ b/packages/core/src/triggers/actions.ts @@ -385,6 +385,7 @@ async function handleRunNow( appId, scope: automationScope(args.scope), name: typeof args.name === "string" ? args.name : "", + requestHeaders: context?.requestHeaders, }); return JSON.stringify(result); } catch (error) { diff --git a/templates/factory/actions/create-factory.spec.ts b/templates/factory/actions/create-factory.spec.ts index 58501d7b85..0b82c35d89 100644 --- a/templates/factory/actions/create-factory.spec.ts +++ b/templates/factory/actions/create-factory.spec.ts @@ -129,7 +129,9 @@ describe("create-factory", () => { const result = await action.run( { name: "Support triage", + slackWorkspace: "secondary", slackChannelId: "C123", + builderSlackUserId: "U096KN3EL2Y", observeSlack: true, repository: "agent-native/agent-native", }, @@ -144,6 +146,12 @@ describe("create-factory", () => { ]), ); expect(insertedConfig).toHaveLength(1); + expect(insertedConfig[0]).toMatchObject({ + slackWorkspace: "secondary", + slackChannelId: "C123", + builderSlackUserId: "U096KN3EL2Y", + pollingEnabled: 1, + }); expect(assertUniqueSlackChannelForFactoryMock).toHaveBeenCalled(); expect(syncFactoryAutomationEnabledStatesMock).toHaveBeenCalledWith( "owner@example.com", diff --git a/templates/factory/actions/create-factory.ts b/templates/factory/actions/create-factory.ts index 689e22316a..59fc3fc7cd 100644 --- a/templates/factory/actions/create-factory.ts +++ b/templates/factory/actions/create-factory.ts @@ -40,8 +40,10 @@ export default defineAction({ schema: z.object({ name: z.string().trim().min(1).max(120), description: z.string().trim().max(500).optional(), + slackWorkspace: z.enum(["primary", "secondary"]).optional(), slackChannelId: z.string().trim().max(128).optional(), slackChannelName: z.string().trim().max(200).optional(), + builderSlackUserId: z.string().trim().max(128).optional(), observeSlack: z.boolean().optional(), repository: z.string().trim().max(256).optional(), observeGithub: z.boolean().optional(), @@ -111,7 +113,16 @@ export default defineAction({ orgId, }); - if (automationPlan.hasConfig) { + const shouldWriteConfig = + automationPlan.hasConfig || + Boolean(input.slackChannelId?.trim()) || + Boolean(input.slackChannelName?.trim()) || + Boolean(input.builderSlackUserId?.trim()) || + Boolean(input.slackWorkspace) || + Boolean(input.sentryOrgSlug?.trim()) || + Boolean(input.sentryProjectSlug?.trim()) || + Boolean(input.sentryEnvironment?.trim()); + if (shouldWriteConfig) { const slackChannelId = input.slackChannelId?.trim() || null; await assertUniqueSlackChannelForFactory( tx as unknown as typeof db, @@ -122,10 +133,11 @@ export default defineAction({ await tx.insert(triageConfig).values({ id: factoryConfigRowId(orgId, factoryId), factoryId, - slackWorkspace: "primary", + slackWorkspace: + input.slackWorkspace === "secondary" ? "secondary" : "primary", slackChannelId, slackChannelName: input.slackChannelName?.trim() || null, - builderSlackUserId: null, + builderSlackUserId: input.builderSlackUserId?.trim() || null, pollingEnabled: automationPlan.pollingEnabled ? 1 : 0, githubPollingEnabled: automationPlan.githubPollingEnabled ? 1 : 0, sentryPollingEnabled: automationPlan.sentryPollingEnabled ? 1 : 0, diff --git a/templates/factory/actions/list-factory-audit.ts b/templates/factory/actions/list-factory-audit.ts index 8d84b6aebd..4efbc9cfcf 100644 --- a/templates/factory/actions/list-factory-audit.ts +++ b/templates/factory/actions/list-factory-audit.ts @@ -10,6 +10,7 @@ import { getDb } from "../server/db/index.js"; import { factoryAuditEvents } from "../server/db/schema.js"; import { factoryIdSchema, + readAutomationDisplayName, readAutomationFactoryId, } from "../server/lib/factory-scope.js"; import { @@ -39,23 +40,30 @@ export default defineAction({ const factoryDefinitions = definitions.filter( ({ meta, name, resource }) => meta.domain === "factory" && - readAutomationFactoryId(meta, resource.content) === factoryId && + readAutomationFactoryId(meta, resource.content, resource.path) === + factoryId && (!automation || name === automation), ); const runGroups = await Promise.all( - factoryDefinitions.map(async ({ name, resource }) => - listAutomationRuns({ + factoryDefinitions.map(async ({ name, resource }) => { + const runs = await listAutomationRuns({ owners: [resource.owner], automation: name, appId: "factory", limit, - }), - ), + }); + // Absent must stay distinguishable from a stored label so the client + // can derive its own fallback instead of rendering the nested path. + const displayName = readAutomationDisplayName(resource.content); + return runs.map((run) => ({ run, displayName })); + }), ); - const runs = runGroups.flat().sort((a, b) => b.startedAt - a.startedAt); - const boundedRuns = runs.slice(0, limit); - const runIds = boundedRuns - .map((run) => run.runId) + const entries = runGroups + .flat() + .sort((a, b) => b.run.startedAt - a.run.startedAt); + const boundedEntries = entries.slice(0, limit); + const runIds = boundedEntries + .map(({ run }) => run.runId) .filter((runId): runId is string => Boolean(runId)); const db = getDb(); @@ -81,9 +89,10 @@ export default defineAction({ } return { - runs: boundedRuns.map((run) => ({ + runs: boundedEntries.map(({ run, displayName }) => ({ id: run.id, automation: run.automation, + displayName, runId: run.runId, threadId: run.threadId, status: run.status, @@ -106,7 +115,7 @@ export default defineAction({ createdAt: event.createdAt, })), })), - count: boundedRuns.length, + count: boundedEntries.length, }; }, }); diff --git a/templates/factory/actions/list-factory-automations.ts b/templates/factory/actions/list-factory-automations.ts index 180d2b52d7..4ec99e92a0 100644 --- a/templates/factory/actions/list-factory-automations.ts +++ b/templates/factory/actions/list-factory-automations.ts @@ -43,7 +43,8 @@ export default defineAction({ const scoped = definitions.filter( ({ meta, resource }) => meta.domain === "factory" && - readAutomationFactoryId(meta, resource.content) === factoryId, + readAutomationFactoryId(meta, resource.content, resource.path) === + factoryId, ); return Promise.all( scoped.map(async ({ resource, name, meta, body, canUpdate }) => { diff --git a/templates/factory/actions/run-factory-automation.ts b/templates/factory/actions/run-factory-automation.ts index 182629b522..d52c095469 100644 --- a/templates/factory/actions/run-factory-automation.ts +++ b/templates/factory/actions/run-factory-automation.ts @@ -37,8 +37,11 @@ export default defineAction({ ); if (!definition) throw new Error("Factory automation not found."); if ( - readAutomationFactoryId(definition.meta, definition.resource.content) !== - factoryId + readAutomationFactoryId( + definition.meta, + definition.resource.content, + definition.resource.path, + ) !== factoryId ) { throw new Error("Factory automation not found."); } @@ -47,7 +50,8 @@ export default defineAction({ orgId, appId: "factory", scope: "organization", - name: definition.name, + path: definition.resource.path, + requestHeaders: context?.requestHeaders, }); }, }); diff --git a/templates/factory/actions/save-factory-automation.ts b/templates/factory/actions/save-factory-automation.ts index 9a93302a83..869db64fe4 100644 --- a/templates/factory/actions/save-factory-automation.ts +++ b/templates/factory/actions/save-factory-automation.ts @@ -63,8 +63,11 @@ export default defineAction({ ); if (!definition) throw new Error("Factory automation not found."); if ( - readAutomationFactoryId(definition.meta, definition.resource.content) !== - factoryId + readAutomationFactoryId( + definition.meta, + definition.resource.content, + definition.resource.path, + ) !== factoryId ) { throw new Error("Factory automation not found."); } diff --git a/templates/factory/app/components/factory/FactoryAuditView.tsx b/templates/factory/app/components/factory/FactoryAuditView.tsx index 05f32353c5..70f6039c67 100644 --- a/templates/factory/app/components/factory/FactoryAuditView.tsx +++ b/templates/factory/app/components/factory/FactoryAuditView.tsx @@ -33,6 +33,7 @@ type FactoryAuditEvent = { type FactoryAuditRun = { id: string; automation: string; + displayName: string | null; runId: string | null; threadId: string | null; status: string; @@ -76,7 +77,13 @@ export function FactoryAuditView({ const auditQuery = useActionQuery( "list-factory-audit", { factoryId, limit: 30 }, - { staleTime: 5_000 }, + { + staleTime: 5_000, + refetchInterval: (query) => + query.state.data?.runs.some((run) => run.status === "running") + ? 1_000 + : false, + }, ); const refetchAudit = auditQuery.refetch; const runs = auditQuery.data?.runs ?? []; @@ -167,7 +174,7 @@ export function FactoryAuditView({ >
- {formatAutomationName(run.automation)} + {automationLabel(run)}
@@ -192,8 +199,7 @@ export function FactoryAuditView({
- {selectedRun && - formatAutomationName(selectedRun.automation)} + {selectedRun && automationLabel(selectedRun)} {selectedRun && (

@@ -643,6 +649,14 @@ function slackThreadUrl(channelId: string, threadTs: string): string { return `https://slack.com/archives/${encodeURIComponent(channelId)}/p${compactTs}?thread_ts=${encodeURIComponent(threadTs)}`; } +function automationLabel(run: FactoryAuditRun): string { + if (run.displayName) return run.displayName; + // Per-factory automations live at `factories//`; only the leaf is a + // name, so formatting the whole path would render the storage layout. + const segments = run.automation.split("/"); + return formatAutomationName(segments[segments.length - 1] || run.automation); +} + function formatAutomationName(value: string): string { const words = value .replace(/^factory-/, "") diff --git a/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts b/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts index 6c699948a4..94b480db30 100644 --- a/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts +++ b/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts @@ -28,3 +28,25 @@ describe("FactorySettingsView load gating", () => { ); }); }); + +describe("FactorySettingsView unsaved-change bar", () => { + it("saves from a sticky bar that only appears while the form is dirty", () => { + const source = readViewSource(); + + expect(source).toContain("const dirty = baseline !== null"); + expect(source).toMatch(/\{dirty \? \(\s*

{ + const source = readViewSource(); + + expect(source).toContain( + "if (hydratedRef.current && dirtyRef.current) return;", + ); + }); +}); diff --git a/templates/factory/app/components/factory/FactorySettingsView.tsx b/templates/factory/app/components/factory/FactorySettingsView.tsx index 25712ec93d..3e7642e445 100644 --- a/templates/factory/app/components/factory/FactorySettingsView.tsx +++ b/templates/factory/app/components/factory/FactorySettingsView.tsx @@ -7,7 +7,7 @@ import { buildSettingsRoute } from "@agent-native/core/client/navigation"; import { SettingsGroup, SettingsRow } from "@agent-native/core/client/settings"; import { ActionQueryError } from "@agent-native/dispatch/components"; import { IconLoader2 } from "@tabler/icons-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; @@ -15,6 +15,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { FactorySourceSettingsGroup } from "./FactorySourceSettingsGroup"; + type TriageConfig = { slackWorkspace?: "primary" | "secondary"; slackChannelId?: string | null; @@ -35,6 +37,46 @@ type TriageConfig = { }; }; +type TriageFormState = { + workspace: "primary" | "secondary"; + channelId: string; + channelName: string; + builderSlackUserId: string; + repository: string; + polling: boolean; + githubPolling: boolean; + sentryPolling: boolean; + sentryOrgSlug: string; + sentryProjectSlug: string; + sentryEnvironment: string; + automationFailureAlertsEnabled: boolean; + automationFailureAlertEmail: string; +}; + +function formStateFromConfig(data: TriageConfig): TriageFormState { + return { + workspace: data.slackWorkspace ?? "primary", + channelId: data.slackChannelId ?? "", + channelName: data.slackChannelName ?? "", + builderSlackUserId: data.builderSlackUserId ?? "", + repository: data.repository ?? "", + polling: data.pollingEnabled ?? false, + githubPolling: data.githubPollingEnabled ?? false, + sentryPolling: data.sentryPollingEnabled ?? false, + sentryOrgSlug: data.sentryOrgSlug ?? "", + sentryProjectSlug: data.sentryProjectSlug ?? "", + sentryEnvironment: data.sentryEnvironment ?? "", + automationFailureAlertsEnabled: data.automationFailureAlertsEnabled ?? true, + automationFailureAlertEmail: data.automationFailureAlertEmail ?? "", + }; +} + +function isSameForm(a: TriageFormState, b: TriageFormState) { + return (Object.keys(a) as (keyof TriageFormState)[]).every( + (key) => a[key] === b[key], + ); +} + type FactoryAutomationHealth = { status: "healthy" | "stale" | "error" | "no-data"; lastCheckedAt?: number | null; @@ -78,35 +120,71 @@ export function FactorySettingsView({ {}, { refetchInterval: 60_000 }, ); + const [baseline, setBaseline] = useState(null); + const hydratedRef = useRef(false); + const dirtyRef = useRef(false); const mutation = useActionMutation("save-triage-config"); + const applyForm = useCallback((state: TriageFormState) => { + setWorkspace(state.workspace); + setChannelId(state.channelId); + setChannelName(state.channelName); + setBuilderSlackUserId(state.builderSlackUserId); + setRepository(state.repository); + setPolling(state.polling); + setGithubPolling(state.githubPolling); + setSentryPolling(state.sentryPolling); + setSentryOrgSlug(state.sentryOrgSlug); + setSentryProjectSlug(state.sentryProjectSlug); + setSentryEnvironment(state.sentryEnvironment); + setAutomationFailureAlertsEnabled(state.automationFailureAlertsEnabled); + setAutomationFailureAlertEmail(state.automationFailureAlertEmail); + }, []); + useEffect(() => { const data = query.data as TriageConfig | undefined; if (!data) return; - setWorkspace(data.slackWorkspace ?? "primary"); - setChannelId(data.slackChannelId ?? ""); - setChannelName(data.slackChannelName ?? ""); - setBuilderSlackUserId(data.builderSlackUserId ?? ""); - setRepository(data.repository ?? ""); - setPolling(data.pollingEnabled ?? false); - setGithubPolling(data.githubPollingEnabled ?? false); - setSentryPolling(data.sentryPollingEnabled ?? false); - setSentryOrgSlug(data.sentryOrgSlug ?? ""); - setSentryProjectSlug(data.sentryProjectSlug ?? ""); - setSentryEnvironment(data.sentryEnvironment ?? ""); - setAutomationFailureAlertsEnabled( - data.automationFailureAlertsEnabled ?? true, - ); - setAutomationFailureAlertEmail(data.automationFailureAlertEmail ?? ""); - }, [query.data]); + // A background refetch must never overwrite edits the user has not saved + // yet: the sticky bar is the only signal those edits still exist. + if (hydratedRef.current && dirtyRef.current) return; + const next = formStateFromConfig(data); + applyForm(next); + setBaseline(next); + hydratedRef.current = true; + }, [applyForm, query.data]); const configLoaded = Boolean(query.data) && !query.isError; + const currentForm: TriageFormState = { + workspace, + channelId, + channelName, + builderSlackUserId, + repository, + polling, + githubPolling, + sentryPolling, + sentryOrgSlug, + sentryProjectSlug, + sentryEnvironment, + automationFailureAlertsEnabled, + automationFailureAlertEmail, + }; + const dirty = baseline !== null && !isSameForm(baseline, currentForm); + + useEffect(() => { + dirtyRef.current = dirty; + }, [dirty]); + const saveSettings = async () => { if (!configLoaded) { toast.error(t("triage.settingsError")); return; } + const saved: TriageFormState = { + ...currentForm, + automationFailureAlertEmail: automationFailureAlertEmail.trim(), + }; try { await mutation.mutateAsync({ factoryId, @@ -124,6 +202,8 @@ export function FactorySettingsView({ automationFailureAlertsEnabled, automationFailureAlertEmail: automationFailureAlertEmail.trim(), }); + applyForm(saved); + setBaseline(saved); toast.success(t("triage.settingsSaved")); } catch (error) { toast.error( @@ -155,6 +235,34 @@ export function FactorySettingsView({ return (
+ {dirty ? ( +
+
+ + {t("triage.unsavedSettings")} + +
+ + +
+
+
+ ) : null} + - + } /> + setPolling(checked === true)} + /> + } + /> + + + } /> - setPolling(checked === true)} - /> - } - /> } /> + + + } /> - + -
- -
- }) { return (
- {[2, 11, 3].map((rowCount, index) => ( + {[2, 5, 2, 4, 3, 3].map((rowCount, index) => (
{Array.from({ length: rowCount }).map((_, rowIndex) => ( @@ -416,47 +532,50 @@ function SchedulerHealthStatus({ "no-data": t("factoryRoute.automationHealthNoData"), }[health.status] : t("factoryRoute.automationHealthNoData"); - const hasNoHeartbeat = - !isError && - (!health || health.status === "no-data") && - !health?.lastCheckedAt; - const healthDescription = isError + const errorDetail = isError ? `${t("factoryRoute.automationDiagnosticsLoadError")} ${error instanceof Error ? error.message : String(error)}` - : health?.lastError - ? `${t("factoryRoute.automationHealthErrorDetail")}: ${health.lastError}` - : health?.status === "stale" - ? t("factoryRoute.automationHealthStaleHint") - : hasNoHeartbeat - ? t("factoryRoute.automationHealthNoDataHint") - : undefined; + : health?.lastError || null; return ( - + + {healthLabel} + } + /> + - - {healthLabel} - - {health?.lastCheckedAt && ( - - {t("factoryRoute.automationLastCheck")}:{" "} - {formatAutomationDate(health.lastCheckedAt)} - - )} - {health?.lastDispatchedAt && ( - - {t("factoryRoute.automationLastDispatch")}:{" "} - {formatAutomationDate(health.lastDispatchedAt)} - - )} -
+ + {formatAutomationDate(health?.lastCheckedAt)} + } /> + + {formatAutomationDate(health?.lastDispatchedAt)} + + } + /> + {errorDetail ? ( + + {errorDetail} + + } + /> + ) : null} ); } diff --git a/templates/factory/app/components/factory/FactorySourceSettingsGroup.tsx b/templates/factory/app/components/factory/FactorySourceSettingsGroup.tsx new file mode 100644 index 0000000000..0188aaf64c --- /dev/null +++ b/templates/factory/app/components/factory/FactorySourceSettingsGroup.tsx @@ -0,0 +1,31 @@ +import { SettingsGroup } from "@agent-native/core/client/settings"; +import type { ReactNode } from "react"; + +export function FactorySourceSettingsGroup({ + title, + description, + optionalLabel, + children, +}: { + title: string; + description: string; + optionalLabel: string; + children: ReactNode; +}) { + return ( +
+
+
+

{title}

+ + {optionalLabel} + +
+

+ {description} +

+
+ {children} +
+ ); +} diff --git a/templates/factory/app/components/factory/NewFactoryForm.tsx b/templates/factory/app/components/factory/NewFactoryForm.tsx index e613110803..c103b0d3ba 100644 --- a/templates/factory/app/components/factory/NewFactoryForm.tsx +++ b/templates/factory/app/components/factory/NewFactoryForm.tsx @@ -1,28 +1,28 @@ import { useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; -import { - IconAlertCircle, - IconBrandGithub, - IconBrandSlack, - IconLoader2, -} from "@tabler/icons-react"; +import { SettingsGroup, SettingsRow } from "@agent-native/core/client/settings"; +import { IconLoader2 } from "@tabler/icons-react"; import { useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { FactorySourceSettingsGroup } from "./FactorySourceSettingsGroup"; + type CreateFactoryResult = { ok: boolean; factoryId: string; name: string; }; +const fieldControlClass = "h-9 w-full sm:w-64"; +const selectControlClass = + "h-9 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-64"; + export function NewFactoryForm({ onCreated, }: { @@ -32,8 +32,12 @@ export function NewFactoryForm({ const createMutation = useActionMutation("create-factory"); const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [slackWorkspace, setSlackWorkspace] = useState<"primary" | "secondary">( + "primary", + ); const [slackChannelId, setSlackChannelId] = useState(""); const [slackChannelName, setSlackChannelName] = useState(""); + const [builderSlackUserId, setBuilderSlackUserId] = useState(""); const [observeSlack, setObserveSlack] = useState(false); const [repository, setRepository] = useState(""); const [observeGithub, setObserveGithub] = useState(false); @@ -49,16 +53,26 @@ export function NewFactoryForm({ toast.error(t("factoryRoute.createFactoryNameRequired")); return; } + const hasSlackFields = + slackChannelId.trim() || + slackChannelName.trim() || + builderSlackUserId.trim() || + observeSlack || + slackWorkspace !== "primary"; try { const result = (await createMutation.mutateAsync({ name: trimmedName, ...(description.trim() ? { description: description.trim() } : {}), + ...(hasSlackFields ? { slackWorkspace } : {}), ...(slackChannelId.trim() ? { slackChannelId: slackChannelId.trim() } : {}), ...(slackChannelName.trim() ? { slackChannelName: slackChannelName.trim() } : {}), + ...(builderSlackUserId.trim() + ? { builderSlackUserId: builderSlackUserId.trim() } + : {}), ...(observeSlack ? { observeSlack: true } : {}), ...(repository.trim() ? { repository: repository.trim() } : {}), ...(observeGithub ? { observeGithub: true } : {}), @@ -86,164 +100,190 @@ export function NewFactoryForm({ return (
void handleSubmit(event)} > -
- - setName(event.target.value)} - placeholder={t("factoryRoute.newFactory")} - autoFocus + + setName(event.target.value)} + placeholder={t("factoryRoute.newFactory")} + autoFocus + className={fieldControlClass} + /> + } /> -
-
- -