diff --git a/.changeset/run-nested-automations-by-path.md b/.changeset/run-nested-automations-by-path.md new file mode 100644 index 0000000000..702a9d6c89 --- /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, 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. diff --git a/packages/core/src/client/agent-page/use-jobs.ts b/packages/core/src/client/agent-page/use-jobs.ts index 7307776dbc..6bb25e9601 100644 --- a/packages/core/src/client/agent-page/use-jobs.ts +++ b/packages/core/src/client/agent-page/use-jobs.ts @@ -69,7 +69,8 @@ export type ManageJobInput = { export type ManageAutomationInput = ManageJobInput; export interface RunAutomationNowInput { - name: string; + name?: string; + path?: string; scope: "personal" | "organization"; } @@ -213,12 +214,11 @@ export function useRunAutomationNow() { 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 }], }); queryClient.invalidateQueries({ queryKey: ["action", "list-automations", { scope }], diff --git a/packages/core/src/jobs/actions/run-automation-now.ts b/packages/core/src/jobs/actions/run-automation-now.ts index bb96f351fc..38b7423b53 100644 --- a/packages/core/src/jobs/actions/run-automation-now.ts +++ b/packages/core/src/jobs/actions/run-automation-now.ts @@ -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//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, }); }, }); 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..975e312539 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:3000"); + } 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/triggers/actions.spec.ts b/packages/core/src/triggers/actions.spec.ts index c1ff1c7be5..07c5406882 100644 --- a/packages/core/src/triggers/actions.spec.ts +++ b/packages/core/src/triggers/actions.spec.ts @@ -46,8 +46,10 @@ vi.mock("../localization/user-timezone.js", () => ({ resolveUserSchedulingTimezone: resolveUserSchedulingTimezoneMock, })); -vi.mock("../jobs/run-history.js", () => ({ - deleteAutomationRuns: deleteAutomationRunsMock, +const queueAutomationRunNowMock = vi.hoisted(() => vi.fn()); + +vi.mock("../jobs/run-now.js", () => ({ + queueAutomationRunNow: queueAutomationRunNowMock, })); vi.mock("../integrations/remote-devices-store.js", () => ({ @@ -67,6 +69,11 @@ describe("manage-automations tool", () => { refreshEventSubscriptionsMock.mockResolvedValue(undefined); resolveUserSchedulingTimezoneMock.mockResolvedValue("America/Los_Angeles"); deleteAutomationRunsMock.mockResolvedValue(undefined); + queueAutomationRunNowMock.mockResolvedValue({ + queued: true, + runId: "run-1", + automationRunId: "run-1", + }); listRemoteDevicesForOwnerMock.mockResolvedValue([]); getRemoteExecutionCapabilitiesMock.mockReturnValue(null); }); @@ -438,6 +445,22 @@ Record the signal.`, ); expect(result).toBe("Error: an automation cannot run another automation."); - expect(resourceGetByPathMock).not.toHaveBeenCalled(); + expect(queueAutomationRunNowMock).not.toHaveBeenCalled(); + }); + + it("runs a nested automation by path without sending an empty name", async () => { + const path = "jobs/factories/enzo-test-factory-3/factory-slack-feedback.md"; + + await tool().run({ action: "run-now", path, scope: "organization" }); + + expect(queueAutomationRunNowMock).toHaveBeenCalledWith( + expect.objectContaining({ + path, + scope: "organization", + }), + ); + expect(queueAutomationRunNowMock.mock.calls[0]?.[0]).not.toHaveProperty( + "name", + ); }); }); diff --git a/packages/core/src/triggers/actions.ts b/packages/core/src/triggers/actions.ts index 3b520ed0b0..eea34dd0ff 100644 --- a/packages/core/src/triggers/actions.ts +++ b/packages/core/src/triggers/actions.ts @@ -379,12 +379,15 @@ async function handleRunNow( return "Error: an automation cannot run another automation."; } try { + const path = typeof args.path === "string" ? args.path.trim() : ""; + const name = typeof args.name === "string" ? args.name : ""; const result = await queueAutomationRunNow({ userEmail: getCurrentUser(), orgId: getRequestOrgId(), appId, scope: automationScope(args.scope), - name: typeof args.name === "string" ? args.name : "", + ...(path ? { path } : { name }), + requestHeaders: context?.requestHeaders, }); return JSON.stringify(result); } catch (error) { @@ -423,7 +426,7 @@ export function createAutomationToolEntries( - **update**: Update an existing automation's settings without changing its creator (enabled, schedule, timezone, condition, body, policy, model, execution host, MCP allowlist). Required param: name. Use the same scope it was created in. - **delete**: Delete an automation. Always confirm with the user first. Required param: name. - **fire-test**: Fire a test event to validate automations. Emits a test.event.fired event. Optional param: data (JSON string). -- **run-now**: Run one automation immediately using its real actions and side effects. This is an explicit user-authorized run and returns a durable run id; it does not change the automation's next scheduled run. Required params: name; optional scope.`, +- **run-now**: Run one automation immediately using its real actions and side effects. This is an explicit user-authorized run and returns a durable run id; it does not change the automation's next scheduled run. Required params: name or path (not both); optional scope. Use path for automations nested under jobs/ (for example jobs/factories//factory-slack-feedback.md); those names contain a slash and cannot round-trip through name.`, parameters: { type: "object" as const, properties: { @@ -436,7 +439,12 @@ export function createAutomationToolEntries( name: { type: "string", description: - "Slug name for the automation (lowercase, hyphens). Used by define, update, delete, and run-now.", + "Slug name for the automation (lowercase, hyphens). Used by define, update, delete, and run-now for flat automations. For nested automations, pass path to run-now instead.", + }, + path: { + type: "string", + description: + "Full jobs resource path (jobs/...md) for a nested automation. Use with run-now instead of name when the automation name contains a slash.", }, scope: { type: "string", 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..4ccf17e1d3 100644 --- a/templates/factory/actions/create-factory.ts +++ b/templates/factory/actions/create-factory.ts @@ -20,6 +20,7 @@ import { } from "../server/lib/factory-automation-plan.js"; import { assertUniqueSlackChannelForFactory, + builderSlackUserIdSchema, factoryConfigRowId, resolveUniqueFactoryId, } from "../server/lib/factory-scope.js"; @@ -40,8 +41,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: builderSlackUserIdSchema.optional(), observeSlack: z.boolean().optional(), repository: z.string().trim().max(256).optional(), observeGithub: z.boolean().optional(), @@ -111,7 +114,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 +134,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/get-slack-feedback-context.ts b/templates/factory/actions/get-slack-feedback-context.ts index 32676dd465..461d22e820 100644 --- a/templates/factory/actions/get-slack-feedback-context.ts +++ b/templates/factory/actions/get-slack-feedback-context.ts @@ -72,6 +72,8 @@ export default defineAction({ threadTs: item.threadTs, coverage: hasMore ? "partial" : "complete", messageCount: messages.length, + itemTitle: item.title, + itemSummary: item.summary, }, }, factoryId, diff --git a/templates/factory/actions/get-triage-item.ts b/templates/factory/actions/get-triage-item.ts index 2118d2c783..cf3627cbf2 100644 --- a/templates/factory/actions/get-triage-item.ts +++ b/templates/factory/actions/get-triage-item.ts @@ -95,6 +95,8 @@ export default defineAction({ feedbackCount: matchingFeedback.length, runCount: runs.length, coverage: item.coverage, + itemTitle: item.title, + itemSummary: item.summary, }, }, factoryId, diff --git a/templates/factory/actions/list-factory-audit.ts b/templates/factory/actions/list-factory-audit.ts index 8d84b6aebd..f14abbc76c 100644 --- a/templates/factory/actions/list-factory-audit.ts +++ b/templates/factory/actions/list-factory-audit.ts @@ -7,9 +7,17 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb } from "../server/db/index.js"; -import { factoryAuditEvents } from "../server/db/schema.js"; +import { + factoryAuditEvents, + triageItems, + triageRuns, +} from "../server/db/schema.js"; +import { projectFactoryAuditReport } from "../server/lib/factory-audit-report.js"; import { factoryIdSchema, + orgFactoryItemFilter, + orgFactoryRunFilter, + readAutomationDisplayName, readAutomationFactoryId, } from "../server/lib/factory-scope.js"; import { @@ -39,23 +47,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(); @@ -80,33 +95,98 @@ export default defineAction({ eventsByRun.set(event.automationRunId, current); } + const itemIds = [ + ...new Set( + events.flatMap((event) => { + const ids: string[] = []; + if (event.itemId) ids.push(event.itemId); + const details = parseDetails(event.detailsJson); + const listed = details.itemIds; + if (Array.isArray(listed)) { + for (const value of listed) { + if (typeof value === "string" && value) ids.push(value); + } + } + return ids; + }), + ), + ]; + const itemRows = itemIds.length + ? await db + .select({ + id: triageItems.id, + title: triageItems.title, + summary: triageItems.summary, + source: triageItems.source, + sourceUrl: triageItems.sourceUrl, + }) + .from(triageItems) + .where( + and( + orgFactoryItemFilter(orgId, factoryId), + inArray(triageItems.id, itemIds), + ), + ) + : []; + const runRows = itemIds.length + ? await db + .select({ + itemId: triageRuns.itemId, + status: triageRuns.status, + error: triageRuns.error, + provider: triageRuns.provider, + startedAt: triageRuns.startedAt, + }) + .from(triageRuns) + .where( + and( + orgFactoryRunFilter(orgId, factoryId), + inArray(triageRuns.itemId, itemIds), + ), + ) + : []; + return { - runs: boundedRuns.map((run) => ({ - id: run.id, - automation: run.automation, - runId: run.runId, - threadId: run.threadId, - status: run.status, - startedAt: run.startedAt, - finishedAt: run.finishedAt, - error: run.error, - events: (eventsByRun.get(run.runId ?? "") ?? []).map((event) => ({ - id: event.id, - automationRunId: event.automationRunId, - automationThreadId: event.automationThreadId, - automationName: event.automationName, - itemId: event.itemId, - source: event.source, - sourceUrl: event.sourceUrl, - action: event.action, - kind: event.kind, - status: event.status, - summary: event.summary, - details: parseDetails(event.detailsJson), - createdAt: event.createdAt, - })), - })), - count: boundedRuns.length, + runs: boundedEntries.map(({ run, displayName }) => { + const mappedEvents = (eventsByRun.get(run.runId ?? "") ?? []).map( + (event) => ({ + id: event.id, + automationRunId: event.automationRunId, + automationThreadId: event.automationThreadId, + automationName: event.automationName, + itemId: event.itemId, + source: event.source, + sourceUrl: event.sourceUrl, + action: event.action, + kind: event.kind, + status: event.status, + summary: event.summary, + details: parseDetails(event.detailsJson), + createdAt: event.createdAt, + }), + ); + const report = projectFactoryAuditReport( + mappedEvents, + itemRows, + runRows, + { startedAt: run.startedAt, finishedAt: run.finishedAt }, + ); + return { + id: run.id, + automation: run.automation, + displayName, + runId: run.runId, + threadId: run.threadId, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + error: run.error, + counts: report.counts, + items: report.items, + trace: report.trace, + }; + }), + 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/list-triage-items.ts b/templates/factory/actions/list-triage-items.ts index f253b41c6e..6b427fc5bc 100644 --- a/templates/factory/actions/list-triage-items.ts +++ b/templates/factory/actions/list-triage-items.ts @@ -106,26 +106,29 @@ export default defineAction({ }; }); - for (const item of listedItems) { - await recordFactoryAudit( - context, - { userEmail, orgId }, - { - action: "list-triage-items", - kind: "read", - itemId: item.itemId, - source: item.source, - sourceUrl: item.sourceUrl, - summary: item.title, - details: { - status: item.status, - coverage: item.coverage, - decision: item.latestDecision?.outcome ?? null, - }, + const purpose = needsReview ? "review_candidates" : "repeat_scan"; + const noun = listedItems.length === 1 ? "item" : "items"; + await recordFactoryAudit( + context, + { userEmail, orgId }, + { + action: "list-triage-items", + kind: "read", + source: source ?? listedItems[0]?.source ?? null, + summary: needsReview + ? `Loaded ${listedItems.length} review candidate${listedItems.length === 1 ? "" : "s"}.` + : `Loaded ${listedItems.length} recent ${source ?? "queue"} ${noun}.`, + details: { + purpose, + limit, + count: listedItems.length, + needsReview, + source: source ?? null, + itemIds: listedItems.map((item) => item.itemId), }, - factoryId, - ); - } + }, + factoryId, + ); return listedItems; }, }); 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/actions/save-triage-config.ts b/templates/factory/actions/save-triage-config.ts index 73cf5e979d..9743ad5926 100644 --- a/templates/factory/actions/save-triage-config.ts +++ b/templates/factory/actions/save-triage-config.ts @@ -13,6 +13,7 @@ import { } from "../server/lib/factory-automation-plan.js"; import { assertUniqueSlackChannelForFactory, + builderSlackUserIdSchema, factoryConfigRowId, factoryIdSchema, readTriageConfigRow, @@ -36,14 +37,7 @@ export default defineAction({ slackWorkspace: workspaceSchema.optional(), slackChannelId: z.string().trim().max(128).optional(), slackChannelName: z.string().trim().max(200).optional(), - builderSlackUserId: z - .string() - .trim() - .max(32) - .refine((value) => value === "" || /^[UW][A-Z0-9]+$/i.test(value), { - message: "Builder Slack member id must look like U01234567.", - }) - .optional(), + builderSlackUserId: builderSlackUserIdSchema.optional(), pollingEnabled: z.boolean().optional(), githubPollingEnabled: z.boolean().optional(), sentryPollingEnabled: z.boolean().optional(), diff --git a/templates/factory/actions/start-builder-for-item.ts b/templates/factory/actions/start-builder-for-item.ts index a903e3ca4f..e48b1690a3 100644 --- a/templates/factory/actions/start-builder-for-item.ts +++ b/templates/factory/actions/start-builder-for-item.ts @@ -670,6 +670,7 @@ export default defineAction({ details: { provider: "bot-tag", runId, + factoryRunId: runId, relatedItemIds: relatedItems.map(({ id }) => id), }, }, @@ -743,6 +744,7 @@ export default defineAction({ details: { provider: "builder-http", runId, + factoryRunId: runId, providerTaskId: result.providerTaskId ?? null, }, }, @@ -765,6 +767,25 @@ export default defineAction({ heartbeatAt: new Date().toISOString(), }) .where(and(eq(triageRuns.id, runId), eq(triageRuns.orgId, orgId))); + await recordFactoryAudit( + context, + { userEmail, orgId }, + { + action: "start-builder-for-item", + kind: "external_action", + factoryId, + itemId, + source: item.source, + sourceUrl: item.sourceUrl, + status: "error", + summary: `Builder dispatch failed: ${message}`, + details: { + provider: isSlack ? "bot-tag" : "builder-http", + runId, + factoryRunId: runId, + }, + }, + ); throw new Error( `Factory Builder dispatch failed after recording the run: ${message}`, ); diff --git a/templates/factory/app/components/factory/FactoryAuditView.layout.test.ts b/templates/factory/app/components/factory/FactoryAuditView.layout.test.ts new file mode 100644 index 0000000000..8cb2ed970d --- /dev/null +++ b/templates/factory/app/components/factory/FactoryAuditView.layout.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +function readViewSource() { + return readFileSync( + new URL("./FactoryAuditView.tsx", import.meta.url), + "utf8", + ); +} + +describe("FactoryAuditView outcome-first audit", () => { + it("renders run headlines from investigated outcomes instead of raw event checks", () => { + const source = readViewSource(); + expect(source).toContain("formatRunHeadline(run.counts, t)"); + expect(source).toContain("run.items"); + expect(source).toContain('t("factoryRoute.auditTrace")'); + expect(source).not.toContain("formatAuditCountLabel"); + expect(source).not.toContain("Slack thread"); + }); +}); diff --git a/templates/factory/app/components/factory/FactoryAuditView.tsx b/templates/factory/app/components/factory/FactoryAuditView.tsx index 05f32353c5..c99872147b 100644 --- a/templates/factory/app/components/factory/FactoryAuditView.tsx +++ b/templates/factory/app/components/factory/FactoryAuditView.tsx @@ -9,16 +9,22 @@ import { IconExternalLink, IconSearch, } from "@tabler/icons-react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useSearchParams } from "react-router"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +type FactoryAuditCounts = { + newlyObserved: number; + scanned: number; + investigated: number; + held: number; + dispatched: number; + failed: number; +}; + type FactoryAuditEvent = { id: string; - automationRunId: string | null; - automationThreadId: string | null; - automationName: string | null; itemId: string | null; source: string | null; sourceUrl: string | null; @@ -30,16 +36,45 @@ type FactoryAuditEvent = { createdAt: string; }; +type FactoryAuditItem = { + itemId: string; + source: string | null; + sourceUrl: string | null; + title: string; + outcome: "held" | "dispatched" | "failed" | "inspected"; + status: string; + rationale: string | null; + dispatchError: string | null; + clearBug: boolean | null; + productUx: boolean | null; + ownerArea: string | null; + guards: string | null; + events: FactoryAuditEvent[]; +}; + +type FactoryAuditTraceStep = { + id: string; + action: string; + summary: string; + status: string; + createdAt: string; + count: number; + purpose: string | null; +}; + type FactoryAuditRun = { id: string; automation: string; + displayName: string | null; runId: string | null; threadId: string | null; status: string; startedAt: number; finishedAt: number | null; error: string | null; - events: FactoryAuditEvent[]; + counts: FactoryAuditCounts; + items: FactoryAuditItem[]; + trace: FactoryAuditTraceStep[]; }; type FactoryAuditResponse = { @@ -47,21 +82,6 @@ type FactoryAuditResponse = { count: number; }; -type AuditItemGroup = { - key: string; - events: FactoryAuditEvent[]; - itemId: string | null; - source: string | null; - sourceUrl: string | null; - title: string; - outcome: string; - status: string; - latestAt: string; - checks: number; - decision: FactoryAuditEvent | null; - externalAction: FactoryAuditEvent | null; -}; - export function FactoryAuditView({ factoryId, refreshToken = 0, @@ -76,7 +96,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 ?? []; @@ -140,14 +166,9 @@ export function FactoryAuditView({
-
- - {t("factoryRoute.auditRuns")} - - - {runs.length} - -
+ + {t("factoryRoute.auditRuns")} +
@@ -167,17 +188,16 @@ export function FactoryAuditView({ >
- {formatAutomationName(run.automation)} + {automationLabel(run)} - +
{formatAuditAge(run.startedAt)} - - {run.events.length}{" "} - {formatAuditCountLabel(run.events.length)} + + {formatRunHeadline(run.counts, t)}
@@ -192,16 +212,19 @@ export function FactoryAuditView({
- {selectedRun && - formatAutomationName(selectedRun.automation)} + {selectedRun && automationLabel(selectedRun)} {selectedRun && (

{formatAuditAge(selectedRun.startedAt)} + + {formatRunHeadline(selectedRun.counts, t)}

)}
- {selectedRun && } + {selectedRun && ( + + )}
@@ -224,35 +247,17 @@ function AuditRunDetail({ factoryId: string; }) { const t = useT(); - const groups = useMemo(() => groupAuditEvents(run.events), [run.events]); - const itemCount = new Set( - run.events - .map((event) => event.itemId) - .filter((itemId): itemId is string => Boolean(itemId)), - ).size; - const decisionCount = run.events.filter( - (event) => event.kind === "decision", - ).length; - const actionCount = run.events.filter( - (event) => event.kind === "external_action", - ).length; + const items = run.items ?? []; + const trace = run.trace ?? []; + const failedItems = items.filter((item) => item.outcome === "failed"); return ( <>
- - {itemCount} {itemCount === 1 ? "item" : "items"} - - - {decisionCount} {decisionCount === 1 ? "decision" : "decisions"} - - - {actionCount} {actionCount === 1 ? "action" : "actions"} - {run.threadId && ( { if ( event.metaKey || @@ -272,167 +277,194 @@ function AuditRunDetail({ )}
- {run.error && ( + {(run.error || failedItems.length > 0) && (

{t("factoryRoute.auditRunError")}

-

{run.error}

+ {run.error ? ( +

+ {run.error} +

+ ) : null} + {failedItems.map((item) => ( +

+ {item.title} + {item.dispatchError ? ` — ${item.dispatchError}` : ""} +

+ ))}
)} -
-
-

Activity

- - {groups.length} {groups.length === 1 ? "thing" : "things"} - -
- {groups.length === 0 ? ( +
+ {items.length === 0 ? (

{t("factoryRoute.auditNoEvents")}

) : ( -
- {groups.map((group) => ( - - ))} -
+ items.map((item) => ( + + )) )}
+ + {trace.length > 0 && ( +
+ + {t("factoryRoute.auditTrace")} + + +
+ {trace.map((step) => ( +
+ {step.summary} + +
+ ))} +
+
+ )} ); } function AuditItemRow({ - group, + item, factoryId, }: { - group: AuditItemGroup; + item: FactoryAuditItem; factoryId: string; }) { const t = useT(); - const decision = group.decision; - const rationale = decision?.summary ?? group.externalAction?.summary ?? null; - const sourceLink = resolveAuditSourceLink(group); + const sourceLink = resolveAuditSourceLink(item); return (
- +
-

{group.title}

+

{item.title}

- {group.outcome} - - {group.checks} {formatAuditCountLabel(group.checks)} + {formatItemOutcome(item.outcome, t)}

- {formatAuditSource(group.source)} + {formatAuditSource(item.source)} - +

- {rationale ? "Why" : "What happened"} + {item.rationale || item.dispatchError + ? t("factoryRoute.auditWhy") + : t("factoryRoute.auditWhatHappened")}

- {rationale ?? - "The source was inspected, but no action was taken."} + {item.dispatchError ?? + item.rationale ?? + t("factoryRoute.auditInspectedOnly")}

- +
-
); } -function AuditDecisionFacts({ event }: { event: FactoryAuditEvent | null }) { +function AuditDecisionFacts({ item }: { item: FactoryAuditItem }) { const t = useT(); - if (!event) return null; - const clearBug = readBooleanDetail(event.details, "clearBug"); - const productUx = readBooleanDetail(event.details, "productUxImplications"); - const ownerArea = readStringDetail(event.details, "ownerOwnedArea"); - const guards = readGuardSummary(event.details.guardResults); - if (clearBug === null && productUx === null && !ownerArea && !guards) + if ( + item.clearBug === null && + item.productUx === null && + !item.ownerArea && + !item.guards + ) { return null; + } return (
- {clearBug !== null && ( + {item.clearBug !== null && ( )} - {productUx !== null && ( + {item.productUx !== null && ( )} - {ownerArea && ( - + {item.ownerArea && ( + )} - {guards && ( - + {item.guards && ( + )}
); @@ -502,117 +534,51 @@ function AuditSkeleton({ rows }: { rows: number }) { ); } -function groupAuditEvents(events: FactoryAuditEvent[]): AuditItemGroup[] { - const grouped = new Map(); - for (const event of events) { - const key = event.itemId ?? `event:${event.id}`; - const current = grouped.get(key) ?? []; - current.push(event); - grouped.set(key, current); +function runHeadlineStatus(run: FactoryAuditRun): string { + if (run.status === "error" || (run.counts?.failed ?? 0) > 0) return "error"; + if (run.status === "running") return "running"; + if ((run.counts?.held ?? 0) > 0 && (run.counts?.dispatched ?? 0) === 0) { + return "skipped"; } - - return [...grouped.entries()] - .map(([key, groupEvents]) => { - const decision = - groupEvents.find((event) => event.kind === "decision") ?? null; - const externalAction = - groupEvents.find((event) => event.kind === "external_action") ?? null; - const titleEvent = - groupEvents.find((event) => event.action === "get-triage-item") ?? - groupEvents.find( - (event) => event.action === "get-slack-feedback-context", - ) ?? - groupEvents.find((event) => event.action === "list-triage-items") ?? - groupEvents.find((event) => event.kind === "observed") ?? - groupEvents[0]; - const latestAt = groupEvents.reduce( - (latest, event) => - new Date(event.createdAt).getTime() > new Date(latest).getTime() - ? event.createdAt - : latest, - groupEvents[0].createdAt, - ); - - return { - key, - events: groupEvents, - itemId: titleEvent.itemId, - source: titleEvent.source, - sourceUrl: titleEvent.sourceUrl, - title: formatAuditSubject(titleEvent), - outcome: formatAuditOutcome(groupEvents, decision, externalAction), - status: formatGroupStatus(groupEvents, decision, externalAction), - latestAt, - checks: groupEvents.filter( - (event) => event.kind === "read" || event.kind === "observed", - ).length, - decision, - externalAction, - }; - }) - .sort( - (left, right) => - new Date(right.latestAt).getTime() - new Date(left.latestAt).getTime(), - ); + return run.status; } -function formatAuditSubject(event: FactoryAuditEvent): string { - if (event.action === "get-slack-feedback-context") { - const messageCount = readNumberDetail(event.details, "messageCount"); - return `Slack thread${messageCount === null ? "" : ` · ${messageCount} ${messageCount === 1 ? "message" : "messages"}`}`; - } - const summary = event.summary.trim(); - const subject = summary.replace(/^(Inspected|Read)\s+/i, ""); - return truncateAuditText(subject || formatAuditAction(event.action), 110); -} - -function formatAuditOutcome( - events: FactoryAuditEvent[], - decision: FactoryAuditEvent | null, - externalAction: FactoryAuditEvent | null, +function formatRunHeadline( + counts: FactoryAuditCounts | undefined, + t: ReturnType, ): string { - if (externalAction) { - const provider = readStringDetail(externalAction.details, "provider"); - if (provider === "bot-tag") return "Builder tagged in Slack"; - if (provider === "builder-http") return "Builder fix submitted"; - return "Action taken"; - } - if (decision) { - const ownerArea = readStringDetail(decision.details, "ownerOwnedArea"); - const productUx = readBooleanDetail( - decision.details, - "productUxImplications", + if (!counts) return ""; + const parts: string[] = []; + if (counts.newlyObserved > 0) { + parts.push( + t("factoryRoute.auditObserved", { count: counts.newlyObserved }), ); - if (decision.status === "skipped" || ownerArea || productUx === true) { - return "Held for review"; - } - if (readBooleanDetail(decision.details, "clearBug") === true) { - return "Builder fix selected"; - } - return "Decision recorded"; + } else { + parts.push(t("factoryRoute.auditNoNew")); } - if ( - events.some( - (event) => - event.kind === "observed" && /no new|no result/i.test(event.summary), - ) - ) { - return "No new items"; + if (counts.scanned > 0 && counts.scanned !== counts.newlyObserved) { + parts.push(t("factoryRoute.auditScanned", { count: counts.scanned })); + } + if (counts.failed > 0) { + parts.push(t("factoryRoute.auditFailed", { count: counts.failed })); + } + if (counts.dispatched > 0) { + parts.push(t("factoryRoute.auditDispatched", { count: counts.dispatched })); } - return "Inspected only"; + if (counts.held > 0) { + parts.push(t("factoryRoute.auditHeld", { count: counts.held })); + } + return parts.join(" · "); } -function formatGroupStatus( - events: FactoryAuditEvent[], - decision: FactoryAuditEvent | null, - externalAction: FactoryAuditEvent | null, +function formatItemOutcome( + outcome: FactoryAuditItem["outcome"], + t: ReturnType, ): string { - if (events.some((event) => event.status === "error")) return "error"; - if (externalAction) return externalAction.status; - if (decision) return decision.status; - return events.some((event) => event.status === "running") - ? "running" - : "success"; + if (outcome === "failed") return t("factoryRoute.auditOutcomeFailed"); + if (outcome === "dispatched") return t("factoryRoute.auditOutcomeDispatched"); + if (outcome === "held") return t("factoryRoute.auditOutcomeHeld"); + return t("factoryRoute.auditOutcomeInspected"); } function formatAuditSource(source: string | null): string { @@ -623,14 +589,9 @@ function formatAuditSource(source: string | null): string { return source ? formatAuditLabel(source) : "Factory"; } -function resolveAuditSourceLink(group: AuditItemGroup): string | null { - const storedUrl = - group.sourceUrl ?? - group.events.find((event) => event.sourceUrl)?.sourceUrl ?? - null; - if (storedUrl) return storedUrl; - - for (const event of group.events) { +function resolveAuditSourceLink(item: FactoryAuditItem): string | null { + if (item.sourceUrl) return item.sourceUrl; + for (const event of item.events) { const channelId = readStringDetail(event.details, "channelId"); const threadTs = readStringDetail(event.details, "threadTs"); if (channelId && threadTs) return slackThreadUrl(channelId, threadTs); @@ -643,6 +604,12 @@ 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; + const segments = run.automation.split("/"); + return formatAutomationName(segments[segments.length - 1] || run.automation); +} + function formatAutomationName(value: string): string { const words = value .replace(/^factory-/, "") @@ -665,10 +632,6 @@ function formatAuditAction(value: string): string { return formatAuditLabel(value).replace(/^Poll /, "Check "); } -function formatAuditCountLabel(value: number): string { - return value === 1 ? "check" : "checks"; -} - function formatAuditAge(value: string | number) { const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); @@ -695,24 +658,6 @@ function formatAuditLabel(value: string): string { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function truncateAuditText(value: string, maxLength: number): string { - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; -} - -function readBooleanDetail( - details: Record, - key: string, -): boolean | null { - return typeof details[key] === "boolean" ? details[key] : null; -} - -function readNumberDetail( - details: Record, - key: string, -): number | null { - return typeof details[key] === "number" ? details[key] : null; -} - function readStringDetail( details: Record, key: string, @@ -720,19 +665,6 @@ function readStringDetail( return typeof details[key] === "string" && details[key] ? details[key] : null; } -function readGuardSummary(value: unknown): string | null { - if (!Array.isArray(value) || value.length === 0) return null; - const passed = value.filter( - (guard): guard is { passed: boolean } => - typeof guard === "object" && - guard !== null && - "passed" in guard && - typeof guard.passed === "boolean" && - guard.passed, - ).length; - return `${passed}/${value.length} passed`; -} - function yesNo(value: boolean): string { return value ? "yes" : "no"; } diff --git a/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts b/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts index 6c699948a4..de07d085df 100644 --- a/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts +++ b/templates/factory/app/components/factory/FactorySettingsView.layout.test.ts @@ -19,12 +19,61 @@ describe("FactorySettingsView load gating", () => { expect(source).toContain("if (!configLoaded) {"); expect(source).toContain("ActionQueryError"); expect(source).toContain("onRetry={() => void query.refetch()}"); - expect(source).toContain("disabled={mutation.isPending || !configLoaded}"); + expect(source).toContain("disabled={saving || !configLoaded}"); expect(source).toMatch( /if \(!configLoaded\) \{\s*toast\.error\(t\("triage\.settingsError"\)\);\s*return;/, ); expect(source).toContain( - "automationFailureAlertEmail: automationFailureAlertEmail.trim()", + "automationFailureAlertEmail: form.automationFailureAlertEmail.trim()", + ); + expect(source).toContain("channelId: form.channelId.trim()"); + expect(source).toContain("repository: form.repository.trim()"); + expect(source).toContain( + "builderSlackUserId: form.builderSlackUserId.trim()", + ); + }); +}); + +describe("FactorySettingsView factory switching", () => { + it("resets hydration when factoryId changes so Factory B cannot inherit Factory A edits", () => { + const source = readViewSource(); + + expect(source).toContain("hydratedRef.current = false"); + expect(source).toContain("dirtyRef.current = false"); + expect(source).toContain("setBaseline(null)"); + expect(source).toContain("}, [factoryId]);"); + }); + + it("keeps unsaved edits when a background refetch delivers new config", () => { + const source = readViewSource(); + + expect(source).toContain( + "if (hydratedRef.current && dirtyRef.current) return;", + ); + }); +}); + +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( + '
', + ); + expect(source).toContain( + "if (isSameForm(trimmedForm(latestFormRef.current), submitted))", ); }); }); diff --git a/templates/factory/app/components/factory/FactorySettingsView.tsx b/templates/factory/app/components/factory/FactorySettingsView.tsx index 25712ec93d..3b7ecda052 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,60 @@ 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], + ); +} + +function trimmedForm(form: TriageFormState): TriageFormState { + return { + ...form, + channelId: form.channelId.trim(), + channelName: form.channelName.trim(), + builderSlackUserId: form.builderSlackUserId.trim(), + repository: form.repository.trim(), + sentryOrgSlug: form.sentryOrgSlug.trim(), + sentryProjectSlug: form.sentryProjectSlug.trim(), + sentryEnvironment: form.sentryEnvironment.trim(), + automationFailureAlertEmail: form.automationFailureAlertEmail.trim(), + }; +} + type FactoryAutomationHealth = { status: "healthy" | "stale" | "error" | "no-data"; lastCheckedAt?: number | null; @@ -78,52 +134,99 @@ 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(() => { + hydratedRef.current = false; + dirtyRef.current = false; + setBaseline(null); + }, [factoryId]); + 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); + const saving = mutation.isPending; + const latestFormRef = useRef(currentForm); + latestFormRef.current = currentForm; + + useEffect(() => { + dirtyRef.current = dirty; + }, [dirty]); + const saveSettings = async () => { if (!configLoaded) { toast.error(t("triage.settingsError")); return; } + const submitted = trimmedForm(currentForm); try { await mutation.mutateAsync({ factoryId, - slackWorkspace: workspace, - slackChannelId: channelId, - slackChannelName: channelName, - builderSlackUserId, - repository, - pollingEnabled: polling, - githubPollingEnabled: githubPolling, - sentryPollingEnabled: sentryPolling, - sentryOrgSlug, - sentryProjectSlug, - sentryEnvironment, - automationFailureAlertsEnabled, - automationFailureAlertEmail: automationFailureAlertEmail.trim(), + slackWorkspace: submitted.workspace, + slackChannelId: submitted.channelId, + slackChannelName: submitted.channelName, + builderSlackUserId: submitted.builderSlackUserId, + repository: submitted.repository, + pollingEnabled: submitted.polling, + githubPollingEnabled: submitted.githubPolling, + sentryPollingEnabled: submitted.sentryPolling, + sentryOrgSlug: submitted.sentryOrgSlug, + sentryProjectSlug: submitted.sentryProjectSlug, + sentryEnvironment: submitted.sentryEnvironment, + automationFailureAlertsEnabled: + submitted.automationFailureAlertsEnabled, + automationFailureAlertEmail: submitted.automationFailureAlertEmail, }); + if (isSameForm(trimmedForm(latestFormRef.current), submitted)) { + applyForm(submitted); + } + setBaseline(submitted); toast.success(t("triage.settingsSaved")); } catch (error) { toast.error( @@ -155,6 +258,34 @@ export function FactorySettingsView({ return (
+ {dirty ? ( +
+
+ + {t("triage.unsavedSettings")} + +
+ + +
+
+
+ ) : null} + - - - setWorkspace(event.target.value as "primary" | "secondary") - } - className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-64" - > - - - - } - /> - setChannelId(event.target.value)} - placeholder={t("triage.slackChannelPlaceholder")} - className={fieldControlClass} - /> - } - /> - setChannelName(event.target.value)} - placeholder={t("triage.slackChannelNamePlaceholder")} - className={fieldControlClass} - /> - } - /> - setBuilderSlackUserId(event.target.value)} - placeholder={t("triage.builderSlackUserIdPlaceholder")} - className={fieldControlClass} - /> - } - /> - setRepository(event.target.value)} - placeholder={t("triage.repositoryPlaceholder")} - className={fieldControlClass} - /> - } - /> - setPolling(checked === true)} - /> - } - /> - setGithubPolling(checked === true)} - /> - } - /> - setSentryOrgSlug(event.target.value)} - placeholder={t("triage.sentryOrgPlaceholder")} - className={fieldControlClass} - /> - } - /> - setSentryProjectSlug(event.target.value)} - placeholder={t("triage.sentryProjectPlaceholder")} - className={fieldControlClass} - /> - } - /> - setSentryEnvironment(event.target.value)} - placeholder={t("triage.sentryEnvironmentPlaceholder")} - className={fieldControlClass} - /> - } - /> - setSentryPolling(checked === true)} - /> - } - /> - +
+ + + setWorkspace(event.target.value as "primary" | "secondary") + } + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-64" + > + + + + } + /> + setChannelId(event.target.value)} + placeholder={t("triage.slackChannelPlaceholder")} + className={fieldControlClass} + /> + } + /> + setChannelName(event.target.value)} + placeholder={t("triage.slackChannelNamePlaceholder")} + className={fieldControlClass} + /> + } + /> + setBuilderSlackUserId(event.target.value)} + placeholder={t("triage.builderSlackUserIdPlaceholder")} + className={fieldControlClass} + /> + } + /> + setPolling(checked === true)} + /> + } + /> + - - - setAutomationFailureAlertsEnabled(checked === true) - } - /> - } - /> - - setAutomationFailureAlertEmail(event.target.value) - } - placeholder={t( - "factoryRoute.automationFailureAlertEmailPlaceholder", - )} - className={fieldControlClass} - /> - } - /> - - {(query.data as TriageConfig | undefined)?.emailReadiness - ?.status ?? "unknown"} - - } - /> - + + setRepository(event.target.value)} + placeholder={t("triage.repositoryPlaceholder")} + className={fieldControlClass} + /> + } + /> + + setGithubPolling(checked === true) + } + /> + } + /> + -
- -
+ setSentryOrgSlug(event.target.value)} + placeholder={t("triage.sentryOrgPlaceholder")} + className={fieldControlClass} + /> + } + /> + setSentryProjectSlug(event.target.value)} + placeholder={t("triage.sentryProjectPlaceholder")} + className={fieldControlClass} + /> + } + /> + setSentryEnvironment(event.target.value)} + placeholder={t("triage.sentryEnvironmentPlaceholder")} + className={fieldControlClass} + /> + } + /> + + setSentryPolling(checked === true) + } + /> + } + /> + + + + + setAutomationFailureAlertsEnabled(checked === true) + } + /> + } + /> + + setAutomationFailureAlertEmail(event.target.value) + } + placeholder={t( + "factoryRoute.automationFailureAlertEmailPlaceholder", + )} + className={fieldControlClass} + /> + } + /> + + {(query.data as TriageConfig | undefined)?.emailReadiness + ?.status ?? "unknown"} + + } + /> + +
}) { return (
- {[2, 11, 3].map((rowCount, index) => ( + {[2, 5, 2, 4, 3, 3].map((rowCount, index) => (
{Array.from({ length: rowCount }).map((_, rowIndex) => ( @@ -416,47 +563,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..be9a082c17 --- /dev/null +++ b/templates/factory/app/components/factory/FactorySourceSettingsGroup.tsx @@ -0,0 +1,33 @@ +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 ? ( +

+ {description} +

+ ) : null} +
+ {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} + /> + } /> -
-
- -