diff --git a/__tests__/hooks/useFlowRuns.test.tsx b/__tests__/hooks/useFlowRuns.test.tsx new file mode 100644 index 0000000..9ffa968 --- /dev/null +++ b/__tests__/hooks/useFlowRuns.test.tsx @@ -0,0 +1,88 @@ +/** + * Tests for useFlowRuns / useTriggerFlow — validates run-history fetch and, + * critically, that a 200 response carrying an inner { success: false } envelope + * is surfaced as a failure (not a false success). + */ +import React from "react"; +import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/* ---- Mock the SDK client ---- */ +const mockListRuns = jest.fn(); +const mockExecute = jest.fn(); +const mockClient = { + automation: { listRuns: mockListRuns, execute: mockExecute, getRun: jest.fn() }, +}; +jest.mock("@objectstack/client-react", () => ({ + useClient: () => mockClient, +})); + +import { useFlowRuns, useTriggerFlow } from "~/hooks/useFlowRuns"; + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +beforeEach(() => { + mockListRuns.mockReset(); + mockExecute.mockReset(); +}); + +describe("useFlowRuns", () => { + it("returns the run list + hasMore", async () => { + mockListRuns.mockResolvedValue({ + runs: [{ id: "r1", flowName: "f", status: "success" }], + hasMore: true, + }); + const { result } = renderHook(() => useFlowRuns("f"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockListRuns).toHaveBeenCalledWith("f", { limit: 25 }); + expect(result.current.data).toEqual({ + runs: [{ id: "r1", flowName: "f", status: "success" }], + hasMore: true, + }); + }); + + it("defaults to an empty list when the response is empty", async () => { + mockListRuns.mockResolvedValue(undefined); + const { result } = renderHook(() => useFlowRuns("f"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ runs: [], hasMore: false }); + }); +}); + +describe("useTriggerFlow", () => { + it("reports ok on a successful run", async () => { + mockExecute.mockResolvedValue({ runId: "r9", status: "success" }); + const { result } = renderHook(() => useTriggerFlow(), { wrapper }); + let res: { ok: boolean; error?: string } = { ok: false }; + await act(async () => { + res = await result.current.trigger("f"); + }); + expect(res.ok).toBe(true); + }); + + it("surfaces an inner { success: false } envelope as a failure", async () => { + // HTTP 200 but the engine couldn't run the flow. + mockExecute.mockResolvedValue({ success: false, error: "Flow 'f' not found" }); + const { result } = renderHook(() => useTriggerFlow(), { wrapper }); + let res: { ok: boolean; error?: string } = { ok: true }; + await act(async () => { + res = await result.current.trigger("f"); + }); + expect(res.ok).toBe(false); + expect(res.error).toBe("Flow 'f' not found"); + }); + + it("reports the error message when the request throws", async () => { + mockExecute.mockRejectedValue(new Error("network down")); + const { result } = renderHook(() => useTriggerFlow(), { wrapper }); + let res: { ok: boolean; error?: string } = { ok: true }; + await act(async () => { + res = await result.current.trigger("f"); + }); + expect(res.ok).toBe(false); + expect(res.error).toBe("network down"); + }); +}); diff --git a/app/flows/[name].tsx b/app/flows/[name].tsx deleted file mode 100644 index f1b48e9..0000000 --- a/app/flows/[name].tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { View, Text, ScrollView } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { useLocalSearchParams } from "expo-router"; -import { Workflow } from "lucide-react-native"; -import { ScreenHeader } from "~/components/common/ScreenHeader"; -import { Badge } from "~/components/ui/Badge"; -import { EmptyState } from "~/components/ui/EmptyState"; -import { ListSkeleton } from "~/components/ui/ListSkeleton"; -import { FlowViewer } from "~/components/automation/FlowViewer"; -import { useFlow } from "~/hooks/useFlows"; - -function humanize(token: string): string { - return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - -/** - * Flow detail — renders a read-only `FlowViewer` diagram of a single automation - * flow's nodes and edges, plus its trigger/status metadata. - */ -export default function FlowDetailScreen() { - const { name } = useLocalSearchParams<{ name: string }>(); - const flowName = Array.isArray(name) ? name[0] : name; - const { flow, isLoading, error } = useFlow(flowName); - - if (isLoading) { - return ( - - - - - ); - } - - if (error || !flow) { - return ( - - - - - ); - } - - const inputs = flow.variables.filter((v) => v.isInput); - - return ( - - - - {/* Metadata summary */} - - {flow.description ? ( - {flow.description} - ) : null} - - {flow.type ? {humanize(flow.type)} : null} - - {flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"} - - {typeof flow.version === "number" ? ( - {`v${flow.version}`} - ) : null} - {inputs.length > 0 ? ( - {`${inputs.length} input${inputs.length === 1 ? "" : "s"}`} - ) : null} - - - - {/* Flow diagram */} - - - - - ); -} diff --git a/app/flows/[name]/index.tsx b/app/flows/[name]/index.tsx new file mode 100644 index 0000000..19cf79c --- /dev/null +++ b/app/flows/[name]/index.tsx @@ -0,0 +1,154 @@ +import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Workflow, Play } from "lucide-react-native"; +import { ScreenHeader } from "~/components/common/ScreenHeader"; +import { Badge } from "~/components/ui/Badge"; +import { EmptyState } from "~/components/ui/EmptyState"; +import { ListSkeleton } from "~/components/ui/ListSkeleton"; +import { useToast } from "~/components/ui/Toast"; +import { useConfirm } from "~/components/ui/ConfirmDialog"; +import { FlowViewer } from "~/components/automation/FlowViewer"; +import { FlowRunList } from "~/components/automation/FlowRunList"; +import { useFlow } from "~/hooks/useFlows"; +import { useFlowRuns, useTriggerFlow } from "~/hooks/useFlowRuns"; + +function humanize(token: string): string { + return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Flow detail — a read-only `FlowViewer` diagram of one automation flow, plus a + * "Run" action (manual trigger) and the recent run history. + */ +export default function FlowDetailScreen() { + const { name } = useLocalSearchParams<{ name: string }>(); + const flowName = Array.isArray(name) ? name[0] : name; + const router = useRouter(); + const { t } = useTranslation(); + const { toastSuccess, toastError } = useToast(); + const confirm = useConfirm(); + + const { flow, isLoading, error } = useFlow(flowName); + const { data: runsData, isLoading: runsLoading } = useFlowRuns(flowName); + const { trigger, isTriggering } = useTriggerFlow(); + + const runs = runsData?.runs ?? []; + + const handleRun = async () => { + if (!flow) return; + const ok = await confirm({ + title: t("workflow.runFlow"), + message: t("workflow.runConfirm", { flow: flow.label }), + confirmLabel: t("workflow.runLabel"), + }); + if (!ok) return; + const result = await trigger(flow.name); + if (result.ok) { + toastSuccess(t("workflow.runStarted")); + } else { + toastError(result.error ?? t("workflow.runFailed")); + } + }; + + if (isLoading) { + return ( + + + + + ); + } + + if (error || !flow) { + return ( + + + + + ); + } + + const inputs = flow.variables.filter((v) => v.isInput); + + return ( + + + {isTriggering ? ( + + ) : ( + + )} + + {t("workflow.runLabel")} + + + } + /> + + {/* Metadata summary */} + + {flow.description ? ( + {flow.description} + ) : null} + + {flow.type ? {humanize(flow.type)} : null} + + {flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"} + + {typeof flow.version === "number" ? ( + {`v${flow.version}`} + ) : null} + {inputs.length > 0 ? ( + {`${inputs.length} input${inputs.length === 1 ? "" : "s"}`} + ) : null} + + + + + {/* Flow diagram */} + + + {/* Run history */} + + + {t("workflow.runHistory")} + + {runsLoading ? ( + + ) : runs.length === 0 ? ( + {t("workflow.noRuns")} + ) : ( + + router.push( + `/flows/${encodeURIComponent(flow.name)}/runs/${encodeURIComponent(run.id)}`, + ) + } + /> + )} + + + + ); +} diff --git a/app/flows/[name]/runs/[runId].tsx b/app/flows/[name]/runs/[runId].tsx new file mode 100644 index 0000000..3d6d18f --- /dev/null +++ b/app/flows/[name]/runs/[runId].tsx @@ -0,0 +1,119 @@ +import { View, Text, ScrollView } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Workflow, CheckCircle2, XCircle, MinusCircle } from "lucide-react-native"; +import { ScreenHeader } from "~/components/common/ScreenHeader"; +import { Badge } from "~/components/ui/Badge"; +import { EmptyState } from "~/components/ui/EmptyState"; +import { ListSkeleton } from "~/components/ui/ListSkeleton"; +import { runStatusVariant } from "~/components/automation/FlowRunList"; +import { useFlowRun, type FlowStepLog } from "~/hooks/useFlowRuns"; +import { formatDateTime } from "~/lib/formatting"; + +function humanize(token: string): string { + return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function StepIcon({ status }: { status: FlowStepLog["status"] }) { + if (status === "success") return ; + if (status === "failure") return ; + return ; +} + +function StepRow({ step }: { step: FlowStepLog }) { + return ( + + + + + + + {step.nodeLabel ?? step.nodeId} + + + {humanize(step.nodeType)} + {typeof step.durationMs === "number" ? ` · ${step.durationMs} ms` : ""} + + {step.error?.message ? ( + {step.error.message} + ) : null} + + + ); +} + +/** + * Flow run detail — shows one execution's status, timing, and per-node step log. + */ +export default function FlowRunDetailScreen() { + const { name, runId } = useLocalSearchParams<{ name: string; runId: string }>(); + const flowName = Array.isArray(name) ? name[0] : name; + const runIdStr = Array.isArray(runId) ? runId[0] : runId; + const { t } = useTranslation(); + const { data: run, isLoading, error } = useFlowRun(flowName, runIdStr); + + const backFallback = `/flows/${encodeURIComponent(flowName ?? "")}`; + + if (isLoading) { + return ( + + + + + ); + } + + if (error || !run) { + return ( + + + + + ); + } + + const steps = run.steps ?? []; + + return ( + + + + {/* Run summary */} + + + {humanize(run.status)} + {run.trigger?.type ? {humanize(run.trigger.type)} : null} + {typeof run.durationMs === "number" ? ( + {`${run.durationMs} ms`} + ) : null} + + {run.startedAt ? ( + + {formatDateTime(run.startedAt)} + + ) : null} + {run.error?.message ? ( + {run.error.message} + ) : null} + + + {/* Step log */} + + + {t("workflow.steps")} + + {steps.length === 0 ? ( + + ) : ( + steps.map((step, i) => ) + )} + + + ); +} diff --git a/apps/server/objectstack.config.ts b/apps/server/objectstack.config.ts index 786812a..01144e7 100644 --- a/apps/server/objectstack.config.ts +++ b/apps/server/objectstack.config.ts @@ -1,4 +1,5 @@ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; +import { AutomationServicePlugin } from '@objectstack/service-automation'; import * as objects from './src/objects'; const stack: ObjectStackDefinition = defineStack({ @@ -12,6 +13,11 @@ const stack: ObjectStackDefinition = defineStack({ }, objects: Object.values(objects), + + // Enable the automation engine so flows can be triggered + leave a run log + // (exposes /api/v1/automation/{name}/trigger and /runs). The plugin seeds the + // built-in node executors itself (ADR-0018). + plugins: [new AutomationServicePlugin()], }); export default stack; diff --git a/apps/server/package.json b/apps/server/package.json index 534457c..d128a7f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,7 +14,10 @@ "@objectstack/spec": "^7.5.0", "@objectstack/runtime": "^7.5.0", "@objectstack/objectql": "^7.5.0", - "@objectstack/driver-memory": "^7.5.0" + "@objectstack/driver-memory": "^7.5.0", + "@objectstack/service-automation": "^7.5.0", + "@objectstack/plugin-trigger-record-change": "^7.5.0", + "@objectstack/plugin-trigger-schedule": "^7.5.0" }, "devDependencies": { "@objectstack/cli": "^7.5.0", diff --git a/components/automation/FlowRunList.tsx b/components/automation/FlowRunList.tsx new file mode 100644 index 0000000..35790e5 --- /dev/null +++ b/components/automation/FlowRunList.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { View, Text } from "react-native"; +import { ChevronRight } from "lucide-react-native"; +import { PressableCard } from "~/components/ui/PressableCard"; +import { Badge } from "~/components/ui/Badge"; +import { formatDateTime } from "~/lib/formatting"; +import type { FlowRun } from "~/hooks/useFlowRuns"; + +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +/** Map an execution status to a badge variant. */ +export function runStatusVariant(status: string | undefined): BadgeVariant { + switch ((status ?? "").toLowerCase()) { + case "success": + case "completed": + return "default"; + case "failure": + case "failed": + case "error": + return "destructive"; + case "running": + case "pending": + case "in_progress": + return "secondary"; + default: + return "outline"; + } +} + +function humanize(token: string): string { + return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export interface FlowRunListProps { + runs: FlowRun[]; + onPressRun?: (run: FlowRun) => void; +} + +/** A tappable list of flow executions (status, trigger, time). */ +export function FlowRunList({ runs, onPressRun }: FlowRunListProps) { + return ( + + {runs.map((run) => ( + onPressRun?.(run)} + className="flex-row items-center rounded-xl border border-border bg-card px-4 py-3" + accessibilityLabel={`Run ${run.id}, status ${run.status}`} + > + + + {humanize(run.status)} + {run.trigger?.type ? ( + {humanize(run.trigger.type)} + ) : null} + + {run.startedAt ? ( + + {formatDateTime(run.startedAt)} + {typeof run.durationMs === "number" ? ` · ${run.durationMs} ms` : ""} + + ) : null} + + + + ))} + + ); +} diff --git a/hooks/useFlowRuns.ts b/hooks/useFlowRuns.ts new file mode 100644 index 0000000..3e713c3 --- /dev/null +++ b/hooks/useFlowRuns.ts @@ -0,0 +1,124 @@ +import { useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { useClient } from "@objectstack/client-react"; +import { useCallback, useState } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface FlowStepLog { + nodeId: string; + nodeType: string; + nodeLabel?: string; + status: "success" | "failure" | "skipped"; + startedAt?: string; + completedAt?: string; + durationMs?: number; + error?: { code?: string; message?: string }; +} + +/** A single flow execution, mirroring service-automation `ExecutionLogEntry`. */ +export interface FlowRun { + id: string; + flowName: string; + flowVersion?: number; + status: string; + startedAt?: string; + completedAt?: string; + durationMs?: number; + trigger?: { type?: string; userId?: string; object?: string; recordId?: string }; + steps?: FlowStepLog[]; + error?: { code?: string; message?: string }; +} + +export interface FlowRunsResult { + runs: FlowRun[]; + hasMore: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Run history */ +/* ------------------------------------------------------------------ */ + +/** + * List recent executions of a flow (`GET /automation/{name}/runs`). Returns an + * empty list (not an error) when the server has no automation engine, so the UI + * can render an empty state rather than a failure. + */ +export function useFlowRuns( + flowName: string | undefined, +): UseQueryResult { + const client = useClient(); + return useQuery({ + queryKey: ["flow-runs", flowName], + enabled: !!flowName, + queryFn: async (): Promise => { + const res = await client.automation.listRuns<{ + runs?: FlowRun[]; + hasMore?: boolean; + }>(flowName!, { limit: 25 }); + return { runs: res?.runs ?? [], hasMore: res?.hasMore ?? false }; + }, + }); +} + +/** Fetch a single run with its step log (`GET /automation/{name}/runs/{id}`). */ +export function useFlowRun( + flowName: string | undefined, + runId: string | undefined, +): UseQueryResult { + const client = useClient(); + return useQuery({ + queryKey: ["flow-run", flowName, runId], + enabled: !!flowName && !!runId, + queryFn: async () => (await client.automation.getRun(flowName!, runId!)) ?? null, + }); +} + +/* ------------------------------------------------------------------ */ +/* Trigger */ +/* ------------------------------------------------------------------ */ + +export interface TriggerResult { + ok: boolean; + error?: string; + data?: unknown; +} + +/** + * Run a flow on demand (`POST /automation/{name}/trigger`). The engine wraps the + * handler result, so an HTTP 200 can still carry `{ success: false, error }` — + * surface that as a failure rather than reporting a false success. + */ +export function useTriggerFlow(): { + trigger: (flowName: string, ctx?: Record) => Promise; + isTriggering: boolean; +} { + const client = useClient(); + const queryClient = useQueryClient(); + const [isTriggering, setIsTriggering] = useState(false); + + const trigger = useCallback( + async (flowName: string, ctx?: Record): Promise => { + setIsTriggering(true); + try { + const data = await client.automation.execute(flowName, ctx ?? {}); + // Unwrapped handler result may itself signal a logical failure. + const inner = data as { success?: boolean; error?: string } | null; + if (inner && inner.success === false) { + return { ok: false, error: inner.error ?? "Flow execution failed", data }; + } + return { ok: true, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Flow execution failed" }; + } finally { + setIsTriggering(false); + // Refresh the run history regardless of outcome. + void queryClient.invalidateQueries({ queryKey: ["flow-runs", flowName] }); + } + }, + [client, queryClient], + ); + + return { trigger, isTriggering }; +} diff --git a/locales/ar.json b/locales/ar.json index ce3917f..4a0fd8d 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -73,7 +73,15 @@ "updateStatus": "تحديث الحالة", "moveToConfirm": "نقل هذا السجل إلى \"{{state}}\"؟", "statusUpdated": "تم تحديث الحالة.", - "statusUpdateFailed": "فشل تحديث الحالة." + "statusUpdateFailed": "فشل تحديث الحالة.", + "runFlow": "تشغيل التدفق", + "runConfirm": "تشغيل \"{{flow}}\" الآن؟", + "runStarted": "بدأ تشغيل التدفق.", + "runFailed": "فشل تشغيل التدفق.", + "runHistory": "سجل التشغيل", + "noRuns": "لا توجد عمليات تشغيل بعد.", + "steps": "الخطوات", + "runLabel": "تشغيل" }, "actions": { "title": "إجراءات", diff --git a/locales/en.json b/locales/en.json index 2cbb247..f2cfbda 100644 --- a/locales/en.json +++ b/locales/en.json @@ -69,7 +69,15 @@ "updateStatus": "Update Status", "moveToConfirm": "Move this record to \"{{state}}\"?", "statusUpdated": "Status updated.", - "statusUpdateFailed": "Failed to update status." + "statusUpdateFailed": "Failed to update status.", + "runFlow": "Run Flow", + "runConfirm": "Run \"{{flow}}\" now?", + "runStarted": "Flow run started.", + "runFailed": "Flow run failed.", + "runHistory": "Run History", + "noRuns": "No runs yet.", + "steps": "Steps", + "runLabel": "Run" }, "actions": { "title": "Actions", diff --git a/locales/zh.json b/locales/zh.json index e3024ff..75eada4 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -68,7 +68,15 @@ "updateStatus": "更新状态", "moveToConfirm": "将此记录移动到“{{state}}”?", "statusUpdated": "状态已更新。", - "statusUpdateFailed": "更新状态失败。" + "statusUpdateFailed": "更新状态失败。", + "runFlow": "运行流程", + "runConfirm": "立即运行“{{flow}}”?", + "runStarted": "流程已开始运行。", + "runFailed": "流程运行失败。", + "runHistory": "运行历史", + "noRuns": "暂无运行记录。", + "steps": "步骤", + "runLabel": "运行" }, "actions": { "title": "操作", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b07e0f0..5d4cb10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,7 +239,7 @@ importers: version: 19.1.0(react@19.1.0) ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@29.7.0)(jest@29.7.0(@types/node@25.2.2))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.2.2))(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -307,9 +307,18 @@ importers: '@objectstack/objectql': specifier: ^7.5.0 version: 7.5.0(ai@6.0.193(zod@4.4.3)) + '@objectstack/plugin-trigger-record-change': + specifier: ^7.5.0 + version: 7.5.0(ai@6.0.193(zod@4.4.3)) + '@objectstack/plugin-trigger-schedule': + specifier: ^7.5.0 + version: 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/runtime': specifier: ^7.5.0 version: 7.5.0(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(ai@6.0.193(zod@4.4.3))(better-call@1.3.5(zod@4.3.6))(better-sqlite3@12.10.0)(jose@6.1.3)(kysely@0.29.2)(mongodb@7.2.0)(nanostores@1.3.0)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@objectstack/service-automation': + specifier: ^7.5.0 + version: 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/spec': specifier: ^7.5.0 version: 7.5.0(ai@6.0.193(zod@4.4.3)) @@ -10503,7 +10512,7 @@ snapshots: '@objectstack/plugin-webhooks': 7.5.0(ai@6.0.193(zod@4.3.6)) '@objectstack/rest': 7.5.0(ai@6.0.193(zod@4.3.6)) '@objectstack/runtime': 7.5.0(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(ai@6.0.193(zod@4.3.6))(better-call@1.3.5(zod@4.3.6))(better-sqlite3@12.10.0)(jose@6.1.3)(kysely@0.29.2)(mongodb@7.2.0)(nanostores@1.3.0)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@objectstack/service-ai': 7.5.0(@ai-sdk/gateway@3.0.121(zod@4.3.6)) + '@objectstack/service-ai': 7.5.0(@ai-sdk/gateway@3.0.121(zod@4.4.3)) '@objectstack/service-analytics': 7.5.0(ai@6.0.193(zod@4.3.6)) '@objectstack/service-automation': 7.5.0(ai@6.0.193(zod@4.3.6)) '@objectstack/service-cache': 7.5.0(ai@6.0.193(zod@4.3.6)) @@ -10607,7 +10616,7 @@ snapshots: '@objectstack/plugin-webhooks': 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/rest': 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/runtime': 7.5.0(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(ai@6.0.193(zod@4.4.3))(better-call@1.3.5(zod@4.3.6))(better-sqlite3@12.10.0)(jose@6.1.3)(kysely@0.29.2)(mongodb@7.2.0)(nanostores@1.3.0)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@objectstack/service-ai': 7.5.0(@ai-sdk/gateway@3.0.121(zod@4.3.6)) + '@objectstack/service-ai': 7.5.0(@ai-sdk/gateway@3.0.121(zod@4.4.3)) '@objectstack/service-analytics': 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/service-automation': 7.5.0(ai@6.0.193(zod@4.4.3)) '@objectstack/service-cache': 7.5.0(ai@6.0.193(zod@4.4.3)) @@ -11386,7 +11395,7 @@ snapshots: - vitest - vue - '@objectstack/service-ai@7.5.0(@ai-sdk/gateway@3.0.121(zod@4.3.6))': + '@objectstack/service-ai@7.5.0(@ai-sdk/gateway@3.0.121(zod@4.4.3))': dependencies: '@ai-sdk/provider': 3.0.10 '@objectstack/core': 7.5.0(ai@6.0.193(zod@4.4.3)) @@ -18628,7 +18637,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@29.7.0)(jest@29.7.0(@types/node@25.2.2))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.2.2))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -18646,7 +18655,6 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.0) - esbuild: 0.28.0 jest-util: 29.7.0 ts-morph@28.0.0: