mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 22:26:19 +08:00
Compare commits
2 Commits
fix/cli-to
...
cursor/ref
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f582c1b2f | |||
| f734c35443 |
@ -63,7 +63,7 @@ pnpm analyze-component <path> --json
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ Before: Complex state logic in component
|
// ❌ Before: Complex state logic in component
|
||||||
function Configuration() {
|
const Configuration: FC = () => {
|
||||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||||
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
||||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||||
@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Component becomes cleaner
|
// Component becomes cleaner
|
||||||
function Configuration() {
|
const Configuration: FC = () => {
|
||||||
const { modelConfig, setModelConfig } = useModelConfig(appId)
|
const { modelConfig, setModelConfig } = useModelConfig(appId)
|
||||||
return <div>...</div>
|
return <div>...</div>
|
||||||
}
|
}
|
||||||
@ -189,6 +189,8 @@ const Template = useMemo(() => {
|
|||||||
|
|
||||||
**Dify Convention**:
|
**Dify Convention**:
|
||||||
- This skill is for component decomposition, not query/mutation design.
|
- This skill is for component decomposition, not query/mutation design.
|
||||||
|
- When refactoring data fetching, follow `web/AGENTS.md`.
|
||||||
|
- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling.
|
||||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||||
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
|
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
|
||||||
|
|
||||||
@ -365,7 +367,7 @@ For each extraction:
|
|||||||
┌────────────────────────────────────────┐
|
┌────────────────────────────────────────┐
|
||||||
│ 1. Extract code │
|
│ 1. Extract code │
|
||||||
│ 2. Run: pnpm lint:fix │
|
│ 2. Run: pnpm lint:fix │
|
||||||
│ 3. Run: pnpm type-check │
|
│ 3. Run: pnpm type-check:tsgo │
|
||||||
│ 4. Run: pnpm test │
|
│ 4. Run: pnpm test │
|
||||||
│ 5. Test functionality manually │
|
│ 5. Test functionality manually │
|
||||||
│ 6. PASS? → Next extraction │
|
│ 6. PASS? → Next extraction │
|
||||||
|
|||||||
@ -60,10 +60,8 @@ const Template = useMemo(() => {
|
|||||||
**After** (complexity: ~3):
|
**After** (complexity: ~3):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { ComponentType } from 'react'
|
|
||||||
|
|
||||||
// Define lookup table outside component
|
// Define lookup table outside component
|
||||||
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = {
|
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, FC<TemplateProps>>> = {
|
||||||
[AppModeEnum.CHAT]: {
|
[AppModeEnum.CHAT]: {
|
||||||
[LanguagesSupported[1]]: TemplateChatZh,
|
[LanguagesSupported[1]]: TemplateChatZh,
|
||||||
[LanguagesSupported[7]]: TemplateChatJa,
|
[LanguagesSupported[7]]: TemplateChatJa,
|
||||||
|
|||||||
@ -65,10 +65,10 @@ interface ConfigurationHeaderProps {
|
|||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigurationHeader({
|
const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({
|
||||||
isAdvancedMode,
|
isAdvancedMode,
|
||||||
onPublish,
|
onPublish,
|
||||||
}: ConfigurationHeaderProps) {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -136,7 +136,7 @@ const AppInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ After: Separate view components
|
// ✅ After: Separate view components
|
||||||
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
|
const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
|
||||||
return (
|
return (
|
||||||
<div className="expanded">
|
<div className="expanded">
|
||||||
{/* Clean, focused expanded view */}
|
{/* Clean, focused expanded view */}
|
||||||
@ -144,7 +144,7 @@ function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
|
const AppInfoCollapsed: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
|
||||||
return (
|
return (
|
||||||
<div className="collapsed">
|
<div className="collapsed">
|
||||||
{/* Clean, focused collapsed view */}
|
{/* Clean, focused collapsed view */}
|
||||||
@ -203,12 +203,12 @@ interface AppInfoModalsProps {
|
|||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppInfoModals({
|
const AppInfoModals: FC<AppInfoModalsProps> = ({
|
||||||
appDetail,
|
appDetail,
|
||||||
activeModal,
|
activeModal,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: AppInfoModalsProps) {
|
}) => {
|
||||||
const handleEdit = async (data) => { /* logic */ }
|
const handleEdit = async (data) => { /* logic */ }
|
||||||
const handleDuplicate = async (data) => { /* logic */ }
|
const handleDuplicate = async (data) => { /* logic */ }
|
||||||
const handleDelete = async () => { /* logic */ }
|
const handleDelete = async () => { /* logic */ }
|
||||||
@ -296,7 +296,7 @@ interface OperationItemProps {
|
|||||||
onAction: (id: string) => void
|
onAction: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperationItem({ operation, onAction }: OperationItemProps) {
|
const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => {
|
||||||
return (
|
return (
|
||||||
<div className="operation-item">
|
<div className="operation-item">
|
||||||
<span className="icon">{operation.icon}</span>
|
<span className="icon">{operation.icon}</span>
|
||||||
@ -435,7 +435,7 @@ interface ChildProps {
|
|||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function Child({ value, onChange, onSubmit }: ChildProps) {
|
const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input value={value} onChange={e => onChange(e.target.value)} />
|
<input value={value} onChange={e => onChange(e.target.value)} />
|
||||||
|
|||||||
@ -112,13 +112,13 @@ export const useModelConfig = ({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before: 50+ lines of state management
|
// Before: 50+ lines of state management
|
||||||
function Configuration() {
|
const Configuration: FC = () => {
|
||||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||||
// ... lots of related state and effects
|
// ... lots of related state and effects
|
||||||
}
|
}
|
||||||
|
|
||||||
// After: Clean component
|
// After: Clean component
|
||||||
function Configuration() {
|
const Configuration: FC = () => {
|
||||||
const {
|
const {
|
||||||
modelConfig,
|
modelConfig,
|
||||||
setModelConfig,
|
setModelConfig,
|
||||||
@ -159,6 +159,8 @@ function Configuration() {
|
|||||||
|
|
||||||
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
|
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
|
||||||
|
|
||||||
|
- Follow `web/AGENTS.md` first.
|
||||||
|
- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling.
|
||||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||||
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
|
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS
|
|||||||
- `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter
|
- `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter
|
||||||
3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved.
|
3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved.
|
||||||
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
|
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
|
||||||
5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern.
|
5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern.
|
||||||
|
|
||||||
## Local Rules
|
## Local Rules
|
||||||
|
|
||||||
|
|||||||
@ -9,18 +9,18 @@ Category: Performance
|
|||||||
|
|
||||||
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
|
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
|
||||||
|
|
||||||
## Complex prop stability
|
## Complex prop memoization
|
||||||
|
|
||||||
IsUrgent: False
|
IsUrgent: True
|
||||||
Category: Performance
|
Category: Performance
|
||||||
|
|
||||||
### Description
|
### Description
|
||||||
|
|
||||||
Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization.
|
Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders.
|
||||||
|
|
||||||
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
||||||
|
|
||||||
Risky:
|
Wrong:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<HeavyComp
|
<HeavyComp
|
||||||
@ -31,7 +31,7 @@ Risky:
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
Better when stable identity matters:
|
Right:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const config = useMemo(() => ({
|
const config = useMemo(() => ({
|
||||||
|
|||||||
44
.agents/skills/frontend-query-mutation/SKILL.md
Normal file
44
.agents/skills/frontend-query-mutation/SKILL.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: frontend-query-mutation
|
||||||
|
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions() directly or extract a helper or use-* hook, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Query & Mutation
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
- Keep contract as the single source of truth in `web/contract/*`.
|
||||||
|
- Prefer contract-shaped `queryOptions()` and `mutationOptions()`.
|
||||||
|
- Keep invalidation and mutation flow knowledge in the service layer.
|
||||||
|
- Keep abstractions minimal to preserve TypeScript inference.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Identify the change surface.
|
||||||
|
- Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape.
|
||||||
|
- Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and legacy migrations.
|
||||||
|
- Read both references when a task spans contract shape and runtime behavior.
|
||||||
|
2. Implement the smallest abstraction that fits the task.
|
||||||
|
- Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site.
|
||||||
|
- Extract a small shared query helper only when multiple call sites share the same extra options.
|
||||||
|
- Create `web/service/use-{domain}.ts` only for orchestration or shared domain behavior.
|
||||||
|
3. Preserve Dify conventions.
|
||||||
|
- Keep contract inputs in `{ params, query?, body? }` shape.
|
||||||
|
- Bind invalidation in the service-layer mutation definition.
|
||||||
|
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required.
|
||||||
|
|
||||||
|
## Files Commonly Touched
|
||||||
|
|
||||||
|
- `web/contract/console/*.ts`
|
||||||
|
- `web/contract/marketplace.ts`
|
||||||
|
- `web/contract/router.ts`
|
||||||
|
- `web/service/client.ts`
|
||||||
|
- `web/service/use-*.ts`
|
||||||
|
- component and hook call sites using `consoleQuery` or `marketplaceQuery`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Use `references/contract-patterns.md` for contract shape, router registration, query and mutation helpers, and anti-patterns that degrade inference.
|
||||||
|
- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, and legacy migration rules.
|
||||||
|
|
||||||
|
Treat this skill as the single query and mutation entry point for Dify frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs.
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Frontend Query & Mutation"
|
||||||
|
short_description: "Dify TanStack Query and oRPC patterns"
|
||||||
|
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, conditional queries, invalidation, or legacy query/mutation migrations."
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
# Contract Patterns
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- Intent
|
||||||
|
- Minimal structure
|
||||||
|
- Core workflow
|
||||||
|
- Query usage decision rule
|
||||||
|
- Mutation usage decision rule
|
||||||
|
- Anti-patterns
|
||||||
|
- Contract rules
|
||||||
|
- Type export
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
- Keep contract as the single source of truth in `web/contract/*`.
|
||||||
|
- Default query usage to call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract.
|
||||||
|
- Keep abstractions minimal and preserve TypeScript inference.
|
||||||
|
|
||||||
|
## Minimal Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
web/contract/
|
||||||
|
├── base.ts
|
||||||
|
├── router.ts
|
||||||
|
├── marketplace.ts
|
||||||
|
└── console/
|
||||||
|
├── billing.ts
|
||||||
|
└── ...other domains
|
||||||
|
web/service/client.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Workflow
|
||||||
|
|
||||||
|
1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`.
|
||||||
|
- Use `base.route({...}).output(type<...>())` as the baseline.
|
||||||
|
- Add `.input(type<...>())` only when the request has `params`, `query`, or `body`.
|
||||||
|
- For `GET` without input, omit `.input(...)`; do not use `.input(type<unknown>())`.
|
||||||
|
2. Register contract in `web/contract/router.ts`.
|
||||||
|
- Import directly from domain files and nest by API prefix.
|
||||||
|
3. Consume from UI call sites via oRPC query utilities.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { consoleQuery } from '@/service/client'
|
||||||
|
|
||||||
|
const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
throwOnError: true,
|
||||||
|
select: invoice => invoice.url,
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Usage Decision Rule
|
||||||
|
|
||||||
|
1. Default to direct `*.queryOptions(...)` usage at the call site.
|
||||||
|
2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook.
|
||||||
|
3. Create `web/service/use-{domain}.ts` only for orchestration.
|
||||||
|
- Combine multiple queries or mutations.
|
||||||
|
- Share domain-level derived state or invalidation helpers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const invoicesBaseQueryOptions = () =>
|
||||||
|
consoleQuery.billing.invoices.queryOptions({ retry: false })
|
||||||
|
|
||||||
|
const invoiceQuery = useQuery({
|
||||||
|
...invoicesBaseQueryOptions(),
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mutation Usage Decision Rule
|
||||||
|
|
||||||
|
1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`.
|
||||||
|
2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic.
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`.
|
||||||
|
- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case.
|
||||||
|
- Do not create thin `use-*` passthrough hooks for a single endpoint.
|
||||||
|
- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection.
|
||||||
|
|
||||||
|
## Contract Rules
|
||||||
|
|
||||||
|
- Input structure: always use `{ params, query?, body? }`.
|
||||||
|
- No-input `GET`: omit `.input(...)`; do not use `.input(type<unknown>())`.
|
||||||
|
- Path params: use `{paramName}` in the path and match it in the `params` object.
|
||||||
|
- Router nesting: group by API prefix, for example `/billing/*` becomes `billing: {}`.
|
||||||
|
- No barrel files: import directly from specific files.
|
||||||
|
- Types: import from `@/types/` and use the `type<T>()` helper.
|
||||||
|
- Mutations: prefer `mutationOptions`; use explicit `mutationKey` mainly for defaults, filtering, and devtools.
|
||||||
|
|
||||||
|
## Type Export
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>
|
||||||
|
```
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
# Runtime Rules
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- Conditional queries
|
||||||
|
- Cache invalidation
|
||||||
|
- Key API guide
|
||||||
|
- `mutate` vs `mutateAsync`
|
||||||
|
- Legacy migration
|
||||||
|
|
||||||
|
## Conditional Queries
|
||||||
|
|
||||||
|
Prefer contract-shaped `queryOptions(...)`.
|
||||||
|
When required input is missing, prefer `input: skipToken` instead of placeholder params or non-null assertions.
|
||||||
|
Use `enabled` only for extra business gating after the input itself is already valid.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
// Disable the query by skipping input construction.
|
||||||
|
function useAccessMode(appId: string | undefined) {
|
||||||
|
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
|
||||||
|
input: appId
|
||||||
|
? { params: { appId } }
|
||||||
|
: skipToken,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid runtime-only guards that bypass type checking.
|
||||||
|
function useBadAccessMode(appId: string | undefined) {
|
||||||
|
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
|
||||||
|
input: { params: { appId: appId! } },
|
||||||
|
enabled: !!appId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cache Invalidation
|
||||||
|
|
||||||
|
Bind invalidation in the service-layer mutation definition.
|
||||||
|
Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- `.key()` for namespace or prefix invalidation
|
||||||
|
- `.queryKey(...)` only for exact cache reads or writes such as `getQueryData` and `setQueryData`
|
||||||
|
- `queryClient.invalidateQueries(...)` in mutation `onSuccess`
|
||||||
|
|
||||||
|
Do not use deprecated `useInvalid` from `use-base.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Service layer owns cache invalidation.
|
||||||
|
export const useUpdateAccessMode = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component only adds UI behavior.
|
||||||
|
updateAccessMode({ appId, mode }, {
|
||||||
|
onSuccess: () => toast.success('...'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avoid putting invalidation knowledge in the component.
|
||||||
|
mutate({ appId, mode }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key API Guide
|
||||||
|
|
||||||
|
- `.key(...)`
|
||||||
|
- Use for partial matching operations.
|
||||||
|
- Prefer it for invalidation, refetch, and cancel patterns.
|
||||||
|
- Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })`
|
||||||
|
- `.queryKey(...)`
|
||||||
|
- Use for a specific query's full key.
|
||||||
|
- Prefer it for exact cache addressing and direct reads or writes.
|
||||||
|
- `.mutationKey(...)`
|
||||||
|
- Use for a specific mutation's full key.
|
||||||
|
- Prefer it for mutation defaults registration, mutation-status filtering, and devtools grouping.
|
||||||
|
|
||||||
|
## `mutate` vs `mutateAsync`
|
||||||
|
|
||||||
|
Prefer `mutate` by default.
|
||||||
|
Use `mutateAsync` only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Event handlers should usually call `mutate(...)` with `onSuccess` or `onError`.
|
||||||
|
- Every `await mutateAsync(...)` must be wrapped in `try/catch`.
|
||||||
|
- Do not use `mutateAsync` when callbacks already express the flow clearly.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Default case.
|
||||||
|
mutation.mutate(data, {
|
||||||
|
onSuccess: result => router.push(result.url),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Promise semantics are required.
|
||||||
|
try {
|
||||||
|
const order = await createOrder.mutateAsync(orderData)
|
||||||
|
await confirmPayment.mutateAsync({ orderId: order.id, token })
|
||||||
|
router.push(`/orders/${order.id}`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unknown error')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy Migration
|
||||||
|
|
||||||
|
When touching old code, migrate it toward these rules:
|
||||||
|
|
||||||
|
| Old pattern | New pattern |
|
||||||
|
|---|---|
|
||||||
|
| `useInvalid(key)` in service layer | `queryClient.invalidateQueries(...)` inside mutation `onSuccess` |
|
||||||
|
| component-triggered invalidation after mutation | move invalidation into the service-layer mutation definition |
|
||||||
|
| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` |
|
||||||
|
| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` |
|
||||||
@ -5,7 +5,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com
|
|||||||
|
|
||||||
# Dify Frontend Testing Skill
|
# Dify Frontend Testing Skill
|
||||||
|
|
||||||
This skill enables Codex to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices.
|
This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices.
|
||||||
|
|
||||||
> **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`).
|
> **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`).
|
||||||
|
|
||||||
@ -24,27 +24,35 @@ Apply this skill when the user:
|
|||||||
**Do NOT apply** when:
|
**Do NOT apply** when:
|
||||||
|
|
||||||
- User is asking about backend/API tests (Python/pytest)
|
- User is asking about backend/API tests (Python/pytest)
|
||||||
- User is asking about E2E tests (Cucumber + Playwright under `e2e/`)
|
- User is asking about E2E tests (Playwright/Cypress)
|
||||||
- User is only asking conceptual questions without code context
|
- User is only asking conceptual questions without code context
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
### Key Commands
|
### Tech Stack
|
||||||
|
|
||||||
Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`.
|
| Tool | Version | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Vitest | 4.0.16 | Test runner |
|
||||||
|
| React Testing Library | 16.0 | Component testing |
|
||||||
|
| jsdom | - | Test environment |
|
||||||
|
| nock | 14.0 | HTTP mocking |
|
||||||
|
| TypeScript | 5.x | Type safety |
|
||||||
|
|
||||||
|
### Key Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|
||||||
# Watch mode
|
# Watch mode
|
||||||
pnpm test --watch
|
pnpm test:watch
|
||||||
|
|
||||||
# Run specific file
|
# Run specific file
|
||||||
pnpm test path/to/file.spec.tsx
|
pnpm test path/to/file.spec.tsx
|
||||||
|
|
||||||
# Generate coverage report
|
# Generate coverage report
|
||||||
pnpm test --coverage
|
pnpm test:coverage
|
||||||
|
|
||||||
# Analyze component complexity
|
# Analyze component complexity
|
||||||
pnpm analyze-component <path>
|
pnpm analyze-component <path>
|
||||||
@ -192,7 +200,7 @@ When assigned to test a directory/path, test **ALL content** within that path:
|
|||||||
|
|
||||||
- ✅ **Import real project components** directly (including base components and siblings)
|
- ✅ **Import real project components** directly (including base components and siblings)
|
||||||
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
|
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
|
||||||
- ❌ **DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`)
|
- ❌ **DO NOT mock** base components (`@/app/components/base/*`)
|
||||||
- ❌ **DO NOT mock** sibling/child components in the same directory
|
- ❌ **DO NOT mock** sibling/child components in the same directory
|
||||||
|
|
||||||
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
||||||
@ -220,10 +228,7 @@ Every test should clearly separate:
|
|||||||
### 2. Black-Box Testing
|
### 2. Black-Box Testing
|
||||||
|
|
||||||
- Test observable behavior, not implementation details
|
- Test observable behavior, not implementation details
|
||||||
- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
|
- Use semantic queries (getByRole, getByLabelText)
|
||||||
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
|
|
||||||
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
|
|
||||||
- Do not assert decorative icons by test id. Assert the named control that contains them, or mark decorative icons `aria-hidden`.
|
|
||||||
- Avoid testing internal state directly
|
- Avoid testing internal state directly
|
||||||
- **Prefer pattern matching over hardcoded strings** in assertions:
|
- **Prefer pattern matching over hardcoded strings** in assertions:
|
||||||
|
|
||||||
@ -320,12 +325,12 @@ For more detailed information, refer to:
|
|||||||
### Reference Examples in Codebase
|
### Reference Examples in Codebase
|
||||||
|
|
||||||
- `web/utils/classnames.spec.ts` - Utility function tests
|
- `web/utils/classnames.spec.ts` - Utility function tests
|
||||||
- `web/app/components/base/radio/__tests__/index.spec.tsx` - Component tests
|
- `web/app/components/base/button/index.spec.tsx` - Component tests
|
||||||
- `web/__mocks__/provider-context.ts` - Mock factory example
|
- `web/__mocks__/provider-context.ts` - Mock factory example
|
||||||
|
|
||||||
### Project Configuration
|
### Project Configuration
|
||||||
|
|
||||||
- `web/vite.config.ts` - Vite/Vitest configuration
|
- `web/vitest.config.ts` - Vitest configuration
|
||||||
- `web/vitest.setup.ts` - Test environment setup
|
- `web/vitest.setup.ts` - Test environment setup
|
||||||
- `web/scripts/analyze-component.js` - Component analysis tool
|
- `web/scripts/analyze-component.js` - Component analysis tool
|
||||||
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
|
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
|
||||||
|
|||||||
@ -36,7 +36,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
|||||||
|
|
||||||
### Integration vs Mocking
|
### Integration vs Mocking
|
||||||
|
|
||||||
- [ ] **DO NOT mock base components or dify-ui primitives** (base `Loading`, `Input`, `Badge`; dify-ui `Button`, `Tooltip`, `Dialog`, etc.)
|
- [ ] **DO NOT mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||||
- [ ] Import real project components instead of mocking
|
- [ ] Import real project components instead of mocking
|
||||||
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
||||||
- [ ] Prefer integration testing when using single spec file
|
- [ ] Prefer integration testing when using single spec file
|
||||||
@ -73,7 +73,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
|||||||
|
|
||||||
### Mocks
|
### Mocks
|
||||||
|
|
||||||
- [ ] **DO NOT mock base components or dify-ui primitives** (`@/app/components/base/*` or `@langgenius/dify-ui/*`)
|
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
|
||||||
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
||||||
- [ ] Shared mock state reset in `beforeEach`
|
- [ ] Shared mock state reset in `beforeEach`
|
||||||
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
|
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
|
||||||
@ -127,7 +127,7 @@ For the current file being tested:
|
|||||||
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
- [ ] Run full directory test: `pnpm test path/to/directory/`
|
||||||
- [ ] Check coverage report: `pnpm test:coverage`
|
- [ ] Check coverage report: `pnpm test:coverage`
|
||||||
- [ ] Run `pnpm lint:fix` on all test files
|
- [ ] Run `pnpm lint:fix` on all test files
|
||||||
- [ ] Run `pnpm type-check`
|
- [ ] Run `pnpm type-check:tsgo`
|
||||||
|
|
||||||
## Common Issues to Watch
|
## Common Issues to Watch
|
||||||
|
|
||||||
|
|||||||
@ -2,27 +2,29 @@
|
|||||||
|
|
||||||
## ⚠️ Important: What NOT to Mock
|
## ⚠️ Important: What NOT to Mock
|
||||||
|
|
||||||
### DO NOT Mock Base Components or dify-ui Primitives
|
### DO NOT Mock Base Components
|
||||||
|
|
||||||
**Never mock components from `@/app/components/base/` or from `@langgenius/dify-ui/*`** such as:
|
**Never mock components from `@/app/components/base/`** such as:
|
||||||
|
|
||||||
- Legacy base (`@/app/components/base/*`): `Loading`, `Spinner`, `Input`, `Badge`, `Tag`
|
- `Loading`, `Spinner`
|
||||||
- dify-ui primitives (`@langgenius/dify-ui/*`): `Button`, `Tooltip`, `Dialog`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, `AlertDialog`, `Toast`
|
- `Button`, `Input`, `Select`
|
||||||
|
- `Tooltip`, `Modal`, `Dropdown`
|
||||||
|
- `Icon`, `Badge`, `Tag`
|
||||||
|
|
||||||
**Why?**
|
**Why?**
|
||||||
|
|
||||||
- These components have their own dedicated tests
|
- Base components will have their own dedicated tests
|
||||||
- Mocking them creates false positives (tests pass but real integration fails)
|
- Mocking them creates false positives (tests pass but real integration fails)
|
||||||
- Using real components tests actual integration behavior
|
- Using real components tests actual integration behavior
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Don't mock base components or dify-ui primitives
|
// ❌ WRONG: Don't mock base components
|
||||||
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
|
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
|
||||||
vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => <button>{children}</button> }))
|
vi.mock('@/app/components/base/button', () => ({ children }: any) => <button>{children}</button>)
|
||||||
|
|
||||||
// ✅ CORRECT: Import and use the real components
|
// ✅ CORRECT: Import and use real base components
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import Button from '@/app/components/base/button'
|
||||||
// They will render normally in tests
|
// They will render normally in tests
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -56,7 +58,7 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details.
|
|||||||
|
|
||||||
| Location | Purpose |
|
| Location | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
|
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) |
|
||||||
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
||||||
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
||||||
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
||||||
@ -216,21 +218,28 @@ describe('Component', () => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. HTTP and `fetch` Mocking
|
### 5. HTTP Mocking with Nock
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import nock from 'nock'
|
||||||
|
|
||||||
|
const GITHUB_HOST = 'https://api.github.com'
|
||||||
|
const GITHUB_PATH = '/repos/owner/repo'
|
||||||
|
|
||||||
|
const mockGithubApi = (status: number, body: Record<string, unknown>, delayMs = 0) => {
|
||||||
|
return nock(GITHUB_HOST)
|
||||||
|
.get(GITHUB_PATH)
|
||||||
|
.delay(delayMs)
|
||||||
|
.reply(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
describe('GithubComponent', () => {
|
describe('GithubComponent', () => {
|
||||||
beforeEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks()
|
nock.cleanAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display repo info', async () => {
|
it('should display repo info', async () => {
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
mockGithubApi(200, { name: 'dify', stars: 1000 })
|
||||||
new Response(JSON.stringify({ name: 'dify', stars: 1000 }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
render(<GithubComponent />)
|
render(<GithubComponent />)
|
||||||
|
|
||||||
@ -240,12 +249,7 @@ describe('GithubComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle API error', async () => {
|
it('should handle API error', async () => {
|
||||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
mockGithubApi(500, { message: 'Server error' })
|
||||||
new Response(JSON.stringify({ message: 'Server error' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
render(<GithubComponent />)
|
render(<GithubComponent />)
|
||||||
|
|
||||||
@ -256,8 +260,6 @@ describe('GithubComponent', () => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses. Do not introduce an HTTP interception dependency such as `nock` or MSW unless it is already declared in the workspace or adding it is part of the task.
|
|
||||||
|
|
||||||
### 6. Context Providers
|
### 6. Context Providers
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@ -317,7 +319,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||||||
|
|
||||||
### ✅ DO
|
### ✅ DO
|
||||||
|
|
||||||
1. **Use real base components and dify-ui primitives** - Import from `@/app/components/base/` or `@langgenius/dify-ui/*` directly
|
1. **Use real base components** - Import from `@/app/components/base/` directly
|
||||||
1. **Use real project components** - Prefer importing over mocking
|
1. **Use real project components** - Prefer importing over mocking
|
||||||
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
||||||
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
||||||
@ -328,11 +330,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||||||
|
|
||||||
### ❌ DON'T
|
### ❌ DON'T
|
||||||
|
|
||||||
1. **Don't mock base components or dify-ui primitives** (`Loading`, `Input`, `Button`, `Tooltip`, `Dialog`, etc.)
|
1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||||
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
||||||
1. Don't mock components you can import directly
|
1. Don't mock components you can import directly
|
||||||
1. Don't create overly simplified mocks that miss conditional logic
|
1. Don't create overly simplified mocks that miss conditional logic
|
||||||
1. Don't leave HTTP mocks or service mock state leaking between tests
|
1. Don't forget to clean up nock after each test
|
||||||
1. Don't use `any` types in mocks without necessity
|
1. Don't use `any` types in mocks without necessity
|
||||||
|
|
||||||
### Mock Decision Tree
|
### Mock Decision Tree
|
||||||
@ -340,7 +342,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||||||
```
|
```
|
||||||
Need to use a component in test?
|
Need to use a component in test?
|
||||||
│
|
│
|
||||||
├─ Is it from @/app/components/base/* or @langgenius/dify-ui/*?
|
├─ Is it from @/app/components/base/*?
|
||||||
│ └─ YES → Import real component, DO NOT mock
|
│ └─ YES → Import real component, DO NOT mock
|
||||||
│
|
│
|
||||||
├─ Is it a project component?
|
├─ Is it a project component?
|
||||||
|
|||||||
@ -227,12 +227,12 @@ Failing tests compound:
|
|||||||
|
|
||||||
**Fix failures immediately before proceeding.**
|
**Fix failures immediately before proceeding.**
|
||||||
|
|
||||||
## Integration with Codex's Todo Feature
|
## Integration with Claude's Todo Feature
|
||||||
|
|
||||||
When using Codex for multi-file testing:
|
When using Claude for multi-file testing:
|
||||||
|
|
||||||
1. **Create a todo list** before starting
|
1. **Ask Claude to create a todo list** before starting
|
||||||
1. **Process one file at a time**
|
1. **Request one file at a time** or ensure Claude processes incrementally
|
||||||
1. **Verify each test passes** before asking for the next
|
1. **Verify each test passes** before asking for the next
|
||||||
1. **Mark todos complete** as you progress
|
1. **Mark todos complete** as you progress
|
||||||
|
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
name: how-to-write-component
|
|
||||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
|
||||||
---
|
|
||||||
|
|
||||||
# How To Write A Component
|
|
||||||
|
|
||||||
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
|
|
||||||
|
|
||||||
## Core Defaults
|
|
||||||
|
|
||||||
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
|
||||||
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
|
||||||
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
|
|
||||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
|
|
||||||
|
|
||||||
## Ownership
|
|
||||||
|
|
||||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
|
||||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
|
|
||||||
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
|
|
||||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
|
|
||||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
|
||||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
|
||||||
|
|
||||||
## Components, Props, And Types
|
|
||||||
|
|
||||||
- Type component signatures directly; do not use `FC` or `React.FC`.
|
|
||||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
|
||||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
|
||||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
|
||||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
|
|
||||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
|
|
||||||
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
|
|
||||||
|
|
||||||
## Queries And Mutations
|
|
||||||
|
|
||||||
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
|
|
||||||
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
|
|
||||||
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
|
|
||||||
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
|
|
||||||
- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
|
|
||||||
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
|
|
||||||
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
|
|
||||||
- Do not use deprecated `useInvalid` or `useReset`.
|
|
||||||
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
|
|
||||||
|
|
||||||
## Component Boundaries
|
|
||||||
|
|
||||||
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
|
|
||||||
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
|
|
||||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
|
||||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
|
|
||||||
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
|
|
||||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
|
||||||
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
|
||||||
|
|
||||||
## You Might Not Need An Effect
|
|
||||||
|
|
||||||
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
|
|
||||||
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
|
|
||||||
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
|
|
||||||
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
|
|
||||||
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
|
|
||||||
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
|
|
||||||
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
|
|
||||||
|
|
||||||
## Navigation And Performance
|
|
||||||
|
|
||||||
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
|
|
||||||
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
[run]
|
[run]
|
||||||
omit =
|
omit =
|
||||||
api/conftest.py
|
|
||||||
api/tests/*
|
api/tests/*
|
||||||
api/migrations/*
|
api/migrations/*
|
||||||
api/core/rag/datasource/vdb/*
|
api/core/rag/datasource/vdb/*
|
||||||
|
|||||||
@ -7,7 +7,7 @@ cd web && pnpm install
|
|||||||
pipx install uv
|
pipx install uv
|
||||||
|
|
||||||
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
|
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
|
||||||
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention\"" >> ~/.bashrc
|
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention\"" >> ~/.bashrc
|
||||||
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev:inspect\"" >> ~/.bashrc
|
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev:inspect\"" >> ~/.bashrc
|
||||||
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
|
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
|
||||||
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
|
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
**/node_modules
|
|
||||||
**/.pnpm-store
|
|
||||||
**/dist
|
|
||||||
**/.next
|
|
||||||
**/.turbo
|
|
||||||
**/.cache
|
|
||||||
**/__pycache__
|
|
||||||
**/*.pyc
|
|
||||||
**/.mypy_cache
|
|
||||||
**/.ruff_cache
|
|
||||||
.git
|
|
||||||
.github
|
|
||||||
*.md
|
|
||||||
!web/README.md
|
|
||||||
!api/README.md
|
|
||||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@ -5,7 +5,3 @@
|
|||||||
# them.
|
# them.
|
||||||
|
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
# Codegen output must stay byte-identical across platforms so
|
|
||||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
|
||||||
*.generated.ts text eol=lf
|
|
||||||
|
|||||||
68
.github/CODEOWNERS
vendored
68
.github/CODEOWNERS
vendored
@ -4,10 +4,7 @@
|
|||||||
# Owners can be @username, @org/team-name, or email addresses.
|
# Owners can be @username, @org/team-name, or email addresses.
|
||||||
# For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
# For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||||
|
|
||||||
* @crazywoola @laipz8200
|
* @crazywoola @laipz8200 @Yeuoly
|
||||||
|
|
||||||
# ESLint suppression file is maintained by autofix.ci pruning.
|
|
||||||
/eslint-suppressions.json
|
|
||||||
|
|
||||||
# CODEOWNERS file
|
# CODEOWNERS file
|
||||||
/.github/CODEOWNERS @laipz8200 @crazywoola
|
/.github/CODEOWNERS @laipz8200 @crazywoola
|
||||||
@ -18,10 +15,6 @@
|
|||||||
# Docs
|
# Docs
|
||||||
/docs/ @crazywoola
|
/docs/ @crazywoola
|
||||||
|
|
||||||
# CLI
|
|
||||||
/cli/ @langgenius/maintainers
|
|
||||||
/.github/workflows/cli-tests.yml @langgenius/maintainers
|
|
||||||
|
|
||||||
# Backend (default owner, more specific rules below will override)
|
# Backend (default owner, more specific rules below will override)
|
||||||
/api/ @QuantumGhost
|
/api/ @QuantumGhost
|
||||||
|
|
||||||
@ -89,39 +82,39 @@
|
|||||||
/api/tasks/deal_dataset_vector_index_task.py @JohnJyong
|
/api/tasks/deal_dataset_vector_index_task.py @JohnJyong
|
||||||
|
|
||||||
# Backend - Plugins
|
# Backend - Plugins
|
||||||
/api/core/plugin/ @WH-2099
|
/api/core/plugin/ @Mairuis @Yeuoly @Stream29
|
||||||
/api/services/plugin/ @WH-2099
|
/api/services/plugin/ @Mairuis @Yeuoly @Stream29
|
||||||
/api/controllers/console/workspace/plugin.py @WH-2099
|
/api/controllers/console/workspace/plugin.py @Mairuis @Yeuoly @Stream29
|
||||||
/api/controllers/inner_api/plugin/ @WH-2099
|
/api/controllers/inner_api/plugin/ @Mairuis @Yeuoly @Stream29
|
||||||
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @WH-2099
|
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @Mairuis @Yeuoly @Stream29
|
||||||
|
|
||||||
# Backend - Trigger/Schedule/Webhook
|
# Backend - Trigger/Schedule/Webhook
|
||||||
/api/controllers/trigger/ @CourTeous33
|
/api/controllers/trigger/ @Mairuis @Yeuoly
|
||||||
/api/controllers/console/app/workflow_trigger.py @CourTeous33
|
/api/controllers/console/app/workflow_trigger.py @Mairuis @Yeuoly
|
||||||
/api/controllers/console/workspace/trigger_providers.py @CourTeous33
|
/api/controllers/console/workspace/trigger_providers.py @Mairuis @Yeuoly
|
||||||
/api/core/trigger/ @CourTeous33
|
/api/core/trigger/ @Mairuis @Yeuoly
|
||||||
/api/core/app/layers/trigger_post_layer.py @CourTeous33
|
/api/core/app/layers/trigger_post_layer.py @Mairuis @Yeuoly
|
||||||
/api/services/trigger/ @CourTeous33
|
/api/services/trigger/ @Mairuis @Yeuoly
|
||||||
/api/models/trigger.py @CourTeous33
|
/api/models/trigger.py @Mairuis @Yeuoly
|
||||||
/api/fields/workflow_trigger_fields.py @CourTeous33
|
/api/fields/workflow_trigger_fields.py @Mairuis @Yeuoly
|
||||||
/api/repositories/workflow_trigger_log_repository.py @CourTeous33
|
/api/repositories/workflow_trigger_log_repository.py @Mairuis @Yeuoly
|
||||||
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33
|
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @Mairuis @Yeuoly
|
||||||
/api/libs/schedule_utils.py @CourTeous33
|
/api/libs/schedule_utils.py @Mairuis @Yeuoly
|
||||||
/api/services/workflow/scheduler.py @CourTeous33
|
/api/services/workflow/scheduler.py @Mairuis @Yeuoly
|
||||||
/api/schedule/trigger_provider_refresh_task.py @CourTeous33
|
/api/schedule/trigger_provider_refresh_task.py @Mairuis @Yeuoly
|
||||||
/api/schedule/workflow_schedule_task.py @CourTeous33
|
/api/schedule/workflow_schedule_task.py @Mairuis @Yeuoly
|
||||||
/api/tasks/trigger_processing_tasks.py @CourTeous33
|
/api/tasks/trigger_processing_tasks.py @Mairuis @Yeuoly
|
||||||
/api/tasks/trigger_subscription_refresh_tasks.py @CourTeous33
|
/api/tasks/trigger_subscription_refresh_tasks.py @Mairuis @Yeuoly
|
||||||
/api/tasks/workflow_schedule_tasks.py @CourTeous33
|
/api/tasks/workflow_schedule_tasks.py @Mairuis @Yeuoly
|
||||||
/api/tasks/workflow_cfs_scheduler/ @CourTeous33
|
/api/tasks/workflow_cfs_scheduler/ @Mairuis @Yeuoly
|
||||||
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @CourTeous33
|
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @Mairuis @Yeuoly
|
||||||
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @CourTeous33
|
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @Mairuis @Yeuoly
|
||||||
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @CourTeous33
|
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @Mairuis @Yeuoly
|
||||||
/api/events/event_handlers/sync_webhook_when_app_created.py @CourTeous33
|
/api/events/event_handlers/sync_webhook_when_app_created.py @Mairuis @Yeuoly
|
||||||
|
|
||||||
# Backend - Async Workflow
|
# Backend - Async Workflow
|
||||||
/api/services/async_workflow_service.py @Mairuis
|
/api/services/async_workflow_service.py @Mairuis @Yeuoly
|
||||||
/api/tasks/async_workflow_tasks.py @Mairuis
|
/api/tasks/async_workflow_tasks.py @Mairuis @Yeuoly
|
||||||
|
|
||||||
# Backend - Billing
|
# Backend - Billing
|
||||||
/api/services/billing_service.py @hj24 @zyssyz123
|
/api/services/billing_service.py @hj24 @zyssyz123
|
||||||
@ -166,7 +159,6 @@
|
|||||||
|
|
||||||
# Frontend - App - API Documentation
|
# Frontend - App - API Documentation
|
||||||
/web/app/components/develop/ @JzoNgKVO @iamjoel
|
/web/app/components/develop/ @JzoNgKVO @iamjoel
|
||||||
/web/app/components/develop/template/*.mdx @JzoNgKVO @iamjoel @RiskeyL
|
|
||||||
|
|
||||||
# Frontend - App - Logs and Annotations
|
# Frontend - App - Logs and Annotations
|
||||||
/web/app/components/app/workflow-log/ @JzoNgKVO @iamjoel
|
/web/app/components/app/workflow-log/ @JzoNgKVO @iamjoel
|
||||||
|
|||||||
7
.github/actions/setup-web/action.yml
vendored
7
.github/actions/setup-web/action.yml
vendored
@ -1,15 +1,10 @@
|
|||||||
name: Setup Web Environment
|
name: Setup Web Environment
|
||||||
description: Set up Node.js, Vite+, pnpm, and web dependencies
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
- name: Setup Vite+
|
- name: Setup Vite+
|
||||||
uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0
|
uses: voidzero-dev/setup-vp@20553a7a7429c429a74894104a2835d7fed28a72 # v1.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
111
.github/dependabot.yml
vendored
111
.github/dependabot.yml
vendored
@ -110,114 +110,3 @@ updates:
|
|||||||
github-actions-dependencies:
|
github-actions-dependencies:
|
||||||
patterns:
|
patterns:
|
||||||
- "*"
|
- "*"
|
||||||
- package-ecosystem: "uv"
|
|
||||||
directory: "/api"
|
|
||||||
target-branch: "lts/1.13.x"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
groups:
|
|
||||||
flask:
|
|
||||||
patterns:
|
|
||||||
- "flask"
|
|
||||||
- "flask-*"
|
|
||||||
- "werkzeug"
|
|
||||||
- "gunicorn"
|
|
||||||
google:
|
|
||||||
patterns:
|
|
||||||
- "google-*"
|
|
||||||
- "googleapis-*"
|
|
||||||
opentelemetry:
|
|
||||||
patterns:
|
|
||||||
- "opentelemetry-*"
|
|
||||||
pydantic:
|
|
||||||
patterns:
|
|
||||||
- "pydantic"
|
|
||||||
- "pydantic-*"
|
|
||||||
llm:
|
|
||||||
patterns:
|
|
||||||
- "langfuse"
|
|
||||||
- "langsmith"
|
|
||||||
- "litellm"
|
|
||||||
- "mlflow*"
|
|
||||||
- "opik"
|
|
||||||
- "weave*"
|
|
||||||
- "arize*"
|
|
||||||
- "tiktoken"
|
|
||||||
- "transformers"
|
|
||||||
database:
|
|
||||||
patterns:
|
|
||||||
- "sqlalchemy"
|
|
||||||
- "psycopg2*"
|
|
||||||
- "psycogreen"
|
|
||||||
- "redis*"
|
|
||||||
- "alembic*"
|
|
||||||
storage:
|
|
||||||
patterns:
|
|
||||||
- "boto3*"
|
|
||||||
- "botocore*"
|
|
||||||
- "azure-*"
|
|
||||||
- "bce-*"
|
|
||||||
- "cos-python-*"
|
|
||||||
- "esdk-obs-*"
|
|
||||||
- "google-cloud-storage"
|
|
||||||
- "opendal"
|
|
||||||
- "oss2"
|
|
||||||
- "supabase*"
|
|
||||||
- "tos*"
|
|
||||||
vdb:
|
|
||||||
patterns:
|
|
||||||
- "alibabacloud*"
|
|
||||||
- "chromadb"
|
|
||||||
- "clickhouse-*"
|
|
||||||
- "clickzetta-*"
|
|
||||||
- "couchbase"
|
|
||||||
- "elasticsearch"
|
|
||||||
- "opensearch-py"
|
|
||||||
- "oracledb"
|
|
||||||
- "pgvect*"
|
|
||||||
- "pymilvus"
|
|
||||||
- "pymochow"
|
|
||||||
- "pyobvector"
|
|
||||||
- "qdrant-client"
|
|
||||||
- "intersystems-*"
|
|
||||||
- "tablestore"
|
|
||||||
- "tcvectordb"
|
|
||||||
- "tidb-vector"
|
|
||||||
- "upstash-*"
|
|
||||||
- "volcengine-*"
|
|
||||||
- "weaviate-*"
|
|
||||||
- "xinference-*"
|
|
||||||
- "mo-vector"
|
|
||||||
- "mysql-connector-*"
|
|
||||||
dev:
|
|
||||||
patterns:
|
|
||||||
- "coverage"
|
|
||||||
- "dotenv-linter"
|
|
||||||
- "faker"
|
|
||||||
- "lxml-stubs"
|
|
||||||
- "basedpyright"
|
|
||||||
- "ruff"
|
|
||||||
- "pytest*"
|
|
||||||
- "types-*"
|
|
||||||
- "boto3-stubs"
|
|
||||||
- "hypothesis"
|
|
||||||
- "pandas-stubs"
|
|
||||||
- "scipy-stubs"
|
|
||||||
- "import-linter"
|
|
||||||
- "celery-types"
|
|
||||||
- "mypy*"
|
|
||||||
- "pyrefly"
|
|
||||||
python-packages:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
target-branch: "lts/1.13.x"
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
groups:
|
|
||||||
github-actions-dependencies:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|||||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@ -6,4 +6,5 @@ web:
|
|||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
- 'pnpm-workspace.yaml'
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
|
|||||||
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_SHA=${BASE_SHA:-}
|
|
||||||
HEAD_SHA=${HEAD_SHA:-}
|
|
||||||
MAIN_REF=${MAIN_REF:-origin/main}
|
|
||||||
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
|
|
||||||
|
|
||||||
error() {
|
|
||||||
printf 'ERROR: %s\n' "$1" >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
|
|
||||||
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
|
|
||||||
error "Base commit '$BASE_SHA' is not available in the local git checkout."
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
|
|
||||||
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
|
|
||||||
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
failed=0
|
|
||||||
checked=0
|
|
||||||
|
|
||||||
while IFS= read -r commit_sha; do
|
|
||||||
[[ -n "$commit_sha" ]] || continue
|
|
||||||
|
|
||||||
checked=$((checked + 1))
|
|
||||||
subject=$(git log -1 --format=%s "$commit_sha")
|
|
||||||
source_sha=$(
|
|
||||||
git log -1 --format=%B "$commit_sha" \
|
|
||||||
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
|
|
||||||
| tail -n 1
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ -z "$source_sha" ]]; then
|
|
||||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
|
||||||
failed=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
|
|
||||||
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
|
|
||||||
failed=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
|
|
||||||
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
|
|
||||||
failed=1
|
|
||||||
fi
|
|
||||||
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
|
|
||||||
|
|
||||||
if [[ "$failed" -ne 0 ]]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$checked" -eq 0 ]]; then
|
|
||||||
echo "No PR commits to check."
|
|
||||||
else
|
|
||||||
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
|
|
||||||
fi
|
|
||||||
19
.github/workflows/anti-slop.yml
vendored
Normal file
19
.github/workflows/anti-slop.yml
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
name: Anti-Slop PR Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, synchronize]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
anti-slop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
close-pr: false
|
||||||
|
failure-add-pr-labels: "needs-revision"
|
||||||
56
.github/workflows/api-tests.yml
vendored
56
.github/workflows/api-tests.yml
vendored
@ -16,7 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
api-unit:
|
api-unit:
|
||||||
name: API Unit Tests
|
name: API Unit Tests
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
COVERAGE_FILE: coverage-unit
|
COVERAGE_FILE: coverage-unit
|
||||||
defaults:
|
defaults:
|
||||||
@ -35,7 +35,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@ -48,23 +48,10 @@ jobs:
|
|||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
- name: Run dify config tests
|
- name: Run dify config tests
|
||||||
run: uv run --project api pytest api/tests/unit_tests/configs/test_env_consistency.py
|
run: uv run --project api dev/pytest/pytest_config_tests.py
|
||||||
|
|
||||||
- name: Run Unit Tests
|
- name: Run Unit Tests
|
||||||
run: |
|
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
|
||||||
uv run --project api pytest \
|
|
||||||
-p no:benchmark \
|
|
||||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
|
||||||
-n auto \
|
|
||||||
api/tests/unit_tests \
|
|
||||||
api/providers/vdb/*/tests/unit_tests \
|
|
||||||
api/providers/trace/*/tests/unit_tests \
|
|
||||||
--ignore=api/tests/unit_tests/controllers
|
|
||||||
# Controller tests register Flask routes at import time, so keep them out of xdist.
|
|
||||||
uv run --project api pytest \
|
|
||||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
|
||||||
--cov-append \
|
|
||||||
api/tests/unit_tests/controllers
|
|
||||||
|
|
||||||
- name: Upload unit coverage data
|
- name: Upload unit coverage data
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
@ -75,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
api-integration:
|
api-integration:
|
||||||
name: API Integration Tests
|
name: API Integration Tests
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
COVERAGE_FILE: coverage-integration
|
COVERAGE_FILE: coverage-integration
|
||||||
STORAGE_TYPE: opendal
|
STORAGE_TYPE: opendal
|
||||||
@ -97,7 +84,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@ -109,11 +96,32 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Set up dotenvs
|
||||||
|
run: |
|
||||||
|
cp docker/.env.example docker/.env
|
||||||
|
cp docker/middleware.env.example docker/middleware.env
|
||||||
|
|
||||||
|
- name: Expose Service Ports
|
||||||
|
run: sh .github/workflows/expose_service_ports.sh
|
||||||
|
|
||||||
|
- name: Set up Sandbox
|
||||||
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
|
with:
|
||||||
|
compose-file: |
|
||||||
|
docker/docker-compose.middleware.yaml
|
||||||
|
services: |
|
||||||
|
db_postgres
|
||||||
|
redis
|
||||||
|
sandbox
|
||||||
|
ssrf_proxy
|
||||||
|
|
||||||
|
- name: setup test config
|
||||||
|
run: |
|
||||||
|
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||||
|
|
||||||
- name: Run Integration Tests
|
- name: Run Integration Tests
|
||||||
run: |
|
run: |
|
||||||
uv run --project api pytest \
|
uv run --project api pytest \
|
||||||
-p no:benchmark \
|
|
||||||
--start-middleware \
|
|
||||||
-n auto \
|
-n auto \
|
||||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
api/tests/integration_tests/workflow \
|
api/tests/integration_tests/workflow \
|
||||||
@ -129,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
api-coverage:
|
api-coverage:
|
||||||
name: API Coverage
|
name: API Coverage
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- api-unit
|
- api-unit
|
||||||
- api-integration
|
- api-integration
|
||||||
@ -148,7 +156,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@ -195,7 +203,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
disable_search: true
|
disable_search: true
|
||||||
|
|||||||
26
.github/workflows/autofix.yml
vendored
26
.github/workflows/autofix.yml
vendored
@ -13,7 +13,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
autofix:
|
autofix:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Complete merge group check
|
- name: Complete merge group check
|
||||||
if: github.event_name == 'merge_group'
|
if: github.event_name == 'merge_group'
|
||||||
@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Check Docker Compose inputs
|
- name: Check Docker Compose inputs
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group'
|
||||||
id: docker-compose-changes
|
id: docker-compose-changes
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
docker/generate_docker_compose
|
docker/generate_docker_compose
|
||||||
@ -35,7 +35,7 @@ jobs:
|
|||||||
- name: Check web inputs
|
- name: Check web inputs
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group'
|
||||||
id: web-changes
|
id: web-changes
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
web/**
|
web/**
|
||||||
@ -43,11 +43,12 @@ jobs:
|
|||||||
package.json
|
package.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
|
.npmrc
|
||||||
.nvmrc
|
.nvmrc
|
||||||
- name: Check api inputs
|
- name: Check api inputs
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group'
|
||||||
id: api-changes
|
id: api-changes
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
api/**
|
api/**
|
||||||
@ -57,7 +58,7 @@ jobs:
|
|||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- if: github.event_name != 'merge_group'
|
- if: github.event_name != 'merge_group'
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
|
|
||||||
- name: Generate Docker Compose
|
- name: Generate Docker Compose
|
||||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||||
@ -113,23 +114,14 @@ jobs:
|
|||||||
find . -name "*.py.bak" -type f -delete
|
find . -name "*.py.bak" -type f -delete
|
||||||
|
|
||||||
- name: Setup web environment
|
- name: Setup web environment
|
||||||
if: github.event_name != 'merge_group'
|
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Generate API docs
|
|
||||||
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
|
||||||
run: |
|
|
||||||
cd api
|
|
||||||
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
|
|
||||||
|
|
||||||
- name: Generate frontend contracts
|
|
||||||
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
|
||||||
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
|
|
||||||
|
|
||||||
- name: ESLint autofix
|
- name: ESLint autofix
|
||||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
cd web
|
||||||
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
||||||
|
|
||||||
- if: github.event_name != 'merge_group'
|
- if: github.event_name != 'merge_group'
|
||||||
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
||||||
|
|||||||
54
.github/workflows/build-push.yml
vendored
54
.github/workflows/build-push.yml
vendored
@ -26,40 +26,37 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ${{ matrix.runs_on }}
|
runs-on: ${{ matrix.runs_on }}
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "build-api-amd64"
|
- service_name: "build-api-amd64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
artifact_context: "api"
|
artifact_context: "api"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}:api"
|
||||||
file: "api/Dockerfile"
|
file: "Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-latest
|
||||||
- service_name: "build-api-arm64"
|
- service_name: "build-api-arm64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
artifact_context: "api"
|
artifact_context: "api"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}:api"
|
||||||
file: "api/Dockerfile"
|
file: "Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-24.04-arm
|
||||||
- service_name: "build-web-amd64"
|
- service_name: "build-web-amd64"
|
||||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||||
artifact_context: "web"
|
artifact_context: "web"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-latest
|
||||||
- service_name: "build-web-arm64"
|
- service_name: "build-web-arm64"
|
||||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||||
artifact_context: "web"
|
artifact_context: "web"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-24.04-arm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
@ -73,8 +70,8 @@ jobs:
|
|||||||
username: ${{ env.DOCKERHUB_USER }}
|
username: ${{ env.DOCKERHUB_USER }}
|
||||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
- name: Set up Docker Buildx
|
||||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
- name: Extract metadata for Docker
|
- name: Extract metadata for Docker
|
||||||
id: meta
|
id: meta
|
||||||
@ -84,15 +81,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
id: build
|
id: build
|
||||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||||
with:
|
with:
|
||||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
|
||||||
context: ${{ matrix.build_context }}
|
context: ${{ matrix.build_context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.service_name }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.service_name }}
|
||||||
|
|
||||||
- name: Export digest
|
- name: Export digest
|
||||||
env:
|
env:
|
||||||
@ -110,33 +108,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
fork-build-validate:
|
|
||||||
if: github.repository != 'langgenius/dify'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- service_name: "validate-api-amd64"
|
|
||||||
build_context: "{{defaultContext}}"
|
|
||||||
file: "api/Dockerfile"
|
|
||||||
- service_name: "validate-web-amd64"
|
|
||||||
build_context: "{{defaultContext}}"
|
|
||||||
file: "web/Dockerfile"
|
|
||||||
steps:
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
|
||||||
|
|
||||||
- name: Validate Docker image
|
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
|
||||||
with:
|
|
||||||
push: false
|
|
||||||
context: ${{ matrix.build_context }}
|
|
||||||
file: ${{ matrix.file }}
|
|
||||||
platforms: linux/amd64
|
|
||||||
|
|
||||||
create-manifest:
|
create-manifest:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
88
.github/workflows/cli-release.yml
vendored
88
.github/workflows/cli-release.yml
vendored
@ -1,88 +0,0 @@
|
|||||||
name: CLI Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'difyctl-v*'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: cli-release-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: build standalone binaries (all targets)
|
|
||||||
runs-on: depot-ubuntu-24.04
|
|
||||||
if: github.repository == 'langgenius/dify'
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: ./cli
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup web environment
|
|
||||||
uses: ./.github/actions/setup-web
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Read cli/package.json
|
|
||||||
id: manifest
|
|
||||||
run: |
|
|
||||||
version=$(node -p "require('./package.json').version")
|
|
||||||
channel=$(node -p "require('./package.json').difyctl.channel")
|
|
||||||
minDify=$(node -p "require('./package.json').difyctl.compat.minDify")
|
|
||||||
maxDify=$(node -p "require('./package.json').difyctl.compat.maxDify")
|
|
||||||
{
|
|
||||||
echo "version=$version"
|
|
||||||
echo "channel=$channel"
|
|
||||||
echo "minDify=$minDify"
|
|
||||||
echo "maxDify=$maxDify"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Validate manifest
|
|
||||||
run: scripts/release-validate-manifest.sh
|
|
||||||
|
|
||||||
- name: Install cross-arch native prebuilds
|
|
||||||
# Re-installs node_modules with every @napi-rs/keyring platform variant
|
|
||||||
# so `bun build --compile` can embed the right .node into each target.
|
|
||||||
working-directory: ./
|
|
||||||
run: NPM_CONFIG_USERCONFIG="$PWD/cli/scripts/cross-arch.npmrc" pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Compile standalone binaries (all targets)
|
|
||||||
env:
|
|
||||||
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
|
||||||
DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }}
|
|
||||||
DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }}
|
|
||||||
DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }}
|
|
||||||
run: |
|
|
||||||
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
|
||||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
|
||||||
pnpm build:bin
|
|
||||||
|
|
||||||
- name: Generate sha256 checksum file
|
|
||||||
env:
|
|
||||||
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
|
||||||
run: scripts/release-write-checksums.sh
|
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
|
||||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
|
||||||
with:
|
|
||||||
tag_name: difyctl-v${{ steps.manifest.outputs.version }}
|
|
||||||
name: difyctl ${{ steps.manifest.outputs.version }}
|
|
||||||
prerelease: ${{ steps.manifest.outputs.channel != 'stable' }}
|
|
||||||
generate_release_notes: true
|
|
||||||
fail_on_unmatched_files: true
|
|
||||||
files: |
|
|
||||||
cli/dist/bin/difyctl-v*
|
|
||||||
60
.github/workflows/cli-smoke.yml
vendored
60
.github/workflows/cli-smoke.yml
vendored
@ -1,60 +0,0 @@
|
|||||||
name: CLI Smoke (live dify)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dify_version:
|
|
||||||
description: "Dify image tag to test against (e.g. 1.7.0)"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
cli_ref:
|
|
||||||
description: "Git ref to build the cli from (default: current branch)"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
smoke:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- name: Checkout cli ref
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.cli_ref || github.ref }}
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup web environment
|
|
||||||
uses: ./.github/actions/setup-web
|
|
||||||
|
|
||||||
- name: Bring up dify
|
|
||||||
env:
|
|
||||||
DIFY_VERSION: ${{ inputs.dify_version }}
|
|
||||||
run: |
|
|
||||||
cd docker
|
|
||||||
cp .env.example .env
|
|
||||||
DIFY_API_IMAGE_TAG="$DIFY_VERSION" \
|
|
||||||
DIFY_WEB_IMAGE_TAG="$DIFY_VERSION" \
|
|
||||||
docker compose up -d api worker web db redis
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -fsS http://localhost:5001/health >/dev/null 2>&1; then
|
|
||||||
echo "dify api ready after ${i}s"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run smoke against live dify
|
|
||||||
working-directory: ./cli
|
|
||||||
run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001
|
|
||||||
|
|
||||||
- name: Dump dify logs on failure
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
cd docker
|
|
||||||
docker compose logs api worker web --tail=200
|
|
||||||
50
.github/workflows/cli-tests.yml
vendored
50
.github/workflows/cli-tests.yml
vendored
@ -1,50 +0,0 @@
|
|||||||
name: CLI Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
secrets:
|
|
||||||
CODECOV_TOKEN:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: cli-tests-${{ github.head_ref || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: CLI Tests (${{ matrix.os }})
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [depot-ubuntu-24.04, windows-latest, macos-latest]
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: ./cli
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup web environment
|
|
||||||
uses: ./.github/actions/setup-web
|
|
||||||
|
|
||||||
- name: CI pipeline (typecheck, lint, coverage, build)
|
|
||||||
run: pnpm ci
|
|
||||||
|
|
||||||
- name: Report coverage
|
|
||||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
|
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
|
||||||
with:
|
|
||||||
directory: cli/coverage
|
|
||||||
flags: cli
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
|
||||||
38
.github/workflows/db-migration-test.yml
vendored
38
.github/workflows/db-migration-test.yml
vendored
@ -9,7 +9,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
db-migration-test-postgres:
|
db-migration-test-postgres:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@ -19,7 +19,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@ -37,10 +37,10 @@ jobs:
|
|||||||
- name: Prepare middleware env
|
- name: Prepare middleware env
|
||||||
run: |
|
run: |
|
||||||
cd docker
|
cd docker
|
||||||
cp envs/middleware.env.example middleware.env
|
cp middleware.env.example middleware.env
|
||||||
|
|
||||||
- name: Set up Middlewares
|
- name: Set up Middlewares
|
||||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.middleware.yaml
|
docker/docker-compose.middleware.yaml
|
||||||
@ -59,7 +59,7 @@ jobs:
|
|||||||
run: uv run --directory api flask upgrade-db
|
run: uv run --directory api flask upgrade-db
|
||||||
|
|
||||||
db-migration-test-mysql:
|
db-migration-test-mysql:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@ -69,7 +69,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@ -87,14 +87,14 @@ jobs:
|
|||||||
- name: Prepare middleware env for MySQL
|
- name: Prepare middleware env for MySQL
|
||||||
run: |
|
run: |
|
||||||
cd docker
|
cd docker
|
||||||
cp envs/middleware.env.example middleware.env
|
cp middleware.env.example middleware.env
|
||||||
sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env
|
sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env
|
||||||
sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env
|
sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env
|
||||||
sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env
|
sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env
|
||||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||||
|
|
||||||
- name: Set up Middlewares
|
- name: Set up Middlewares
|
||||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.middleware.yaml
|
docker/docker-compose.middleware.yaml
|
||||||
@ -110,28 +110,6 @@ jobs:
|
|||||||
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
|
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
|
||||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
|
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
|
||||||
|
|
||||||
# hoverkraft-tech/compose-action@v2.6.0 only waits for `docker compose up -d`
|
|
||||||
# to return (container processes started); it does not wait on healthcheck
|
|
||||||
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
|
|
||||||
# wait the migration runs while InnoDB is still initialising and gets
|
|
||||||
# killed with "Lost connection during query". Poll a real SELECT until it
|
|
||||||
# succeeds.
|
|
||||||
- name: Wait for MySQL to accept queries
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if docker run --rm --network host mysql:8.0 \
|
|
||||||
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
|
|
||||||
-e 'SELECT 1' >/dev/null 2>&1; then
|
|
||||||
echo "MySQL ready after ${i}s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "MySQL not ready after 60s; dumping container logs:"
|
|
||||||
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Run DB Migration
|
- name: Run DB Migration
|
||||||
env:
|
env:
|
||||||
DEBUG: true
|
DEBUG: true
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
name: Deploy SaaS
|
name: Deploy Agent Dev
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@ -7,22 +7,22 @@ on:
|
|||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Build and Push API & Web"]
|
workflows: ["Build and Push API & Web"]
|
||||||
branches:
|
branches:
|
||||||
- "deploy/saas"
|
- "deploy/agent-dev"
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'deploy/saas'
|
github.event.workflow_run.head_branch == 'deploy/agent-dev'
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy to server
|
- name: Deploy to server
|
||||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SAAS_DEV_SSH_HOST }}
|
host: ${{ secrets.AGENT_DEV_SSH_HOST }}
|
||||||
username: ${{ secrets.SSH_USER }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
script: |
|
script: |
|
||||||
${{ vars.SSH_SCRIPT_SAAS_DEV || secrets.SSH_SCRIPT_SAAS_DEV }}
|
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||||
2
.github/workflows/deploy-dev.yml
vendored
2
.github/workflows/deploy-dev.yml
vendored
@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'deploy/dev'
|
github.event.workflow_run.head_branch == 'deploy/dev'
|
||||||
|
|||||||
2
.github/workflows/deploy-enterprise.yml
vendored
2
.github/workflows/deploy-enterprise.yml
vendored
@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'deploy/enterprise'
|
github.event.workflow_run.head_branch == 'deploy/enterprise'
|
||||||
|
|||||||
2
.github/workflows/deploy-hitl.yml
vendored
2
.github/workflows/deploy-hitl.yml
vendored
@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
if: |
|
if: |
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.head_branch == 'build/feat/hitl'
|
github.event.workflow_run.head_branch == 'build/feat/hitl'
|
||||||
|
|||||||
57
.github/workflows/docker-build.yml
vendored
57
.github/workflows/docker-build.yml
vendored
@ -6,12 +6,6 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
paths:
|
paths:
|
||||||
- api/Dockerfile
|
- api/Dockerfile
|
||||||
- api/Dockerfile.dockerignore
|
|
||||||
- api/pyproject.toml
|
|
||||||
- api/uv.lock
|
|
||||||
- dify-agent/pyproject.toml
|
|
||||||
- dify-agent/README.md
|
|
||||||
- dify-agent/src/**
|
|
||||||
- web/Dockerfile
|
- web/Dockerfile
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@ -20,59 +14,28 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docker:
|
build-docker:
|
||||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
runs-on: ${{ matrix.runs_on }}
|
runs-on: ${{ matrix.runs_on }}
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "api-amd64"
|
- service_name: "api-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-latest
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}:api"
|
||||||
file: "api/Dockerfile"
|
file: "Dockerfile"
|
||||||
- service_name: "api-arm64"
|
- service_name: "api-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-24.04-arm
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}:api"
|
||||||
file: "api/Dockerfile"
|
file: "Dockerfile"
|
||||||
- service_name: "web-amd64"
|
- service_name: "web-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-latest
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
- service_name: "web-arm64"
|
- service_name: "web-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: ubuntu-24.04-arm
|
||||||
context: "{{defaultContext}}"
|
|
||||||
file: "web/Dockerfile"
|
|
||||||
steps:
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
|
||||||
|
|
||||||
- name: Build Docker Image
|
|
||||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
|
||||||
with:
|
|
||||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
|
||||||
push: false
|
|
||||||
context: ${{ matrix.context }}
|
|
||||||
file: ${{ matrix.file }}
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
build-docker-fork:
|
|
||||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- service_name: "api-amd64"
|
|
||||||
context: "{{defaultContext}}"
|
|
||||||
file: "api/Dockerfile"
|
|
||||||
- service_name: "web-amd64"
|
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
steps:
|
steps:
|
||||||
@ -85,4 +48,6 @@ jobs:
|
|||||||
push: false
|
push: false
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
platforms: linux/amd64
|
platforms: ${{ matrix.platform }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
17
.github/workflows/expose_service_ports.sh
vendored
Executable file
17
.github/workflows/expose_service_ports.sh
vendored
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
yq eval '.services.weaviate.ports += ["8080:8080"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.weaviate.ports += ["50051:50051"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.qdrant.ports += ["6333:6333"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.chroma.ports += ["8000:8000"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services["milvus-standalone"].ports += ["19530:19530"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.pgvector.ports += ["5433:5432"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services["pgvecto-rs"].ports += ["5431:5432"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services["elasticsearch"].ports += ["9200:9200"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.couchbase-server.ports += ["8091-8096:8091-8096"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.couchbase-server.ports += ["11210:11210"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.tidb.ports += ["4000:4000"]' -i docker/tidb/docker-compose.yaml
|
||||||
|
yq eval '.services.oceanbase.ports += ["2881:2881"]' -i docker/docker-compose.yaml
|
||||||
|
yq eval '.services.opengauss.ports += ["6600:6600"]' -i docker/docker-compose.yaml
|
||||||
|
|
||||||
|
echo "Ports exposed for sandbox, weaviate (HTTP 8080, gRPC 50051), tidb, qdrant, chroma, milvus, pgvector, pgvecto-rs, elasticsearch, couchbase, opengauss"
|
||||||
49
.github/workflows/hotfix-cherry-pick.yml
vendored
49
.github/workflows/hotfix-cherry-pick.yml
vendored
@ -1,49 +0,0 @@
|
|||||||
name: Hotfix Cherry-Pick Provenance
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'hotfix/**'
|
|
||||||
- 'lts/**'
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-cherry-pick-provenance:
|
|
||||||
name: Require cherry-pick provenance
|
|
||||||
runs-on: depot-ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch PR base, PR head, and main
|
|
||||||
env:
|
|
||||||
BASE_REF: ${{ github.base_ref }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags --prune origin \
|
|
||||||
"+refs/heads/main:refs/remotes/origin/main" \
|
|
||||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
|
|
||||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
|
|
||||||
|
|
||||||
- name: Load checker from main
|
|
||||||
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
|
||||||
|
|
||||||
- name: Check PR commits
|
|
||||||
env:
|
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
MAIN_REF: origin/main
|
|
||||||
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
|
||||||
4
.github/workflows/labeler.yml
vendored
4
.github/workflows/labeler.yml
vendored
@ -7,8 +7,8 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||||
with:
|
with:
|
||||||
sync-labels: true
|
sync-labels: true
|
||||||
|
|||||||
113
.github/workflows/main-ci.yml
vendored
113
.github/workflows/main-ci.yml
vendored
@ -23,7 +23,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
pre_job:
|
pre_job:
|
||||||
name: Skip Duplicate Checks
|
name: Skip Duplicate Checks
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||||
steps:
|
steps:
|
||||||
@ -39,10 +39,9 @@ jobs:
|
|||||||
name: Check Changed Files
|
name: Check Changed Files
|
||||||
needs: pre_job
|
needs: pre_job
|
||||||
if: needs.pre_job.outputs.should_skip != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
api-changed: ${{ steps.changes.outputs.api }}
|
api-changed: ${{ steps.changes.outputs.api }}
|
||||||
cli-changed: ${{ steps.changes.outputs.cli }}
|
|
||||||
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
||||||
web-changed: ${{ steps.changes.outputs.web }}
|
web-changed: ${{ steps.changes.outputs.web }}
|
||||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||||
@ -56,31 +55,21 @@ jobs:
|
|||||||
api:
|
api:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
- '.github/workflows/api-tests.yml'
|
- '.github/workflows/api-tests.yml'
|
||||||
|
- '.github/workflows/expose_service_ports.sh'
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/middleware.env.example'
|
||||||
- 'docker/docker-compose.middleware.yaml'
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
- 'docker/docker-compose-template.yaml'
|
- 'docker/docker-compose-template.yaml'
|
||||||
- 'docker/generate_docker_compose'
|
- 'docker/generate_docker_compose'
|
||||||
- 'docker/ssrf_proxy/**'
|
- 'docker/ssrf_proxy/**'
|
||||||
- 'docker/volumes/sandbox/conf/**'
|
- 'docker/volumes/sandbox/conf/**'
|
||||||
cli:
|
|
||||||
- 'cli/**'
|
|
||||||
- 'packages/tsconfig/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- 'pnpm-workspace.yaml'
|
|
||||||
- 'eslint.config.mjs'
|
|
||||||
- '.npmrc'
|
|
||||||
- '.nvmrc'
|
|
||||||
- '.github/workflows/cli-tests.yml'
|
|
||||||
- '.github/workflows/cli-docker-build.yml'
|
|
||||||
- '.github/actions/setup-web/**'
|
|
||||||
web:
|
web:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
- 'packages/**'
|
- 'packages/**'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
- 'pnpm-workspace.yaml'
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
- '.github/workflows/web-tests.yml'
|
- '.github/workflows/web-tests.yml'
|
||||||
- '.github/actions/setup-web/**'
|
- '.github/actions/setup-web/**'
|
||||||
@ -94,21 +83,20 @@ jobs:
|
|||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
- 'pnpm-workspace.yaml'
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.npmrc'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
- 'docker/docker-compose.middleware.yaml'
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/middleware.env.example'
|
||||||
- '.github/workflows/web-e2e.yml'
|
- '.github/workflows/web-e2e.yml'
|
||||||
- '.github/actions/setup-web/**'
|
- '.github/actions/setup-web/**'
|
||||||
vdb:
|
vdb:
|
||||||
- 'api/core/rag/datasource/**'
|
- 'api/core/rag/datasource/**'
|
||||||
- 'api/tests/integration_tests/vdb/**'
|
- 'api/tests/integration_tests/vdb/**'
|
||||||
- 'api/conftest.py'
|
|
||||||
- 'api/tests/pytest_dify.py'
|
|
||||||
- 'api/providers/vdb/*/tests/**'
|
- 'api/providers/vdb/*/tests/**'
|
||||||
- '.github/workflows/vdb-tests.yml'
|
- '.github/workflows/vdb-tests.yml'
|
||||||
|
- '.github/workflows/expose_service_ports.sh'
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/middleware.env.example'
|
||||||
- 'docker/docker-compose.pytest.ports.yaml'
|
|
||||||
- 'docker/docker-compose.yaml'
|
- 'docker/docker-compose.yaml'
|
||||||
- 'docker/docker-compose-template.yaml'
|
- 'docker/docker-compose-template.yaml'
|
||||||
- 'docker/generate_docker_compose'
|
- 'docker/generate_docker_compose'
|
||||||
@ -128,8 +116,9 @@ jobs:
|
|||||||
- 'api/migrations/**'
|
- 'api/migrations/**'
|
||||||
- 'api/.env.example'
|
- 'api/.env.example'
|
||||||
- '.github/workflows/db-migration-test.yml'
|
- '.github/workflows/db-migration-test.yml'
|
||||||
|
- '.github/workflows/expose_service_ports.sh'
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/middleware.env.example'
|
||||||
- 'docker/docker-compose.middleware.yaml'
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
- 'docker/docker-compose-template.yaml'
|
- 'docker/docker-compose-template.yaml'
|
||||||
- 'docker/generate_docker_compose'
|
- 'docker/generate_docker_compose'
|
||||||
@ -152,7 +141,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped API tests
|
- name: Report skipped API tests
|
||||||
run: echo "No API-related changes detected; skipping API tests."
|
run: echo "No API-related changes detected; skipping API tests."
|
||||||
@ -165,7 +154,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- api-tests-run
|
- api-tests-run
|
||||||
- api-tests-skip
|
- api-tests-skip
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize API Tests status
|
- name: Finalize API Tests status
|
||||||
env:
|
env:
|
||||||
@ -197,66 +186,6 @@ jobs:
|
|||||||
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
cli-tests-run:
|
|
||||||
name: Run CLI Tests
|
|
||||||
needs:
|
|
||||||
- pre_job
|
|
||||||
- check-changes
|
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed == 'true'
|
|
||||||
uses: ./.github/workflows/cli-tests.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
cli-tests-skip:
|
|
||||||
name: Skip CLI Tests
|
|
||||||
needs:
|
|
||||||
- pre_job
|
|
||||||
- check-changes
|
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
|
|
||||||
runs-on: depot-ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Report skipped CLI tests
|
|
||||||
run: echo "No CLI-related changes detected; skipping CLI tests."
|
|
||||||
|
|
||||||
cli-tests:
|
|
||||||
name: CLI Tests
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs:
|
|
||||||
- pre_job
|
|
||||||
- check-changes
|
|
||||||
- cli-tests-run
|
|
||||||
- cli-tests-skip
|
|
||||||
runs-on: depot-ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Finalize CLI Tests status
|
|
||||||
env:
|
|
||||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
|
||||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
|
|
||||||
RUN_RESULT: ${{ needs.cli-tests-run.result }}
|
|
||||||
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
|
|
||||||
run: |
|
|
||||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
|
||||||
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
|
||||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
|
||||||
echo "CLI tests ran successfully."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "CLI tests were required but finished with result: $RUN_RESULT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
|
||||||
echo "CLI tests were skipped because no CLI-related files changed."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
web-tests-run:
|
web-tests-run:
|
||||||
name: Run Web Tests
|
name: Run Web Tests
|
||||||
needs:
|
needs:
|
||||||
@ -272,7 +201,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped web tests
|
- name: Report skipped web tests
|
||||||
run: echo "No web-related changes detected; skipping web tests."
|
run: echo "No web-related changes detected; skipping web tests."
|
||||||
@ -285,7 +214,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- web-tests-run
|
- web-tests-run
|
||||||
- web-tests-skip
|
- web-tests-skip
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize Web Tests status
|
- name: Finalize Web Tests status
|
||||||
env:
|
env:
|
||||||
@ -331,7 +260,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped web full-stack e2e
|
- name: Report skipped web full-stack e2e
|
||||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||||
@ -344,7 +273,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- web-e2e-run
|
- web-e2e-run
|
||||||
- web-e2e-skip
|
- web-e2e-skip
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize Web Full-Stack E2E status
|
- name: Finalize Web Full-Stack E2E status
|
||||||
env:
|
env:
|
||||||
@ -396,7 +325,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped VDB tests
|
- name: Report skipped VDB tests
|
||||||
run: echo "No VDB-related changes detected; skipping VDB tests."
|
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||||
@ -409,7 +338,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- vdb-tests-run
|
- vdb-tests-run
|
||||||
- vdb-tests-skip
|
- vdb-tests-skip
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize VDB Tests status
|
- name: Finalize VDB Tests status
|
||||||
env:
|
env:
|
||||||
@ -455,7 +384,7 @@ jobs:
|
|||||||
- pre_job
|
- pre_job
|
||||||
- check-changes
|
- check-changes
|
||||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Report skipped DB migration tests
|
- name: Report skipped DB migration tests
|
||||||
run: echo "No migration-related changes detected; skipping DB migration tests."
|
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||||
@ -468,7 +397,7 @@ jobs:
|
|||||||
- check-changes
|
- check-changes
|
||||||
- db-migration-test-run
|
- db-migration-test-run
|
||||||
- db-migration-test-skip
|
- db-migration-test-skip
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Finalize DB Migration Test status
|
- name: Finalize DB Migration Test status
|
||||||
env:
|
env:
|
||||||
|
|||||||
24
.github/workflows/pyrefly-diff-comment.yml
vendored
24
.github/workflows/pyrefly-diff-comment.yml
vendored
@ -12,7 +12,7 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
comment:
|
comment:
|
||||||
name: Comment PR with pyrefly diff
|
name: Comment PR with pyrefly diff
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@ -76,29 +76,13 @@ jobs:
|
|||||||
diff += '\\n\\n... (truncated) ...';
|
diff += '\\n\\n... (truncated) ...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff.trim()) {
|
const body = diff.trim()
|
||||||
const body = '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>';
|
? '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>'
|
||||||
const marker = '### Pyrefly Diff';
|
: '### Pyrefly Diff\nNo changes detected.';
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
|
||||||
issue_number: prNumber,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
});
|
|
||||||
const existing = comments.find((comment) => comment.body.startsWith(marker));
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
comment_id: existing.id,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
issue_number: prNumber,
|
issue_number: prNumber,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
21
.github/workflows/pyrefly-diff.yml
vendored
21
.github/workflows/pyrefly-diff.yml
vendored
@ -10,7 +10,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pyrefly-diff:
|
pyrefly-diff:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
@ -22,7 +22,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Python & UV
|
- name: Setup Python & UV
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
@ -103,26 +103,9 @@ jobs:
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
: '### Pyrefly Diff\nNo changes detected.';
|
: '### Pyrefly Diff\nNo changes detected.';
|
||||||
|
|
||||||
const marker = '### Pyrefly Diff';
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
|
||||||
issue_number: prNumber,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
});
|
|
||||||
const existing = comments.find((comment) => comment.body.startsWith(marker));
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
comment_id: existing.id,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
issue_number: prNumber,
|
issue_number: prNumber,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
comment:
|
comment:
|
||||||
name: Comment PR with type coverage
|
name: Comment PR with type coverage
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Setup Python & UV
|
- name: Setup Python & UV
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
@ -63,8 +63,8 @@ jobs:
|
|||||||
id: render
|
id: render
|
||||||
run: |
|
run: |
|
||||||
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
|
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
|
||||||
--base "$GITHUB_WORKSPACE/base_report.json" \
|
--base base_report.json \
|
||||||
< "$GITHUB_WORKSPACE/pr_report.json")"
|
< pr_report.json)"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "### Pyrefly Type Coverage"
|
echo "### Pyrefly Type Coverage"
|
||||||
|
|||||||
8
.github/workflows/pyrefly-type-coverage.yml
vendored
8
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -10,7 +10,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pyrefly-type-coverage:
|
pyrefly-type-coverage:
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
@ -22,7 +22,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Python & UV
|
- name: Setup Python & UV
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
@ -65,9 +65,6 @@ jobs:
|
|||||||
# Save structured data for the fork-PR comment workflow
|
# Save structured data for the fork-PR comment workflow
|
||||||
cp /tmp/pyrefly_report_pr.json pr_report.json
|
cp /tmp/pyrefly_report_pr.json pr_report.json
|
||||||
cp /tmp/pyrefly_report_base.json base_report.json
|
cp /tmp/pyrefly_report_base.json base_report.json
|
||||||
# Keep fork-PR comments correct while the trusted workflow_run job is
|
|
||||||
# still using the default-branch renderer, which resolves --base from api/.
|
|
||||||
cp /tmp/pyrefly_report_base.json api/base_report.json
|
|
||||||
|
|
||||||
- name: Save PR number
|
- name: Save PR number
|
||||||
run: |
|
run: |
|
||||||
@ -80,7 +77,6 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
pr_report.json
|
pr_report.json
|
||||||
base_report.json
|
base_report.json
|
||||||
api/base_report.json
|
|
||||||
pr_number.txt
|
pr_number.txt
|
||||||
|
|
||||||
- name: Comment PR with type coverage
|
- name: Comment PR with type coverage
|
||||||
|
|||||||
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
|||||||
name: Validate PR title
|
name: Validate PR title
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Complete merge group check
|
- name: Complete merge group check
|
||||||
if: github.event_name == 'merge_group'
|
if: github.event_name == 'merge_group'
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -12,7 +12,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
|
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
94
.github/workflows/style.yml
vendored
94
.github/workflows/style.yml
vendored
@ -15,7 +15,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
python-style:
|
python-style:
|
||||||
name: Python Style
|
name: Python Style
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
api/**
|
api/**
|
||||||
@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@ -47,10 +47,6 @@ jobs:
|
|||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: uv run --directory api --dev lint-imports
|
run: uv run --directory api --dev lint-imports
|
||||||
|
|
||||||
- name: Run Response Contract Linter
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
run: uv run --project api --dev python api/dev/lint_response_contracts.py --fail-on-mismatch
|
|
||||||
|
|
||||||
- name: Run Type Checks
|
- name: Run Type Checks
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: make type-check-core
|
run: make type-check-core
|
||||||
@ -61,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
web-style:
|
web-style:
|
||||||
name: Web Style
|
name: Web Style
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
@ -77,16 +73,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
web/**
|
web/**
|
||||||
e2e/**
|
|
||||||
sdks/nodejs-client/**
|
|
||||||
packages/**
|
packages/**
|
||||||
package.json
|
package.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
|
.npmrc
|
||||||
.nvmrc
|
.nvmrc
|
||||||
.github/workflows/style.yml
|
.github/workflows/style.yml
|
||||||
.github/actions/setup-web/**
|
.github/actions/setup-web/**
|
||||||
@ -95,79 +90,46 @@ jobs:
|
|||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Web tsslint
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
|
||||||
run: vp run lint:tss
|
|
||||||
|
|
||||||
- name: Web dead code check
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
run: vp run knip
|
|
||||||
|
|
||||||
ts-common-style:
|
|
||||||
name: TS Common
|
|
||||||
runs-on: depot-ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
checks: write
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check changed files
|
|
||||||
id: changed-files
|
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
web/**
|
|
||||||
cli/**
|
|
||||||
e2e/**
|
|
||||||
sdks/nodejs-client/**
|
|
||||||
packages/**
|
|
||||||
package.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
pnpm-workspace.yaml
|
|
||||||
.nvmrc
|
|
||||||
eslint.config.mjs
|
|
||||||
.github/workflows/style.yml
|
|
||||||
.github/actions/setup-web/**
|
|
||||||
|
|
||||||
- name: Setup web environment
|
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
|
||||||
uses: ./.github/actions/setup-web
|
|
||||||
|
|
||||||
- name: Restore ESLint cache
|
- name: Restore ESLint cache
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
id: eslint-cache-restore
|
id: eslint-cache-restore
|
||||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: .eslintcache
|
path: web/.eslintcache
|
||||||
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
key: ${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
|
${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
|
||||||
|
|
||||||
- name: Style check
|
- name: Web style check
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
working-directory: ./web
|
||||||
run: vp run lint:ci
|
run: vp run lint:ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Web tsslint
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
working-directory: ./web
|
||||||
|
run: vp run lint:tss
|
||||||
|
|
||||||
|
- name: Web type check
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
working-directory: ./web
|
||||||
run: vp run type-check
|
run: vp run type-check
|
||||||
|
|
||||||
|
- name: Web dead code check
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
working-directory: ./web
|
||||||
|
run: vp run knip
|
||||||
|
|
||||||
- name: Save ESLint cache
|
- name: Save ESLint cache
|
||||||
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
with:
|
with:
|
||||||
path: .eslintcache
|
path: web/.eslintcache
|
||||||
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
|
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
|
||||||
|
|
||||||
superlinter:
|
superlinter:
|
||||||
name: SuperLinter
|
name: SuperLinter
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@ -178,7 +140,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
**.sh
|
**.sh
|
||||||
|
|||||||
5
.github/workflows/tool-test-sdks.yaml
vendored
5
.github/workflows/tool-test-sdks.yaml
vendored
@ -9,6 +9,7 @@ on:
|
|||||||
- package.json
|
- package.json
|
||||||
- pnpm-lock.yaml
|
- pnpm-lock.yaml
|
||||||
- pnpm-workspace.yaml
|
- pnpm-workspace.yaml
|
||||||
|
- .npmrc
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
||||||
@ -17,7 +18,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: unit test for Node.js SDK
|
name: unit test for Node.js SDK
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -29,7 +30,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: ''
|
cache: ''
|
||||||
|
|||||||
4
.github/workflows/translate-i18n-claude.yml
vendored
4
.github/workflows/translate-i18n-claude.yml
vendored
@ -35,7 +35,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
translate:
|
translate:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@ -158,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code for Translation Sync
|
- name: Run Claude Code for Translation Sync
|
||||||
if: steps.context.outputs.CHANGED_FILES != ''
|
if: steps.context.outputs.CHANGED_FILES != ''
|
||||||
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
|
uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@ -16,7 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
trigger:
|
trigger:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
43
.github/workflows/vdb-tests-full.yml
vendored
43
.github/workflows/vdb-tests-full.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Full VDB Tests
|
name: Full VDB Tests
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version:
|
python-version:
|
||||||
@ -36,7 +36,7 @@ jobs:
|
|||||||
remove_tool_cache: true
|
remove_tool_cache: true
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@ -48,6 +48,14 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Set up dotenvs
|
||||||
|
run: |
|
||||||
|
cp docker/.env.example docker/.env
|
||||||
|
cp docker/middleware.env.example docker/middleware.env
|
||||||
|
|
||||||
|
- name: Expose Service Ports
|
||||||
|
run: sh .github/workflows/expose_service_ports.sh
|
||||||
|
|
||||||
# - name: Set up Vector Store (TiDB)
|
# - name: Set up Vector Store (TiDB)
|
||||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||||
# with:
|
# with:
|
||||||
@ -56,13 +64,32 @@ jobs:
|
|||||||
# tidb
|
# tidb
|
||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
|
- name: Set up Full Vector Store Matrix
|
||||||
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
|
with:
|
||||||
|
compose-file: |
|
||||||
|
docker/docker-compose.yaml
|
||||||
|
services: |
|
||||||
|
weaviate
|
||||||
|
qdrant
|
||||||
|
couchbase-server
|
||||||
|
etcd
|
||||||
|
minio
|
||||||
|
milvus-standalone
|
||||||
|
pgvecto-rs
|
||||||
|
pgvector
|
||||||
|
chroma
|
||||||
|
elasticsearch
|
||||||
|
oceanbase
|
||||||
|
|
||||||
|
- name: setup test config
|
||||||
|
run: |
|
||||||
|
echo $(pwd)
|
||||||
|
ls -lah .
|
||||||
|
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||||
|
|
||||||
# - name: Check VDB Ready (TiDB)
|
# - name: Check VDB Ready (TiDB)
|
||||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
- name: Test Vector Stores
|
- name: Test Vector Stores
|
||||||
run: |
|
run: uv run --project api bash dev/pytest/pytest_vdb.sh
|
||||||
uv run --project api pytest \
|
|
||||||
--start-vdb \
|
|
||||||
--vdb-services "weaviate,qdrant,couchbase-server,etcd,minio,milvus-standalone,pgvecto-rs,pgvector,chroma,elasticsearch,oceanbase" \
|
|
||||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
|
||||||
api/providers/vdb/*/tests/integration_tests
|
|
||||||
|
|||||||
35
.github/workflows/vdb-tests.yml
vendored
35
.github/workflows/vdb-tests.yml
vendored
@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: VDB Smoke Tests
|
name: VDB Smoke Tests
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version:
|
python-version:
|
||||||
@ -33,7 +33,7 @@ jobs:
|
|||||||
remove_tool_cache: true
|
remove_tool_cache: true
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@ -45,6 +45,14 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
|
- name: Set up dotenvs
|
||||||
|
run: |
|
||||||
|
cp docker/.env.example docker/.env
|
||||||
|
cp docker/middleware.env.example docker/middleware.env
|
||||||
|
|
||||||
|
- name: Expose Service Ports
|
||||||
|
run: sh .github/workflows/expose_service_ports.sh
|
||||||
|
|
||||||
# - name: Set up Vector Store (TiDB)
|
# - name: Set up Vector Store (TiDB)
|
||||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||||
# with:
|
# with:
|
||||||
@ -53,14 +61,31 @@ jobs:
|
|||||||
# tidb
|
# tidb
|
||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
|
- name: Set up Vector Stores for Smoke Coverage
|
||||||
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
|
with:
|
||||||
|
compose-file: |
|
||||||
|
docker/docker-compose.yaml
|
||||||
|
services: |
|
||||||
|
db_postgres
|
||||||
|
redis
|
||||||
|
weaviate
|
||||||
|
qdrant
|
||||||
|
pgvector
|
||||||
|
chroma
|
||||||
|
|
||||||
|
- name: setup test config
|
||||||
|
run: |
|
||||||
|
echo $(pwd)
|
||||||
|
ls -lah .
|
||||||
|
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||||
|
|
||||||
# - name: Check VDB Ready (TiDB)
|
# - name: Check VDB Ready (TiDB)
|
||||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
- name: Test Vector Stores
|
- name: Test Vector Stores
|
||||||
run: |
|
run: |
|
||||||
uv run --project api pytest \
|
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
--start-vdb \
|
|
||||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
|
||||||
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
||||||
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
||||||
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
||||||
|
|||||||
4
.github/workflows/web-e2e.yml
vendored
4
.github/workflows/web-e2e.yml
vendored
@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Web Full-Stack E2E
|
name: Web Full-Stack E2E
|
||||||
runs-on: depot-ubuntu-24.04-4
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -28,7 +28,7 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|||||||
42
.github/workflows/web-tests.yml
vendored
42
.github/workflows/web-tests.yml
vendored
@ -16,7 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||||
runs-on: depot-ubuntu-24.04-4
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
VITEST_COVERAGE_SCOPE: app-components
|
VITEST_COVERAGE_SCOPE: app-components
|
||||||
strategy:
|
strategy:
|
||||||
@ -39,7 +39,7 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||||
|
|
||||||
- name: Upload blob report
|
- name: Upload blob report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
@ -54,7 +54,7 @@ jobs:
|
|||||||
name: Merge Test Reports
|
name: Merge Test Reports
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
needs: [test]
|
needs: [test]
|
||||||
runs-on: depot-ubuntu-24.04-4
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
defaults:
|
defaults:
|
||||||
@ -83,43 +83,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
directory: web/coverage
|
directory: web/coverage
|
||||||
flags: web
|
flags: web
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||||
|
|
||||||
dify-ui-test:
|
|
||||||
name: dify-ui Tests
|
|
||||||
runs-on: depot-ubuntu-24.04-4
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: ./packages/dify-ui
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup web environment
|
|
||||||
uses: ./.github/actions/setup-web
|
|
||||||
|
|
||||||
- name: Install Chromium for Browser Mode
|
|
||||||
run: vp exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run dify-ui tests
|
|
||||||
run: vp test run --coverage --silent=passed-only
|
|
||||||
|
|
||||||
- name: Report coverage
|
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
|
||||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
|
||||||
with:
|
|
||||||
directory: packages/dify-ui/coverage
|
|
||||||
flags: dify-ui
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
|
||||||
|
|||||||
17
.gitignore
vendored
17
.gitignore
vendored
@ -115,12 +115,6 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
# cli/ has a src/env/ module (DIFY_* registry) — don't treat it as a venv
|
|
||||||
!/cli/src/env/
|
|
||||||
!/cli/src/commands/env/
|
|
||||||
# cli/scripts/lib/ holds TS build helpers (resolve-buildinfo etc.) — don't treat as Python lib/
|
|
||||||
!/cli/scripts/lib/
|
|
||||||
.conda/
|
.conda/
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
@ -209,7 +203,6 @@ sdks/python-client/dify_client.egg-info
|
|||||||
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/launch.json.template
|
!.vscode/launch.json.template
|
||||||
!.vscode/settings.example.json
|
|
||||||
!.vscode/README.md
|
!.vscode/README.md
|
||||||
api/.vscode
|
api/.vscode
|
||||||
# vscode Code History Extension
|
# vscode Code History Extension
|
||||||
@ -225,9 +218,6 @@ node_modules
|
|||||||
# plugin migrate
|
# plugin migrate
|
||||||
plugins.jsonl
|
plugins.jsonl
|
||||||
|
|
||||||
# generated API OpenAPI specs
|
|
||||||
packages/contracts/openapi/
|
|
||||||
|
|
||||||
# mise
|
# mise
|
||||||
mise.toml
|
mise.toml
|
||||||
|
|
||||||
@ -246,16 +236,9 @@ scripts/stress-test/reports/
|
|||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.serena/
|
.serena/
|
||||||
|
|
||||||
# vitest browser mode attachments (failure screenshots, traces, etc.)
|
|
||||||
.vitest-attachments/
|
|
||||||
**/__screenshots__/
|
|
||||||
|
|
||||||
# settings
|
# settings
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
*.local.toml
|
|
||||||
|
|
||||||
# Code Agent Folder
|
# Code Agent Folder
|
||||||
.qoder/*
|
.qoder/*
|
||||||
.context/
|
|
||||||
.eslintcache
|
|
||||||
|
|||||||
@ -56,9 +56,44 @@ if $api_modified; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if $web_modified; then
|
||||||
if $skip_web_checks; then
|
if $skip_web_checks; then
|
||||||
echo "Git operation in progress, skipping web checks"
|
echo "Git operation in progress, skipping web checks"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Running ESLint on web module"
|
||||||
|
|
||||||
|
if git diff --cached --quiet -- 'web/**/*.ts' 'web/**/*.tsx'; then
|
||||||
|
web_ts_modified=false
|
||||||
|
else
|
||||||
|
ts_diff_status=$?
|
||||||
|
if [ $ts_diff_status -eq 1 ]; then
|
||||||
|
web_ts_modified=true
|
||||||
|
else
|
||||||
|
echo "Unable to determine staged TypeScript changes (git exit code: $ts_diff_status)."
|
||||||
|
exit $ts_diff_status
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ./web || exit 1
|
||||||
vp staged
|
vp staged
|
||||||
|
|
||||||
|
if $web_ts_modified; then
|
||||||
|
echo "Running TypeScript type-check:tsgo"
|
||||||
|
if ! npm run type-check:tsgo; then
|
||||||
|
echo "Type check failed. Please run 'npm run type-check:tsgo' to fix the errors."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No staged TypeScript changes detected, skipping type-check:tsgo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running knip"
|
||||||
|
if ! npm run knip; then
|
||||||
|
echo "Knip check failed. Please run 'npm run knip' to fix the errors."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ../
|
||||||
|
fi
|
||||||
|
|||||||
15
.vscode/launch.json.template
vendored
15
.vscode/launch.json.template
vendored
@ -2,10 +2,21 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python: API (gevent)",
|
"name": "Python: Flask API",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/api/app.py",
|
"module": "flask",
|
||||||
|
"env": {
|
||||||
|
"FLASK_APP": "app.py",
|
||||||
|
"FLASK_ENV": "development"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--host=0.0.0.0",
|
||||||
|
"--port=5001",
|
||||||
|
"--no-debugger",
|
||||||
|
"--no-reload"
|
||||||
|
],
|
||||||
"jinja": true,
|
"jinja": true,
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"cwd": "${workspaceFolder}/api",
|
"cwd": "${workspaceFolder}/api",
|
||||||
|
|||||||
@ -9,7 +9,6 @@ The codebase is split into:
|
|||||||
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
|
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
|
||||||
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
|
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
|
||||||
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
||||||
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
|
|
||||||
|
|
||||||
## Backend Workflow
|
## Backend Workflow
|
||||||
|
|
||||||
@ -31,7 +30,7 @@ The codebase is split into:
|
|||||||
## Language Style
|
## Language Style
|
||||||
|
|
||||||
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
||||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
|
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
|
||||||
|
|
||||||
## General Practices
|
## General Practices
|
||||||
|
|
||||||
|
|||||||
93
Makefile
93
Makefile
@ -3,10 +3,6 @@ DOCKER_REGISTRY=langgenius
|
|||||||
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
|
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
|
||||||
API_IMAGE=$(DOCKER_REGISTRY)/dify-api
|
API_IMAGE=$(DOCKER_REGISTRY)/dify-api
|
||||||
VERSION=latest
|
VERSION=latest
|
||||||
DOCKER_DIR=docker
|
|
||||||
DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env
|
|
||||||
DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example
|
|
||||||
DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev
|
|
||||||
|
|
||||||
# Default target - show help
|
# Default target - show help
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@ -21,13 +17,8 @@ dev-setup: prepare-docker prepare-web prepare-api
|
|||||||
# Step 1: Prepare Docker middleware
|
# Step 1: Prepare Docker middleware
|
||||||
prepare-docker:
|
prepare-docker:
|
||||||
@echo "🐳 Setting up Docker middleware..."
|
@echo "🐳 Setting up Docker middleware..."
|
||||||
@if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
|
@cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists"
|
||||||
cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \
|
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d
|
||||||
echo "Docker middleware.env created"; \
|
|
||||||
else \
|
|
||||||
echo "Docker middleware.env already exists"; \
|
|
||||||
fi
|
|
||||||
@cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d
|
|
||||||
@echo "✅ Docker middleware started"
|
@echo "✅ Docker middleware started"
|
||||||
|
|
||||||
# Step 2: Prepare web environment
|
# Step 2: Prepare web environment
|
||||||
@ -48,18 +39,12 @@ prepare-api:
|
|||||||
# Clean dev environment
|
# Clean dev environment
|
||||||
dev-clean:
|
dev-clean:
|
||||||
@echo "⚠️ Stopping Docker containers..."
|
@echo "⚠️ Stopping Docker containers..."
|
||||||
@if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
|
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down
|
||||||
cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \
|
|
||||||
else \
|
|
||||||
echo "Docker middleware.env does not exist, skipping compose down"; \
|
|
||||||
fi
|
|
||||||
@echo "🗑️ Removing volumes..."
|
@echo "🗑️ Removing volumes..."
|
||||||
@rm -rf docker/volumes/db
|
@rm -rf docker/volumes/db
|
||||||
@rm -rf docker/volumes/mysql
|
|
||||||
@rm -rf docker/volumes/redis
|
@rm -rf docker/volumes/redis
|
||||||
@rm -rf docker/volumes/plugin_daemon
|
@rm -rf docker/volumes/plugin_daemon
|
||||||
@rm -rf docker/volumes/weaviate
|
@rm -rf docker/volumes/weaviate
|
||||||
@rm -rf docker/volumes/sandbox/dependencies
|
|
||||||
@rm -rf api/storage
|
@rm -rf api/storage
|
||||||
@echo "✅ Cleanup complete"
|
@echo "✅ Cleanup complete"
|
||||||
|
|
||||||
@ -75,29 +60,24 @@ check:
|
|||||||
@echo "✅ Code check complete"
|
@echo "✅ Code check complete"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@echo "🔧 Running ruff format, check with fixes, response contract lint, import linter, and dotenv-linter..."
|
@echo "🔧 Running ruff format, check with fixes, import linter, and dotenv-linter..."
|
||||||
@uv run --project api --dev ruff format ./api
|
@uv run --project api --dev ruff format ./api
|
||||||
@uv run --project api --dev ruff check --fix ./api
|
@uv run --project api --dev ruff check --fix ./api
|
||||||
@$(MAKE) api-contract-lint
|
|
||||||
@uv run --directory api --dev lint-imports
|
@uv run --directory api --dev lint-imports
|
||||||
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
|
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
|
||||||
@echo "✅ Linting complete"
|
@echo "✅ Linting complete"
|
||||||
|
|
||||||
api-contract-lint:
|
|
||||||
@echo "🔎 Linting Flask response contracts..."
|
|
||||||
@uv run --project api --dev python api/dev/lint_response_contracts.py
|
|
||||||
@echo "✅ Response contract lint complete"
|
|
||||||
|
|
||||||
type-check:
|
type-check:
|
||||||
@echo "📝 Running type checks (pyrefly + mypy)..."
|
@echo "📝 Running type checks (basedpyright + pyrefly + mypy)..."
|
||||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
||||||
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
@./dev/pyrefly-check-local
|
||||||
|
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
||||||
@echo "✅ Type checks complete"
|
@echo "✅ Type checks complete"
|
||||||
|
|
||||||
type-check-core:
|
type-check-core:
|
||||||
@echo "📝 Running core type checks (pyrefly + mypy)..."
|
@echo "📝 Running core type checks (basedpyright + mypy)..."
|
||||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
||||||
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped .
|
||||||
@echo "✅ Core type checks complete"
|
@echo "✅ Core type checks complete"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@ -106,46 +86,7 @@ test:
|
|||||||
echo "Target: $(TARGET_TESTS)"; \
|
echo "Target: $(TARGET_TESTS)"; \
|
||||||
uv run --project api --dev pytest $(TARGET_TESTS); \
|
uv run --project api --dev pytest $(TARGET_TESTS); \
|
||||||
else \
|
else \
|
||||||
echo "Running backend unit tests"; \
|
PYTEST_XDIST_ARGS="-n auto" uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \
|
||||||
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
|
|
||||||
api/tests/unit_tests \
|
|
||||||
api/providers/vdb/*/tests/unit_tests \
|
|
||||||
api/providers/trace/*/tests/unit_tests \
|
|
||||||
--ignore=api/tests/unit_tests/controllers; \
|
|
||||||
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
|
|
||||||
api/tests/unit_tests/controllers; \
|
|
||||||
fi
|
|
||||||
@echo "✅ Unit tests complete"
|
|
||||||
|
|
||||||
test-all:
|
|
||||||
@echo "🧪 Running full backend test suite..."
|
|
||||||
@if [ -n "$(TARGET_TESTS)" ]; then \
|
|
||||||
echo "Target: $(TARGET_TESTS)"; \
|
|
||||||
uv run --project api --dev pytest $(TARGET_TESTS); \
|
|
||||||
else \
|
|
||||||
echo "Running backend unit tests"; \
|
|
||||||
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
|
|
||||||
api/tests/unit_tests \
|
|
||||||
api/providers/vdb/*/tests/unit_tests \
|
|
||||||
api/providers/trace/*/tests/unit_tests \
|
|
||||||
--ignore=api/tests/unit_tests/controllers; \
|
|
||||||
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
|
|
||||||
api/tests/unit_tests/controllers; \
|
|
||||||
echo "Running backend integration tests"; \
|
|
||||||
uv run --project api --dev pytest -p no:benchmark --start-middleware -n auto \
|
|
||||||
--timeout "$${PYTEST_TIMEOUT:-180}" \
|
|
||||||
--cov-append \
|
|
||||||
api/tests/integration_tests/workflow \
|
|
||||||
api/tests/integration_tests/tools \
|
|
||||||
api/tests/test_containers_integration_tests; \
|
|
||||||
echo "Running VDB smoke tests"; \
|
|
||||||
uv run --project api --dev pytest --start-vdb \
|
|
||||||
--timeout "$${PYTEST_TIMEOUT:-180}" \
|
|
||||||
--cov-append \
|
|
||||||
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
|
||||||
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
|
||||||
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
|
||||||
api/providers/vdb/vdb-weaviate/tests/integration_tests; \
|
|
||||||
fi
|
fi
|
||||||
@echo "✅ Tests complete"
|
@echo "✅ Tests complete"
|
||||||
|
|
||||||
@ -191,17 +132,15 @@ help:
|
|||||||
@echo " make prepare-docker - Set up Docker middleware"
|
@echo " make prepare-docker - Set up Docker middleware"
|
||||||
@echo " make prepare-web - Set up web environment"
|
@echo " make prepare-web - Set up web environment"
|
||||||
@echo " make prepare-api - Set up API environment"
|
@echo " make prepare-api - Set up API environment"
|
||||||
@echo " make dev-clean - Stop Docker middleware containers and remove dev data"
|
@echo " make dev-clean - Stop Docker middleware containers"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Backend Code Quality:"
|
@echo "Backend Code Quality:"
|
||||||
@echo " make format - Format code with ruff"
|
@echo " make format - Format code with ruff"
|
||||||
@echo " make check - Check code with ruff"
|
@echo " make check - Check code with ruff"
|
||||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||||
@echo " make api-contract-lint - Check Flask response docs against returned schemas"
|
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
|
||||||
@echo " make type-check - Run type checks (pyrefly, mypy)"
|
@echo " make type-check-core - Run core type checks (basedpyright, mypy)"
|
||||||
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
|
|
||||||
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||||
@echo " make test-all - Run full backend tests, including Docker-backed suites"
|
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Docker Build Targets:"
|
@echo "Docker Build Targets:"
|
||||||
@echo " make build-web - Build web Docker image"
|
@echo " make build-web - Build web Docker image"
|
||||||
@ -211,4 +150,4 @@ help:
|
|||||||
@echo " make build-push-all - Build and push all Docker images"
|
@echo " make build-push-all - Build and push all Docker images"
|
||||||
|
|
||||||
# Phony targets
|
# Phony targets
|
||||||
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint api-contract-lint type-check test test-all
|
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint type-check test
|
||||||
|
|||||||
17
README.md
17
README.md
@ -137,7 +137,20 @@ Star Dify on GitHub and be instantly notified of new releases.
|
|||||||
|
|
||||||
### Custom configurations
|
### Custom configurations
|
||||||
|
|
||||||
If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||||
|
|
||||||
|
#### Customizing Suggested Questions
|
||||||
|
|
||||||
|
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your .env file
|
||||||
|
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
|
||||||
|
SUGGESTED_QUESTIONS_MAX_TOKENS=512
|
||||||
|
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
|
||||||
|
|
||||||
### Metrics Monitoring with Grafana
|
### Metrics Monitoring with Grafana
|
||||||
|
|
||||||
@ -147,7 +160,7 @@ Import the dashboard to Grafana, using Dify's PostgreSQL database as data source
|
|||||||
|
|
||||||
### Deployment with Kubernetes
|
### Deployment with Kubernetes
|
||||||
|
|
||||||
If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
|
If you'd like to configure a highly-available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
|
||||||
|
|
||||||
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
|
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
|
||||||
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
|
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
|
||||||
|
|||||||
@ -33,9 +33,6 @@ TRIGGER_URL=http://localhost:5001
|
|||||||
# The time in seconds after the signature is rejected
|
# The time in seconds after the signature is rejected
|
||||||
FILES_ACCESS_TIMEOUT=300
|
FILES_ACCESS_TIMEOUT=300
|
||||||
|
|
||||||
# Collaboration mode toggle
|
|
||||||
ENABLE_COLLABORATION_MODE=true
|
|
||||||
|
|
||||||
# Access token expiration time in minutes
|
# Access token expiration time in minutes
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
|
||||||
@ -88,10 +85,6 @@ REDIS_HEALTH_CHECK_INTERVAL=30
|
|||||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
||||||
CELERY_BACKEND=redis
|
CELERY_BACKEND=redis
|
||||||
|
|
||||||
# Ops trace retry configuration
|
|
||||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
|
|
||||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
|
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
DB_TYPE=postgresql
|
DB_TYPE=postgresql
|
||||||
DB_USERNAME=postgres
|
DB_USERNAME=postgres
|
||||||
@ -102,8 +95,6 @@ DB_DATABASE=dify
|
|||||||
|
|
||||||
SQLALCHEMY_POOL_PRE_PING=true
|
SQLALCHEMY_POOL_PRE_PING=true
|
||||||
SQLALCHEMY_POOL_TIMEOUT=30
|
SQLALCHEMY_POOL_TIMEOUT=30
|
||||||
# Connection pool reset behavior on return
|
|
||||||
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
|
|
||||||
|
|
||||||
# Storage configuration
|
# Storage configuration
|
||||||
# use for store upload files, private keys...
|
# use for store upload files, private keys...
|
||||||
@ -387,7 +378,7 @@ VIKINGDB_ACCESS_KEY=your-ak
|
|||||||
VIKINGDB_SECRET_KEY=your-sk
|
VIKINGDB_SECRET_KEY=your-sk
|
||||||
VIKINGDB_REGION=cn-shanghai
|
VIKINGDB_REGION=cn-shanghai
|
||||||
VIKINGDB_HOST=api-vikingdb.xxx.volces.com
|
VIKINGDB_HOST=api-vikingdb.xxx.volces.com
|
||||||
VIKINGDB_SCHEME=http
|
VIKINGDB_SCHEMA=http
|
||||||
VIKINGDB_CONNECTION_TIMEOUT=30
|
VIKINGDB_CONNECTION_TIMEOUT=30
|
||||||
VIKINGDB_SOCKET_TIMEOUT=30
|
VIKINGDB_SOCKET_TIMEOUT=30
|
||||||
|
|
||||||
@ -438,6 +429,8 @@ UPLOAD_FILE_EXTENSION_BLACKLIST=
|
|||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
MULTIMODAL_SEND_FORMAT=base64
|
MULTIMODAL_SEND_FORMAT=base64
|
||||||
|
PROMPT_GENERATION_MAX_TOKENS=512
|
||||||
|
CODE_GENERATION_MAX_TOKENS=1024
|
||||||
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
|
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
|
||||||
|
|
||||||
# Mail configuration, support: resend, smtp, sendgrid
|
# Mail configuration, support: resend, smtp, sendgrid
|
||||||
@ -557,7 +550,7 @@ MAX_VARIABLE_SIZE=204800
|
|||||||
|
|
||||||
# GraphEngine Worker Pool Configuration
|
# GraphEngine Worker Pool Configuration
|
||||||
# Minimum number of workers per GraphEngine instance (default: 1)
|
# Minimum number of workers per GraphEngine instance (default: 1)
|
||||||
GRAPH_ENGINE_MIN_WORKERS=3
|
GRAPH_ENGINE_MIN_WORKERS=1
|
||||||
# Maximum number of workers per GraphEngine instance (default: 10)
|
# Maximum number of workers per GraphEngine instance (default: 10)
|
||||||
GRAPH_ENGINE_MAX_WORKERS=10
|
GRAPH_ENGINE_MAX_WORKERS=10
|
||||||
# Queue depth threshold that triggers worker scale up (default: 3)
|
# Queue depth threshold that triggers worker scale up (default: 3)
|
||||||
@ -657,18 +650,12 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
|||||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
|
||||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||||
|
|
||||||
# Marketplace configuration
|
# Marketplace configuration
|
||||||
MARKETPLACE_ENABLED=true
|
MARKETPLACE_ENABLED=true
|
||||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||||
|
|
||||||
# Creators Platform configuration
|
|
||||||
CREATORS_PLATFORM_FEATURES_ENABLED=true
|
|
||||||
CREATORS_PLATFORM_API_URL=https://creators.dify.ai
|
|
||||||
CREATORS_PLATFORM_OAUTH_CLIENT_ID=
|
|
||||||
|
|
||||||
# Endpoint configuration
|
# Endpoint configuration
|
||||||
ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
||||||
|
|
||||||
@ -719,6 +706,22 @@ SWAGGER_UI_PATH=/swagger-ui.html
|
|||||||
# Set to false to export dataset IDs as plain text for easier cross-environment import
|
# Set to false to export dataset IDs as plain text for easier cross-environment import
|
||||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||||
|
|
||||||
|
# Suggested Questions After Answer Configuration
|
||||||
|
# These environment variables allow customization of the suggested questions feature
|
||||||
|
#
|
||||||
|
# Custom prompt for generating suggested questions (optional)
|
||||||
|
# If not set, uses the default prompt that generates 3 questions under 20 characters each
|
||||||
|
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
|
||||||
|
# SUGGESTED_QUESTIONS_PROMPT=
|
||||||
|
|
||||||
|
# Maximum number of tokens for suggested questions generation (default: 256)
|
||||||
|
# Adjust this value for longer questions or more questions
|
||||||
|
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
|
||||||
|
|
||||||
|
# Temperature for suggested questions generation (default: 0.0)
|
||||||
|
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
|
||||||
|
# SUGGESTED_QUESTIONS_TEMPERATURE=0
|
||||||
|
|
||||||
# Tenant isolated task queue configuration
|
# Tenant isolated task queue configuration
|
||||||
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
||||||
|
|
||||||
@ -768,7 +771,6 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
|||||||
# Whether to use Redis cluster mode while use redis as event bus.
|
# Whether to use Redis cluster mode while use redis as event bus.
|
||||||
# It's highly recommended to enable this for large deployments.
|
# It's highly recommended to enable this for large deployments.
|
||||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||||
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
|
||||||
|
|
||||||
# Whether to Enable human input timeout check task
|
# Whether to Enable human input timeout check task
|
||||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||||
|
|||||||
@ -106,6 +106,3 @@ msg = "Use Pydantic payload/query models instead of reqparse."
|
|||||||
|
|
||||||
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
|
||||||
msg = "Use Pydantic payload/query models instead of reqparse."
|
msg = "Use Pydantic payload/query models instead of reqparse."
|
||||||
|
|
||||||
[lint.isort]
|
|
||||||
known-first-party = ["graphon"]
|
|
||||||
18
api/.vscode/launch.json.example
vendored
18
api/.vscode/launch.json.example
vendored
@ -3,21 +3,29 @@
|
|||||||
"compounds": [
|
"compounds": [
|
||||||
{
|
{
|
||||||
"name": "Launch Flask and Celery",
|
"name": "Launch Flask and Celery",
|
||||||
"configurations": ["Python: API (gevent)", "Python: Celery"]
|
"configurations": ["Python: Flask", "Python: Celery"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python: API (gevent)",
|
"name": "Python: Flask",
|
||||||
"consoleName": "API",
|
"consoleName": "Flask",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"python": "${workspaceFolder}/.venv/bin/python",
|
"python": "${workspaceFolder}/.venv/bin/python",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"envFile": ".env",
|
"envFile": ".env",
|
||||||
"program": "${workspaceFolder}/app.py",
|
"module": "flask",
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"jinja": true
|
"jinja": true,
|
||||||
|
"env": {
|
||||||
|
"FLASK_APP": "app.py",
|
||||||
|
"GEVENT_SUPPORT": "True"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--port=5001"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Celery",
|
"name": "Python: Celery",
|
||||||
|
|||||||
@ -180,8 +180,6 @@ Quick checks while iterating:
|
|||||||
- Format: `make format`
|
- Format: `make format`
|
||||||
- Lint (includes auto-fix): `make lint`
|
- Lint (includes auto-fix): `make lint`
|
||||||
- Type check: `make type-check`
|
- Type check: `make type-check`
|
||||||
- Unit tests: `make test`
|
|
||||||
- Full backend tests, including Docker-backed suites: `make test-all`
|
|
||||||
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
|
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
|
||||||
|
|
||||||
Before opening a PR / submitting:
|
Before opening a PR / submitting:
|
||||||
@ -195,11 +193,6 @@ Before opening a PR / submitting:
|
|||||||
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
|
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
|
||||||
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
|
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
|
||||||
- Document non-obvious behaviour with concise docstrings and comments.
|
- Document non-obvious behaviour with concise docstrings and comments.
|
||||||
- For `204 No Content` responses, return an empty body only; never return a dict, model, or other payload.
|
|
||||||
- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`.
|
|
||||||
In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response
|
|
||||||
DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`,
|
|
||||||
and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns.
|
|
||||||
|
|
||||||
### Miscellaneous
|
### Miscellaneous
|
||||||
|
|
||||||
|
|||||||
@ -22,12 +22,9 @@ RUN apt-get update \
|
|||||||
libmpfr-dev libmpc-dev
|
libmpfr-dev libmpc-dev
|
||||||
|
|
||||||
# Install Python dependencies (workspace members under providers/vdb/)
|
# Install Python dependencies (workspace members under providers/vdb/)
|
||||||
COPY api/pyproject.toml api/uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
COPY api/providers ./providers
|
COPY providers ./providers
|
||||||
COPY dify-agent/pyproject.toml dify-agent/README.md /app/dify-agent/
|
RUN uv sync --locked --no-dev
|
||||||
COPY dify-agent/src /app/dify-agent/src
|
|
||||||
# Trust the checked-in lock during image builds; local path sources are copied from the repository context.
|
|
||||||
RUN uv sync --frozen --no-dev --no-editable
|
|
||||||
|
|
||||||
# production stage
|
# production stage
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
@ -110,10 +107,10 @@ RUN python -c "import tiktoken; tiktoken.encoding_for_model('gpt2')" \
|
|||||||
&& chown -R dify:dify ${TIKTOKEN_CACHE_DIR}
|
&& chown -R dify:dify ${TIKTOKEN_CACHE_DIR}
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY --chown=dify:dify api /app/api/
|
COPY --chown=dify:dify . /app/api/
|
||||||
|
|
||||||
# Prepare entrypoint script
|
# Prepare entrypoint script
|
||||||
COPY --chown=dify:dify --chmod=755 api/docker/entrypoint.sh /entrypoint.sh
|
COPY --chown=dify:dify --chmod=755 docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|
||||||
ARG COMMIT_SHA
|
ARG COMMIT_SHA
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
*
|
|
||||||
|
|
||||||
!api/
|
|
||||||
!api/**
|
|
||||||
!dify-agent/
|
|
||||||
!dify-agent/pyproject.toml
|
|
||||||
!dify-agent/README.md
|
|
||||||
!dify-agent/src/
|
|
||||||
!dify-agent/src/**
|
|
||||||
|
|
||||||
api/.venv
|
|
||||||
api/.venv/**
|
|
||||||
api/.env
|
|
||||||
api/*.env.*
|
|
||||||
api/.idea
|
|
||||||
api/.mypy_cache
|
|
||||||
api/.ruff_cache
|
|
||||||
api/storage/generate_files/*
|
|
||||||
api/storage/privkeys/*
|
|
||||||
api/storage/tools/*
|
|
||||||
api/storage/upload_files/*
|
|
||||||
api/logs
|
|
||||||
api/*.log*
|
|
||||||
**/__pycache__
|
|
||||||
**/*.pyc
|
|
||||||
@ -99,13 +99,5 @@ The scripts resolve paths relative to their location, so you can run them from a
|
|||||||
./dev/reformat # Run all formatters and linters
|
./dev/reformat # Run all formatters and linters
|
||||||
uv run ruff check --fix ./ # Fix linting issues
|
uv run ruff check --fix ./ # Fix linting issues
|
||||||
uv run ruff format ./ # Format code
|
uv run ruff format ./ # Format code
|
||||||
uv run pyrefly check # Type checking
|
uv run basedpyright . # Type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
## Generate TS stub
|
|
||||||
|
|
||||||
```
|
|
||||||
uv run dev/generate_swagger_specs.py --output-dir openapi
|
|
||||||
```
|
|
||||||
|
|
||||||
use https://jsontotable.org/openapi-to-typescript to convert to typescript
|
|
||||||
|
|||||||
29
api/app.py
29
api/app.py
@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
@ -10,35 +9,17 @@ if TYPE_CHECKING:
|
|||||||
celery: Celery
|
celery: Celery
|
||||||
|
|
||||||
|
|
||||||
HOST = "0.0.0.0"
|
|
||||||
PORT = 5001
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def is_db_command() -> bool:
|
def is_db_command() -> bool:
|
||||||
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def log_startup_banner(host: str, port: int) -> None:
|
|
||||||
debugger_attached = sys.gettrace() is not None
|
|
||||||
logger.info("Serving Dify API via gevent WebSocket server")
|
|
||||||
logger.info("Bound to http://%s:%s", host, port)
|
|
||||||
logger.info("Debugger attached: %s", "on" if debugger_attached else "off")
|
|
||||||
logger.info("Press CTRL+C to quit")
|
|
||||||
|
|
||||||
|
|
||||||
# create app
|
# create app
|
||||||
flask_app = None
|
|
||||||
socketio_app = None
|
|
||||||
|
|
||||||
if is_db_command():
|
if is_db_command():
|
||||||
from app_factory import create_migrations_app
|
from app_factory import create_migrations_app
|
||||||
|
|
||||||
app = create_migrations_app()
|
app = create_migrations_app()
|
||||||
socketio_app = app
|
|
||||||
flask_app = app
|
|
||||||
else:
|
else:
|
||||||
# Gunicorn and Celery handle monkey patching automatically in production by
|
# Gunicorn and Celery handle monkey patching automatically in production by
|
||||||
# specifying the `gevent` worker class. Manual monkey patching is not required here.
|
# specifying the `gevent` worker class. Manual monkey patching is not required here.
|
||||||
@ -49,14 +30,8 @@ else:
|
|||||||
|
|
||||||
from app_factory import create_app
|
from app_factory import create_app
|
||||||
|
|
||||||
socketio_app, flask_app = create_app()
|
app = create_app()
|
||||||
app = flask_app
|
|
||||||
celery = cast("Celery", app.extensions["celery"])
|
celery = cast("Celery", app.extensions["celery"])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from gevent import pywsgi
|
app.run(host="0.0.0.0", port=5001)
|
||||||
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
|
|
||||||
|
|
||||||
log_startup_banner(HOST, PORT)
|
|
||||||
server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import socketio # type: ignore[reportMissingTypeStubs]
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from opentelemetry.trace import get_current_span
|
from opentelemetry.trace import get_current_span
|
||||||
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
||||||
@ -11,7 +10,6 @@ from contexts.wrapper import RecyclableContextVar
|
|||||||
from controllers.console.error import UnauthorizedAndForceLogout
|
from controllers.console.error import UnauthorizedAndForceLogout
|
||||||
from core.logging.context import init_request_context
|
from core.logging.context import init_request_context
|
||||||
from dify_app import DifyApp
|
from dify_app import DifyApp
|
||||||
from extensions.ext_socketio import sio
|
|
||||||
from services.enterprise.enterprise_service import EnterpriseService
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
from services.feature_service import LicenseStatus
|
from services.feature_service import LicenseStatus
|
||||||
|
|
||||||
@ -117,25 +115,21 @@ def create_flask_app_with_configs() -> DifyApp:
|
|||||||
logger.warning("Failed to add trace headers to response", exc_info=True)
|
logger.warning("Failed to add trace headers to response", exc_info=True)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Capture the decorator return values so static checkers do not treat the hooks as unused.
|
# Capture the decorator's return value to avoid pyright reportUnusedFunction
|
||||||
_ = before_request
|
_ = before_request
|
||||||
_ = add_trace_headers
|
_ = add_trace_headers
|
||||||
|
|
||||||
return dify_app
|
return dify_app
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> tuple[socketio.WSGIApp, DifyApp]:
|
def create_app() -> DifyApp:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
app = create_flask_app_with_configs()
|
app = create_flask_app_with_configs()
|
||||||
initialize_extensions(app)
|
initialize_extensions(app)
|
||||||
|
|
||||||
sio.app = app
|
|
||||||
socketio_app = socketio.WSGIApp(sio, app)
|
|
||||||
|
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
if dify_config.DEBUG:
|
if dify_config.DEBUG:
|
||||||
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
|
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
|
||||||
return socketio_app, app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def initialize_extensions(app: DifyApp):
|
def initialize_extensions(app: DifyApp):
|
||||||
@ -159,7 +153,6 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_logstore,
|
ext_logstore,
|
||||||
ext_mail,
|
ext_mail,
|
||||||
ext_migrate,
|
ext_migrate,
|
||||||
ext_oauth_bearer,
|
|
||||||
ext_orjson,
|
ext_orjson,
|
||||||
ext_otel,
|
ext_otel,
|
||||||
ext_proxy_fix,
|
ext_proxy_fix,
|
||||||
@ -182,6 +175,7 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_import_modules,
|
ext_import_modules,
|
||||||
ext_orjson,
|
ext_orjson,
|
||||||
ext_forward_refs,
|
ext_forward_refs,
|
||||||
|
ext_set_secretkey,
|
||||||
ext_compress,
|
ext_compress,
|
||||||
ext_code_based_extension,
|
ext_code_based_extension,
|
||||||
ext_database,
|
ext_database,
|
||||||
@ -189,7 +183,6 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_migrate,
|
ext_migrate,
|
||||||
ext_redis,
|
ext_redis,
|
||||||
ext_storage,
|
ext_storage,
|
||||||
ext_set_secretkey,
|
|
||||||
ext_logstore, # Initialize logstore after storage, before celery
|
ext_logstore, # Initialize logstore after storage, before celery
|
||||||
ext_celery,
|
ext_celery,
|
||||||
ext_login,
|
ext_login,
|
||||||
@ -204,7 +197,6 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_enterprise_telemetry,
|
ext_enterprise_telemetry,
|
||||||
ext_request_logging,
|
ext_request_logging,
|
||||||
ext_session_factory,
|
ext_session_factory,
|
||||||
ext_oauth_bearer,
|
|
||||||
]
|
]
|
||||||
for ext in extensions:
|
for ext in extensions:
|
||||||
short_name = ext.__name__.split(".")[-1]
|
short_name = ext.__name__.split(".")[-1]
|
||||||
@ -223,11 +215,10 @@ def initialize_extensions(app: DifyApp):
|
|||||||
|
|
||||||
def create_migrations_app() -> DifyApp:
|
def create_migrations_app() -> DifyApp:
|
||||||
app = create_flask_app_with_configs()
|
app = create_flask_app_with_configs()
|
||||||
from extensions import ext_commands, ext_database, ext_migrate
|
from extensions import ext_database, ext_migrate
|
||||||
|
|
||||||
# Initialize only required extensions
|
# Initialize only required extensions
|
||||||
ext_database.init_app(app)
|
ext_database.init_app(app)
|
||||||
ext_migrate.init_app(app)
|
ext_migrate.init_app(app)
|
||||||
ext_commands.init_app(app)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
"""External service client packages."""
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
"""API-side integration boundary for the Dify Agent backend.
|
|
||||||
|
|
||||||
Public wire DTOs come from ``dify_agent.protocol``. This package only contains
|
|
||||||
API adapters: request building from Dify product concepts, a thin client wrapper,
|
|
||||||
event adaptation for future workflow integration, and deterministic fakes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
|
||||||
from clients.agent_backend.errors import (
|
|
||||||
AgentBackendError,
|
|
||||||
AgentBackendHTTPError,
|
|
||||||
AgentBackendRequestBuildError,
|
|
||||||
AgentBackendRunFailedError,
|
|
||||||
AgentBackendStreamError,
|
|
||||||
AgentBackendTransportError,
|
|
||||||
AgentBackendValidationError,
|
|
||||||
)
|
|
||||||
from clients.agent_backend.event_adapter import (
|
|
||||||
AgentBackendInternalEvent,
|
|
||||||
AgentBackendInternalEventType,
|
|
||||||
AgentBackendRunCancelledInternalEvent,
|
|
||||||
AgentBackendRunEventAdapter,
|
|
||||||
AgentBackendRunFailedInternalEvent,
|
|
||||||
AgentBackendRunPausedInternalEvent,
|
|
||||||
AgentBackendRunStartedInternalEvent,
|
|
||||||
AgentBackendRunSucceededInternalEvent,
|
|
||||||
AgentBackendStreamInternalEvent,
|
|
||||||
)
|
|
||||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
|
||||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
|
||||||
from clients.agent_backend.request_builder import (
|
|
||||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
|
||||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
|
||||||
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
|
||||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
|
||||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
|
||||||
AgentBackendModelConfig,
|
|
||||||
AgentBackendOutputConfig,
|
|
||||||
AgentBackendRunRequestBuilder,
|
|
||||||
AgentBackendWorkflowNodeRunInput,
|
|
||||||
CleanupLayerSpec,
|
|
||||||
extract_cleanup_layer_specs,
|
|
||||||
redact_for_agent_backend_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AGENT_SOUL_PROMPT_LAYER_ID",
|
|
||||||
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
|
|
||||||
"DIFY_PLUGIN_TOOLS_LAYER_ID",
|
|
||||||
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
|
||||||
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
|
||||||
"AgentBackendError",
|
|
||||||
"AgentBackendHTTPError",
|
|
||||||
"AgentBackendInternalEvent",
|
|
||||||
"AgentBackendInternalEventType",
|
|
||||||
"AgentBackendModelConfig",
|
|
||||||
"AgentBackendOutputConfig",
|
|
||||||
"AgentBackendRequestBuildError",
|
|
||||||
"AgentBackendRunCancelledInternalEvent",
|
|
||||||
"AgentBackendRunClient",
|
|
||||||
"AgentBackendRunEventAdapter",
|
|
||||||
"AgentBackendRunFailedError",
|
|
||||||
"AgentBackendRunFailedInternalEvent",
|
|
||||||
"AgentBackendRunPausedInternalEvent",
|
|
||||||
"AgentBackendRunRequestBuilder",
|
|
||||||
"AgentBackendRunStartedInternalEvent",
|
|
||||||
"AgentBackendRunSucceededInternalEvent",
|
|
||||||
"AgentBackendStreamError",
|
|
||||||
"AgentBackendStreamInternalEvent",
|
|
||||||
"AgentBackendTransportError",
|
|
||||||
"AgentBackendValidationError",
|
|
||||||
"AgentBackendWorkflowNodeRunInput",
|
|
||||||
"CleanupLayerSpec",
|
|
||||||
"DifyAgentBackendRunClient",
|
|
||||||
"FakeAgentBackendRunClient",
|
|
||||||
"FakeAgentBackendScenario",
|
|
||||||
"create_agent_backend_run_client",
|
|
||||||
"extract_cleanup_layer_specs",
|
|
||||||
"redact_for_agent_backend_log",
|
|
||||||
]
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
"""Synchronous API-side wrapper around the public ``dify-agent`` client.
|
|
||||||
|
|
||||||
``dify-agent`` owns the cross-service DTOs and HTTP/SSE implementation. The API
|
|
||||||
backend keeps this thin wrapper so workflow code depends on a local protocol,
|
|
||||||
gets API-native errors, and can use a deterministic fake in tests without
|
|
||||||
creating another wire contract.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from dify_agent.client import (
|
|
||||||
DifyAgentClientError,
|
|
||||||
DifyAgentHTTPError,
|
|
||||||
DifyAgentStreamError,
|
|
||||||
DifyAgentTimeoutError,
|
|
||||||
DifyAgentValidationError,
|
|
||||||
)
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
CancelRunRequest,
|
|
||||||
CancelRunResponse,
|
|
||||||
CreateRunRequest,
|
|
||||||
CreateRunResponse,
|
|
||||||
RunEvent,
|
|
||||||
RunStatusResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
from clients.agent_backend.errors import (
|
|
||||||
AgentBackendError,
|
|
||||||
AgentBackendHTTPError,
|
|
||||||
AgentBackendStreamError,
|
|
||||||
AgentBackendTransportError,
|
|
||||||
AgentBackendValidationError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunClient(Protocol):
|
|
||||||
"""Local boundary used by API workflow integrations to run Agent backend jobs."""
|
|
||||||
|
|
||||||
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
|
||||||
"""Create one Agent backend run and return its accepted status."""
|
|
||||||
|
|
||||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
|
||||||
"""Request explicit cancellation for one Agent backend run."""
|
|
||||||
|
|
||||||
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
|
||||||
"""Yield public ``dify-agent`` run events in stream order."""
|
|
||||||
|
|
||||||
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
|
||||||
"""Wait for a run to reach a terminal status and return that status."""
|
|
||||||
|
|
||||||
|
|
||||||
class _DifyAgentSyncClient(Protocol):
|
|
||||||
"""Subset of ``dify_agent.client.Client`` used by the API wrapper."""
|
|
||||||
|
|
||||||
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
|
|
||||||
"""Create one run synchronously."""
|
|
||||||
|
|
||||||
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
|
||||||
"""Cancel one run synchronously."""
|
|
||||||
|
|
||||||
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
|
||||||
"""Stream run events synchronously."""
|
|
||||||
|
|
||||||
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
|
||||||
"""Wait for terminal run status synchronously."""
|
|
||||||
|
|
||||||
|
|
||||||
class DifyAgentBackendRunClient:
|
|
||||||
"""Adapter from API sync call sites to ``dify_agent.client.Client`` sync methods."""
|
|
||||||
|
|
||||||
client: _DifyAgentSyncClient
|
|
||||||
|
|
||||||
def __init__(self, client: _DifyAgentSyncClient) -> None:
|
|
||||||
self.client = client
|
|
||||||
|
|
||||||
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
|
||||||
"""Create one run through ``POST /runs`` and normalize client exceptions."""
|
|
||||||
try:
|
|
||||||
return self.client.create_run_sync(request)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_dify_agent_error(exc) from exc
|
|
||||||
|
|
||||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
|
||||||
"""Cancel one run through ``POST /runs/{run_id}/cancel`` and normalize exceptions."""
|
|
||||||
try:
|
|
||||||
return self.client.cancel_run_sync(run_id, request=request)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_dify_agent_error(exc) from exc
|
|
||||||
|
|
||||||
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
|
||||||
"""Stream run events from ``/events/sse`` with the wrapped client's reconnect policy."""
|
|
||||||
try:
|
|
||||||
yield from self.client.stream_events_sync(run_id, after=after)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_dify_agent_error(exc) from exc
|
|
||||||
|
|
||||||
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
|
||||||
"""Poll run status until terminal state and normalize client exceptions."""
|
|
||||||
try:
|
|
||||||
return self.client.wait_run_sync(run_id, timeout_seconds=timeout_seconds)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_dify_agent_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_dify_agent_error(exc: Exception) -> AgentBackendError:
|
|
||||||
"""Map public ``dify-agent`` client errors to API-side integration errors."""
|
|
||||||
match exc:
|
|
||||||
case DifyAgentValidationError() as error:
|
|
||||||
return AgentBackendValidationError(
|
|
||||||
"Agent backend request or response validation failed", detail=error.detail
|
|
||||||
)
|
|
||||||
case DifyAgentHTTPError() as error:
|
|
||||||
return AgentBackendHTTPError(
|
|
||||||
f"Agent backend HTTP {error.status_code}",
|
|
||||||
status_code=error.status_code,
|
|
||||||
detail=error.detail,
|
|
||||||
)
|
|
||||||
case DifyAgentTimeoutError() as error:
|
|
||||||
return AgentBackendTransportError(str(error))
|
|
||||||
case DifyAgentStreamError() as error:
|
|
||||||
return AgentBackendStreamError(str(error))
|
|
||||||
case DifyAgentClientError() as error:
|
|
||||||
return AgentBackendTransportError(str(error))
|
|
||||||
case AgentBackendError() as error:
|
|
||||||
return error
|
|
||||||
case _:
|
|
||||||
return AgentBackendTransportError(str(exc) or type(exc).__name__)
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
"""API-side errors for the Dify Agent backend integration.
|
|
||||||
|
|
||||||
The wire protocol and low-level HTTP behaviour are owned by ``dify-agent``.
|
|
||||||
This module only normalizes those client errors into the API backend's boundary
|
|
||||||
so workflow/node code does not depend directly on transport-specific exception
|
|
||||||
classes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendError(Exception):
|
|
||||||
"""Base error for API-side Agent backend integration failures."""
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRequestBuildError(AgentBackendError):
|
|
||||||
"""Raised when Dify product/workflow state cannot be mapped to a run request."""
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendTransportError(AgentBackendError):
|
|
||||||
"""Raised for timeout or request-level failures talking to Agent backend."""
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendHTTPError(AgentBackendTransportError):
|
|
||||||
"""Raised for Agent backend HTTP errors after status/detail normalization."""
|
|
||||||
|
|
||||||
status_code: int
|
|
||||||
detail: object
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, status_code: int, detail: object) -> None:
|
|
||||||
self.status_code = status_code
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendValidationError(AgentBackendError):
|
|
||||||
"""Raised for local request validation or Agent backend 422 responses."""
|
|
||||||
|
|
||||||
detail: object
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, detail: object) -> None:
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendStreamError(AgentBackendError):
|
|
||||||
"""Raised when an Agent backend event stream is malformed or exhausted."""
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunFailedError(AgentBackendError):
|
|
||||||
"""Raised by callers that choose to translate a terminal failed run into an exception."""
|
|
||||||
|
|
||||||
run_id: str
|
|
||||||
detail: Any
|
|
||||||
|
|
||||||
def __init__(self, run_id: str, detail: Any) -> None:
|
|
||||||
self.run_id = run_id
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(f"Agent backend run failed: {run_id}")
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
"""Adapt public ``dify-agent`` run events into API-internal event semantics.
|
|
||||||
|
|
||||||
The adapter does not define a new cross-service event contract. It consumes
|
|
||||||
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
|
|
||||||
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Annotated, Literal, cast
|
|
||||||
|
|
||||||
from agenton.compositor import CompositorSessionSnapshot
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
PydanticAIStreamRunEvent,
|
|
||||||
RunCancelledEvent,
|
|
||||||
RunEvent,
|
|
||||||
RunFailedEvent,
|
|
||||||
RunPausedEvent,
|
|
||||||
RunStartedEvent,
|
|
||||||
RunSucceededEvent,
|
|
||||||
)
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
|
|
||||||
|
|
||||||
_EVENT_DATA_ADAPTER = TypeAdapter(object)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendInternalEventType(StrEnum):
|
|
||||||
"""API-only event labels used before Graphon/AppQueue integration."""
|
|
||||||
|
|
||||||
RUN_STARTED = "run_started"
|
|
||||||
STREAM_EVENT = "stream_event"
|
|
||||||
RUN_PAUSED = "run_paused"
|
|
||||||
RUN_SUCCEEDED = "run_succeeded"
|
|
||||||
RUN_FAILED = "run_failed"
|
|
||||||
RUN_CANCELLED = "run_cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendInternalEventBase(BaseModel):
|
|
||||||
"""Common fields preserved from public Dify Agent run events."""
|
|
||||||
|
|
||||||
run_id: str
|
|
||||||
source_event_id: str | None = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunStartedInternalEvent(AgentBackendInternalEventBase):
|
|
||||||
"""API-internal marker for a started Agent backend run."""
|
|
||||||
|
|
||||||
type: Literal[AgentBackendInternalEventType.RUN_STARTED] = AgentBackendInternalEventType.RUN_STARTED
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase):
|
|
||||||
"""API-internal wrapper for one pydantic-ai stream event payload."""
|
|
||||||
|
|
||||||
type: Literal[AgentBackendInternalEventType.STREAM_EVENT] = AgentBackendInternalEventType.STREAM_EVENT
|
|
||||||
event_kind: str | None = None
|
|
||||||
data: JsonValue
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
|
|
||||||
"""API-internal terminal success event carrying final output and session state."""
|
|
||||||
|
|
||||||
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
|
|
||||||
output: JsonValue
|
|
||||||
session_snapshot: CompositorSessionSnapshot
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
|
|
||||||
"""API-internal resumable pause event for human handoff and Babysit flows."""
|
|
||||||
|
|
||||||
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
|
|
||||||
reason: str
|
|
||||||
message: str | None = None
|
|
||||||
session_snapshot: CompositorSessionSnapshot | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
|
||||||
"""API-internal terminal failure event carrying the backend-safe error text."""
|
|
||||||
|
|
||||||
type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED
|
|
||||||
error: str
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
|
||||||
"""API-internal terminal cancellation event."""
|
|
||||||
|
|
||||||
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
|
|
||||||
reason: str | None = None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
type AgentBackendInternalEvent = Annotated[
|
|
||||||
AgentBackendRunStartedInternalEvent
|
|
||||||
| AgentBackendStreamInternalEvent
|
|
||||||
| AgentBackendRunPausedInternalEvent
|
|
||||||
| AgentBackendRunSucceededInternalEvent
|
|
||||||
| AgentBackendRunFailedInternalEvent
|
|
||||||
| AgentBackendRunCancelledInternalEvent,
|
|
||||||
Field(discriminator="type"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunEventAdapter:
|
|
||||||
"""Maps public ``dify-agent`` event variants to API-internal event variants."""
|
|
||||||
|
|
||||||
def adapt(self, event: RunEvent) -> list[AgentBackendInternalEvent]:
|
|
||||||
"""Return zero or more API-internal events derived from one public run event."""
|
|
||||||
match event:
|
|
||||||
case RunStartedEvent():
|
|
||||||
return [
|
|
||||||
AgentBackendRunStartedInternalEvent(
|
|
||||||
run_id=event.run_id,
|
|
||||||
source_event_id=event.id,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
case PydanticAIStreamRunEvent():
|
|
||||||
data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json"))
|
|
||||||
event_kind = data.get("event_kind") if isinstance(data, dict) else None
|
|
||||||
return [
|
|
||||||
AgentBackendStreamInternalEvent(
|
|
||||||
run_id=event.run_id,
|
|
||||||
source_event_id=event.id,
|
|
||||||
event_kind=event_kind if isinstance(event_kind, str) else None,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
case RunSucceededEvent():
|
|
||||||
return [
|
|
||||||
AgentBackendRunSucceededInternalEvent(
|
|
||||||
run_id=event.run_id,
|
|
||||||
source_event_id=event.id,
|
|
||||||
output=event.data.output,
|
|
||||||
session_snapshot=event.data.session_snapshot,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
case RunPausedEvent():
|
|
||||||
return [
|
|
||||||
AgentBackendRunPausedInternalEvent(
|
|
||||||
run_id=event.run_id,
|
|
||||||
source_event_id=event.id,
|
|
||||||
reason=event.data.reason,
|
|
||||||
message=event.data.message,
|
|
||||||
session_snapshot=event.data.session_snapshot,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
case RunFailedEvent():
|
|
||||||
return [
|
|
||||||
AgentBackendRunFailedInternalEvent(
|
|
||||||
run_id=event.run_id,
|
|
||||||
source_event_id=event.id,
|
|
||||||
error=event.data.error,
|
|
||||||
reason=event.data.reason,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
case RunCancelledEvent():
|
|
||||||
return [
|
|
||||||
AgentBackendRunCancelledInternalEvent(
|
|
||||||
run_id=event.run_id,
|
|
||||||
source_event_id=event.id,
|
|
||||||
reason=event.data.reason,
|
|
||||||
message=event.data.message,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
"""Factories for API-side Agent backend clients."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dify_agent.client import Client
|
|
||||||
|
|
||||||
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
|
||||||
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
|
||||||
|
|
||||||
|
|
||||||
def create_agent_backend_run_client(
|
|
||||||
*,
|
|
||||||
base_url: str | None = None,
|
|
||||||
use_fake: bool = False,
|
|
||||||
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
|
||||||
) -> AgentBackendRunClient:
|
|
||||||
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
|
|
||||||
if use_fake:
|
|
||||||
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
|
|
||||||
if base_url is None:
|
|
||||||
raise ValueError("base_url is required when creating a real Agent backend client")
|
|
||||||
return DifyAgentBackendRunClient(Client(base_url=base_url))
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
"""Deterministic fake Agent backend client using public ``dify-agent`` events.
|
|
||||||
|
|
||||||
Tests should exercise the same ``RunEvent`` DTOs as the real HTTP client. This
|
|
||||||
fake therefore replaces the previous custom mock protocol instead of emulating a
|
|
||||||
separate ``agent-backend.v1`` event stream.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
from agenton.compositor import CompositorSessionSnapshot
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
CancelRunRequest,
|
|
||||||
CancelRunResponse,
|
|
||||||
CreateRunRequest,
|
|
||||||
CreateRunResponse,
|
|
||||||
RunEvent,
|
|
||||||
RunFailedEvent,
|
|
||||||
RunFailedEventData,
|
|
||||||
RunPausedEvent,
|
|
||||||
RunPausedEventData,
|
|
||||||
RunStartedEvent,
|
|
||||||
RunStatusResponse,
|
|
||||||
RunSucceededEvent,
|
|
||||||
RunSucceededEventData,
|
|
||||||
)
|
|
||||||
|
|
||||||
_FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeAgentBackendScenario(StrEnum):
|
|
||||||
"""Deterministic fake scenarios for API-side integration tests."""
|
|
||||||
|
|
||||||
SUCCESS = "success"
|
|
||||||
FAILED = "failed"
|
|
||||||
PAUSED = "paused"
|
|
||||||
|
|
||||||
|
|
||||||
class FakeAgentBackendRunClient:
|
|
||||||
"""In-memory implementation of ``AgentBackendRunClient`` for unit tests."""
|
|
||||||
|
|
||||||
scenario: FakeAgentBackendScenario
|
|
||||||
run_id: str
|
|
||||||
request: CreateRunRequest | None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
|
||||||
run_id: str = "fake-run-1",
|
|
||||||
) -> None:
|
|
||||||
self.scenario = scenario
|
|
||||||
self.run_id = run_id
|
|
||||||
self.request = None
|
|
||||||
|
|
||||||
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
|
||||||
"""Record the request and return a deterministic accepted response."""
|
|
||||||
self.request = request
|
|
||||||
return CreateRunResponse(run_id=self.run_id, status="running")
|
|
||||||
|
|
||||||
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
|
||||||
"""Return a deterministic cancellation response."""
|
|
||||||
del request
|
|
||||||
return CancelRunResponse(run_id=run_id, status="cancelled")
|
|
||||||
|
|
||||||
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
|
||||||
"""Yield the deterministic public ``RunEvent`` sequence for ``run_id``."""
|
|
||||||
for event in self._events(run_id):
|
|
||||||
if after is not None and event.id is not None and event.id <= after:
|
|
||||||
continue
|
|
||||||
yield event
|
|
||||||
|
|
||||||
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
|
||||||
"""Return a deterministic terminal status; timeout is accepted for protocol parity."""
|
|
||||||
del timeout_seconds
|
|
||||||
match self.scenario:
|
|
||||||
case FakeAgentBackendScenario.SUCCESS:
|
|
||||||
return RunStatusResponse(
|
|
||||||
run_id=run_id,
|
|
||||||
status="succeeded",
|
|
||||||
created_at=_FIXED_TIME,
|
|
||||||
updated_at=_FIXED_TIME,
|
|
||||||
)
|
|
||||||
case FakeAgentBackendScenario.FAILED:
|
|
||||||
return RunStatusResponse(
|
|
||||||
run_id=run_id,
|
|
||||||
status="failed",
|
|
||||||
created_at=_FIXED_TIME,
|
|
||||||
updated_at=_FIXED_TIME,
|
|
||||||
error="fake failure",
|
|
||||||
)
|
|
||||||
case FakeAgentBackendScenario.PAUSED:
|
|
||||||
return RunStatusResponse(
|
|
||||||
run_id=run_id,
|
|
||||||
status="paused",
|
|
||||||
created_at=_FIXED_TIME,
|
|
||||||
updated_at=_FIXED_TIME,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _events(self, run_id: str) -> tuple[RunEvent, ...]:
|
|
||||||
match self.scenario:
|
|
||||||
case FakeAgentBackendScenario.SUCCESS:
|
|
||||||
return (
|
|
||||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
|
||||||
RunSucceededEvent(
|
|
||||||
id="2-0",
|
|
||||||
run_id=run_id,
|
|
||||||
created_at=_FIXED_TIME,
|
|
||||||
data=RunSucceededEventData(
|
|
||||||
output={"text": "hello agent"},
|
|
||||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
case FakeAgentBackendScenario.FAILED:
|
|
||||||
return (
|
|
||||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
|
||||||
RunFailedEvent(
|
|
||||||
id="2-0",
|
|
||||||
run_id=run_id,
|
|
||||||
created_at=_FIXED_TIME,
|
|
||||||
data=RunFailedEventData(error="fake failure", reason="unit_test"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
case FakeAgentBackendScenario.PAUSED:
|
|
||||||
return (
|
|
||||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
|
||||||
RunPausedEvent(
|
|
||||||
id="2-0",
|
|
||||||
run_id=run_id,
|
|
||||||
created_at=_FIXED_TIME,
|
|
||||||
data=RunPausedEventData(
|
|
||||||
reason="human_input_required",
|
|
||||||
message="Agent requested human input.",
|
|
||||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@ -1,349 +0,0 @@
|
|||||||
"""Build ``dify-agent`` run requests from API-side product concepts.
|
|
||||||
|
|
||||||
This module is intentionally an adapter, not a wire DTO package. The emitted
|
|
||||||
object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend
|
|
||||||
protocol has a single owner. API-only context such as Agent Soul vs workflow job
|
|
||||||
prompt is preserved in layer names and metadata until the dedicated product
|
|
||||||
schemas land in later phases. Dify-owned execution identifiers are emitted as an
|
|
||||||
explicit ``dify.execution_context`` layer so the run request stays fully
|
|
||||||
composition-driven.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import ClassVar, cast
|
|
||||||
|
|
||||||
from agenton.compositor import CompositorSessionSnapshot
|
|
||||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
|
||||||
from agenton.layers import ExitIntent
|
|
||||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
|
||||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
|
|
||||||
from dify_agent.layers.dify_plugin import (
|
|
||||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
|
||||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
|
||||||
DifyPluginCredentialValue,
|
|
||||||
DifyPluginLLMLayerConfig,
|
|
||||||
DifyPluginToolsLayerConfig,
|
|
||||||
)
|
|
||||||
from dify_agent.layers.execution_context import (
|
|
||||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
|
||||||
DifyExecutionContextLayerConfig,
|
|
||||||
)
|
|
||||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
DIFY_AGENT_HISTORY_LAYER_ID,
|
|
||||||
DIFY_AGENT_MODEL_LAYER_ID,
|
|
||||||
DIFY_AGENT_OUTPUT_LAYER_ID,
|
|
||||||
CreateRunRequest,
|
|
||||||
LayerExitSignals,
|
|
||||||
RunComposition,
|
|
||||||
RunLayerSpec,
|
|
||||||
RunPurpose,
|
|
||||||
)
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
|
||||||
|
|
||||||
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
|
|
||||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
|
||||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
|
||||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
|
||||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
|
||||||
|
|
||||||
# Layer types that hold credentials in their per-run config. These are excluded
|
|
||||||
# from the cleanup-replay composition (and from the snapshot that is sent with
|
|
||||||
# the cleanup request) because we deliberately do not persist plaintext
|
|
||||||
# credentials between runs.
|
|
||||||
_CLEANUP_EXCLUDED_LAYER_TYPES: tuple[str, ...] = (
|
|
||||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
|
||||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CleanupLayerSpec(BaseModel):
|
|
||||||
"""One layer node replayed by an Agent backend cleanup-only run.
|
|
||||||
|
|
||||||
Cleanup composition cannot include credential-bearing plugin layers, so we
|
|
||||||
persist only the non-plugin layer specs together with the original config.
|
|
||||||
Storing the config (rather than just ``name``/``type``) means cleanup does
|
|
||||||
not depend on the original build-time inputs being re-derivable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
deps: dict[str, str] = Field(default_factory=dict)
|
|
||||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
|
||||||
config: JsonValue = None
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
def extract_cleanup_layer_specs(composition: RunComposition) -> list[CleanupLayerSpec]:
|
|
||||||
"""Project the in-flight composition into the persistable cleanup spec list.
|
|
||||||
|
|
||||||
Plugin layers are intentionally dropped (their configs hold credentials and
|
|
||||||
the lifecycle contract says "do not include an LLM layer" during cleanup).
|
|
||||||
The filtered names must later drive snapshot filtering so the agenton
|
|
||||||
compositor's name-order check still passes for the cleanup run.
|
|
||||||
"""
|
|
||||||
excluded = set(_CLEANUP_EXCLUDED_LAYER_TYPES)
|
|
||||||
specs: list[CleanupLayerSpec] = []
|
|
||||||
for layer in composition.layers:
|
|
||||||
if layer.type in excluded:
|
|
||||||
continue
|
|
||||||
config_value: JsonValue = None
|
|
||||||
if isinstance(layer.config, BaseModel):
|
|
||||||
config_value = layer.config.model_dump(mode="json", warnings=False)
|
|
||||||
else:
|
|
||||||
# ``RunLayerSpec.config`` is typed as ``LayerConfigInput`` which
|
|
||||||
# includes ``Mapping[str, object] | bytes``. In the cleanup-replay
|
|
||||||
# pipeline our builder only emits BaseModel-derived configs or
|
|
||||||
# ``None``, so the wider input alias narrows safely here.
|
|
||||||
config_value = cast(JsonValue, layer.config)
|
|
||||||
specs.append(
|
|
||||||
CleanupLayerSpec(
|
|
||||||
name=layer.name,
|
|
||||||
type=layer.type,
|
|
||||||
deps=dict(layer.deps),
|
|
||||||
metadata=dict(layer.metadata),
|
|
||||||
config=config_value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return specs
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_snapshot_to_specs(
|
|
||||||
snapshot: CompositorSessionSnapshot,
|
|
||||||
specs: list[CleanupLayerSpec],
|
|
||||||
) -> CompositorSessionSnapshot:
|
|
||||||
"""Keep only snapshot layers whose names appear in the cleanup spec list.
|
|
||||||
|
|
||||||
The agenton compositor rejects a snapshot whose layer-name sequence does
|
|
||||||
not match the active composition exactly. Cleanup-replay drops plugin
|
|
||||||
layers, so we must drop the matching snapshot entries here.
|
|
||||||
"""
|
|
||||||
kept_names = {spec.name for spec in specs}
|
|
||||||
filtered_layers: list[LayerSessionSnapshot] = [layer for layer in snapshot.layers if layer.name in kept_names]
|
|
||||||
if len(filtered_layers) == len(snapshot.layers):
|
|
||||||
return snapshot
|
|
||||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendModelConfig(BaseModel):
|
|
||||||
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
|
||||||
|
|
||||||
plugin_id: str
|
|
||||||
model_provider: str
|
|
||||||
model: str
|
|
||||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
|
||||||
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendOutputConfig(BaseModel):
|
|
||||||
"""API-side structured output declaration for the conventional output layer.
|
|
||||||
|
|
||||||
The structured-output tool name is fixed to ``final_output`` inside
|
|
||||||
``dify_agent.layers.output`` so callers only control the JSON Schema plus
|
|
||||||
optional description/strictness metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
json_schema: dict[str, JsonValue]
|
|
||||||
description: str | None = None
|
|
||||||
strict: bool | None = None
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendWorkflowNodeRunInput(BaseModel):
|
|
||||||
"""Inputs needed to build the first workflow-node-oriented Agent backend run request."""
|
|
||||||
|
|
||||||
model: AgentBackendModelConfig
|
|
||||||
execution_context: DifyExecutionContextLayerConfig
|
|
||||||
workflow_node_job_prompt: str
|
|
||||||
user_prompt: str
|
|
||||||
agent_soul_prompt: str | None = None
|
|
||||||
purpose: RunPurpose = "workflow_node"
|
|
||||||
idempotency_key: str | None = None
|
|
||||||
output: AgentBackendOutputConfig | None = None
|
|
||||||
tools: DifyPluginToolsLayerConfig | None = None
|
|
||||||
session_snapshot: CompositorSessionSnapshot | None = None
|
|
||||||
include_history: bool = True
|
|
||||||
suspend_on_exit: bool = True
|
|
||||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
||||||
|
|
||||||
@field_validator("workflow_node_job_prompt", "user_prompt")
|
|
||||||
@classmethod
|
|
||||||
def _reject_blank_prompt(cls, value: str) -> str:
|
|
||||||
if not value.strip():
|
|
||||||
raise ValueError("prompt must not be blank")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendRunRequestBuilder:
|
|
||||||
"""Converts API product state into the public ``dify-agent`` run protocol."""
|
|
||||||
|
|
||||||
def build_cleanup_request(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session_snapshot: CompositorSessionSnapshot,
|
|
||||||
composition_layer_specs: list[CleanupLayerSpec],
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
metadata: dict[str, JsonValue] | None = None,
|
|
||||||
) -> CreateRunRequest:
|
|
||||||
"""Build a lifecycle-only cleanup request that replays the prior layers.
|
|
||||||
|
|
||||||
The agenton compositor enforces that the session snapshot's layer names
|
|
||||||
match the active composition in order, so cleanup must replay the same
|
|
||||||
non-plugin layer graph that produced the snapshot. Plugin layers
|
|
||||||
(``dify.plugin.llm``, ``dify.plugin.tools``) are excluded from both the
|
|
||||||
composition and the snapshot before submission because their configs
|
|
||||||
require credentials that are not persisted between runs.
|
|
||||||
"""
|
|
||||||
if not composition_layer_specs:
|
|
||||||
raise ValueError(
|
|
||||||
"build_cleanup_request requires composition_layer_specs; an empty "
|
|
||||||
"composition would fail the agent backend's snapshot validation."
|
|
||||||
)
|
|
||||||
request_metadata = dict(metadata or {})
|
|
||||||
request_metadata["agent_backend_lifecycle"] = "session_cleanup"
|
|
||||||
layers = [
|
|
||||||
RunLayerSpec(
|
|
||||||
name=spec.name,
|
|
||||||
type=spec.type,
|
|
||||||
deps=dict(spec.deps),
|
|
||||||
metadata=dict(spec.metadata),
|
|
||||||
config=spec.config,
|
|
||||||
)
|
|
||||||
for spec in composition_layer_specs
|
|
||||||
]
|
|
||||||
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, composition_layer_specs)
|
|
||||||
return CreateRunRequest(
|
|
||||||
composition=RunComposition(layers=layers),
|
|
||||||
purpose="workflow_node",
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
metadata=request_metadata,
|
|
||||||
session_snapshot=filtered_snapshot,
|
|
||||||
on_exit=LayerExitSignals(default=ExitIntent.DELETE),
|
|
||||||
)
|
|
||||||
|
|
||||||
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
|
|
||||||
"""Build a workflow Agent Node run request without defining another wire schema."""
|
|
||||||
layers: list[RunLayerSpec] = []
|
|
||||||
if run_input.agent_soul_prompt:
|
|
||||||
layers.append(
|
|
||||||
RunLayerSpec(
|
|
||||||
name=AGENT_SOUL_PROMPT_LAYER_ID,
|
|
||||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
|
||||||
metadata={**run_input.metadata, "origin": "agent_soul"},
|
|
||||||
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
layers.extend(
|
|
||||||
[
|
|
||||||
RunLayerSpec(
|
|
||||||
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
|
||||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
|
||||||
metadata={**run_input.metadata, "origin": "workflow_node_job"},
|
|
||||||
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
|
|
||||||
),
|
|
||||||
RunLayerSpec(
|
|
||||||
name=WORKFLOW_USER_PROMPT_LAYER_ID,
|
|
||||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
|
||||||
metadata={**run_input.metadata, "origin": "workflow_user_prompt"},
|
|
||||||
config=PromptLayerConfig(user=run_input.user_prompt),
|
|
||||||
),
|
|
||||||
RunLayerSpec(
|
|
||||||
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
|
||||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
|
||||||
metadata=run_input.metadata,
|
|
||||||
config=run_input.execution_context,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if run_input.include_history:
|
|
||||||
layers.append(
|
|
||||||
RunLayerSpec(
|
|
||||||
name=DIFY_AGENT_HISTORY_LAYER_ID,
|
|
||||||
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
|
|
||||||
metadata={**run_input.metadata, "origin": "agent_session_history"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
layers.extend(
|
|
||||||
[
|
|
||||||
RunLayerSpec(
|
|
||||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
|
||||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
|
||||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
|
||||||
metadata=run_input.metadata,
|
|
||||||
config=DifyPluginLLMLayerConfig(
|
|
||||||
plugin_id=run_input.model.plugin_id,
|
|
||||||
model_provider=run_input.model.model_provider,
|
|
||||||
model=run_input.model.model,
|
|
||||||
credentials=run_input.model.credentials,
|
|
||||||
model_settings=run_input.model.model_settings or None,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if run_input.tools is not None and run_input.tools.tools:
|
|
||||||
layers.append(
|
|
||||||
RunLayerSpec(
|
|
||||||
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
|
|
||||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
|
||||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
|
||||||
metadata=run_input.metadata,
|
|
||||||
config=run_input.tools,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if run_input.output is not None:
|
|
||||||
layers.append(
|
|
||||||
RunLayerSpec(
|
|
||||||
name=DIFY_AGENT_OUTPUT_LAYER_ID,
|
|
||||||
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
|
||||||
metadata=run_input.metadata,
|
|
||||||
config=DifyOutputLayerConfig(
|
|
||||||
json_schema=run_input.output.json_schema,
|
|
||||||
description=run_input.output.description,
|
|
||||||
strict=run_input.output.strict,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return CreateRunRequest(
|
|
||||||
composition=RunComposition(layers=layers),
|
|
||||||
purpose=run_input.purpose,
|
|
||||||
idempotency_key=run_input.idempotency_key,
|
|
||||||
metadata=run_input.metadata,
|
|
||||||
session_snapshot=run_input.session_snapshot,
|
|
||||||
on_exit=LayerExitSignals(
|
|
||||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key")
|
|
||||||
|
|
||||||
|
|
||||||
def redact_for_agent_backend_log(value: object) -> object:
|
|
||||||
"""Return a JSON-like copy with credential-bearing keys redacted for logs/tests."""
|
|
||||||
if isinstance(value, BaseModel):
|
|
||||||
return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False))
|
|
||||||
if isinstance(value, dict):
|
|
||||||
redacted: dict[object, object] = {}
|
|
||||||
for key, item in value.items():
|
|
||||||
key_text = str(key).lower()
|
|
||||||
if any(part in key_text for part in _SENSITIVE_KEY_PARTS):
|
|
||||||
redacted[key] = "[REDACTED]"
|
|
||||||
else:
|
|
||||||
redacted[key] = redact_for_agent_backend_log(item)
|
|
||||||
return redacted
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [redact_for_agent_backend_log(item) for item in value]
|
|
||||||
return value
|
|
||||||
@ -3,13 +3,6 @@ CLI command modules extracted from `commands.py`.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .account import create_tenant, reset_email, reset_password
|
from .account import create_tenant, reset_email, reset_password
|
||||||
from .data_migrate import data_migrate, legacy_model_types
|
|
||||||
from .data_migration import (
|
|
||||||
export_migration_data,
|
|
||||||
export_migration_data_template,
|
|
||||||
import_migration_data,
|
|
||||||
migration_data_wizard,
|
|
||||||
)
|
|
||||||
from .plugin import (
|
from .plugin import (
|
||||||
extract_plugins,
|
extract_plugins,
|
||||||
extract_unique_plugins,
|
extract_unique_plugins,
|
||||||
@ -32,12 +25,7 @@ from .retention import (
|
|||||||
restore_workflow_runs,
|
restore_workflow_runs,
|
||||||
)
|
)
|
||||||
from .storage import clear_orphaned_file_records, file_usage, migrate_oss, remove_orphaned_files_on_storage
|
from .storage import clear_orphaned_file_records, file_usage, migrate_oss, remove_orphaned_files_on_storage
|
||||||
from .system import (
|
from .system import convert_to_agent_apps, fix_app_site_missing, reset_encrypt_key_pair, upgrade_db
|
||||||
convert_to_agent_apps,
|
|
||||||
fix_app_site_missing,
|
|
||||||
reset_encrypt_key_pair,
|
|
||||||
upgrade_db,
|
|
||||||
)
|
|
||||||
from .vector import (
|
from .vector import (
|
||||||
add_qdrant_index,
|
add_qdrant_index,
|
||||||
migrate_annotation_vector_database,
|
migrate_annotation_vector_database,
|
||||||
@ -56,24 +44,18 @@ __all__ = [
|
|||||||
"clear_orphaned_file_records",
|
"clear_orphaned_file_records",
|
||||||
"convert_to_agent_apps",
|
"convert_to_agent_apps",
|
||||||
"create_tenant",
|
"create_tenant",
|
||||||
"data_migrate",
|
|
||||||
"delete_archived_workflow_runs",
|
"delete_archived_workflow_runs",
|
||||||
"export_app_messages",
|
"export_app_messages",
|
||||||
"export_migration_data",
|
|
||||||
"export_migration_data_template",
|
|
||||||
"extract_plugins",
|
"extract_plugins",
|
||||||
"extract_unique_plugins",
|
"extract_unique_plugins",
|
||||||
"file_usage",
|
"file_usage",
|
||||||
"fix_app_site_missing",
|
"fix_app_site_missing",
|
||||||
"import_migration_data",
|
|
||||||
"install_plugins",
|
"install_plugins",
|
||||||
"install_rag_pipeline_plugins",
|
"install_rag_pipeline_plugins",
|
||||||
"legacy_model_types",
|
|
||||||
"migrate_annotation_vector_database",
|
"migrate_annotation_vector_database",
|
||||||
"migrate_data_for_plugin",
|
"migrate_data_for_plugin",
|
||||||
"migrate_knowledge_vector_database",
|
"migrate_knowledge_vector_database",
|
||||||
"migrate_oss",
|
"migrate_oss",
|
||||||
"migration_data_wizard",
|
|
||||||
"old_metadata_migration",
|
"old_metadata_migration",
|
||||||
"remove_orphaned_files_on_storage",
|
"remove_orphaned_files_on_storage",
|
||||||
"reset_email",
|
"reset_email",
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import base64
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from constants.languages import languages
|
from constants.languages import languages
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
@ -44,11 +43,10 @@ def reset_password(email, new_password, password_confirm):
|
|||||||
# encrypt password with salt
|
# encrypt password with salt
|
||||||
password_hashed = hash_password(new_password, salt)
|
password_hashed = hash_password(new_password, salt)
|
||||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||||
with Session(db.engine) as session:
|
account = db.session.merge(account)
|
||||||
account = session.merge(account)
|
|
||||||
account.password = base64_password_hashed
|
account.password = base64_password_hashed
|
||||||
account.password_salt = base64_salt
|
account.password_salt = base64_salt
|
||||||
session.commit()
|
db.session.commit()
|
||||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||||
click.echo(click.style("Password reset successfully.", fg="green"))
|
click.echo(click.style("Password reset successfully.", fg="green"))
|
||||||
|
|
||||||
@ -79,10 +77,9 @@ def reset_email(email, new_email, email_confirm):
|
|||||||
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
|
||||||
return
|
return
|
||||||
|
|
||||||
with Session(db.engine) as session:
|
account = db.session.merge(account)
|
||||||
account = session.merge(account)
|
|
||||||
account.email = normalized_new_email
|
account.email = normalized_new_email
|
||||||
session.commit()
|
db.session.commit()
|
||||||
click.echo(click.style("Email updated successfully.", fg="green"))
|
click.echo(click.style("Email updated successfully.", fg="green"))
|
||||||
|
|
||||||
|
|
||||||
@ -113,18 +110,8 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
|
|||||||
# Validates name encoding for non-Latin characters.
|
# Validates name encoding for non-Latin characters.
|
||||||
name = name.strip().encode("utf-8").decode("utf-8") if name else None
|
name = name.strip().encode("utf-8").decode("utf-8") if name else None
|
||||||
|
|
||||||
# Generate a random password that satisfies the password policy.
|
# generate random password
|
||||||
# The iteration limit guards against infinite loops caused by unexpected bugs in valid_password.
|
|
||||||
for _ in range(100):
|
|
||||||
new_password = secrets.token_urlsafe(16)
|
new_password = secrets.token_urlsafe(16)
|
||||||
try:
|
|
||||||
valid_password(new_password)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
click.echo(click.style("Failed to generate a valid password. Please try again.", fg="red"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# register account
|
# register account
|
||||||
account = RegisterService.register(
|
account = RegisterService.register(
|
||||||
|
|||||||
@ -1,179 +0,0 @@
|
|||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from contextlib import AbstractContextManager, nullcontext
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from extensions.ext_database import db
|
|
||||||
from graphon.model_runtime.entities.model_entities import ModelType
|
|
||||||
from services.legacy_model_type_migration import (
|
|
||||||
VALID_TABLE_NAMES,
|
|
||||||
LegacyModelTypeMigrationService,
|
|
||||||
load_tenant_ids_from_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
_SUPPORTED_MODEL_TYPE_CHOICES = (
|
|
||||||
ModelType.LLM.value,
|
|
||||||
ModelType.TEXT_EMBEDDING.value,
|
|
||||||
ModelType.RERANK.value,
|
|
||||||
)
|
|
||||||
_DEFAULT_CONCURRENCY = os.cpu_count() or 1
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_multi_value_option(
|
|
||||||
values: tuple[str, ...],
|
|
||||||
*,
|
|
||||||
valid_values: tuple[str, ...],
|
|
||||||
option_name: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
normalized_values: list[str] = []
|
|
||||||
seen_values: set[str] = set()
|
|
||||||
|
|
||||||
for value in values:
|
|
||||||
for item in value.split(","):
|
|
||||||
normalized_item = item.strip()
|
|
||||||
if not normalized_item:
|
|
||||||
continue
|
|
||||||
if normalized_item not in valid_values:
|
|
||||||
raise click.BadParameter(
|
|
||||||
f"invalid value '{normalized_item}'. valid values: {', '.join(valid_values)}",
|
|
||||||
param_hint=option_name,
|
|
||||||
)
|
|
||||||
if normalized_item in seen_values:
|
|
||||||
continue
|
|
||||||
seen_values.add(normalized_item)
|
|
||||||
normalized_values.append(normalized_item)
|
|
||||||
|
|
||||||
return tuple(normalized_values)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group(
|
|
||||||
"data-migrate",
|
|
||||||
help="Online data migration commands.",
|
|
||||||
)
|
|
||||||
def data_migrate() -> None:
|
|
||||||
"""Namespace for production data migration commands."""
|
|
||||||
|
|
||||||
|
|
||||||
@click.command(
|
|
||||||
"legacy-model-types",
|
|
||||||
help=(
|
|
||||||
"Migrate legacy provider model_type values to canonical values. "
|
|
||||||
"Default is dry-run and emits JSON lines only. "
|
|
||||||
"If --tables includes provider_model_credentials, the command may also update "
|
|
||||||
"provider_models and load_balancing_model_configs references so merged credentials stay reachable."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--apply",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Apply the migration. Default is dry-run.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--tables",
|
|
||||||
"tables",
|
|
||||||
multiple=True,
|
|
||||||
type=str,
|
|
||||||
help=(
|
|
||||||
"Limit migration to specific tables. Accepts comma-separated values or repeated flags.\n"
|
|
||||||
"\n"
|
|
||||||
"Options: load_balancing_model_configs, provider_model_credentials, "
|
|
||||||
"provider_model_settings, provider_models, tenant_default_models.\n\n"
|
|
||||||
"When provider_model_credentials is selected, provider_models and "
|
|
||||||
"load_balancing_model_configs may also be updated for credential reference rewrites.\n"
|
|
||||||
"\n"
|
|
||||||
"If unspecified, all relevant tables are migrated."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--model-types",
|
|
||||||
"model_types",
|
|
||||||
multiple=True,
|
|
||||||
type=str,
|
|
||||||
help=(
|
|
||||||
"Canonical model types to migrate. Accepts comma-separated values or repeated flags.\n"
|
|
||||||
"\n"
|
|
||||||
"Options: llm,text-embedding,rerank\n"
|
|
||||||
"\n"
|
|
||||||
"If unspecified, all relevant legacy model types are migrated."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--tenant-id-file",
|
|
||||||
type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
|
|
||||||
help="Optional file containing tenant ids, one per line.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--output",
|
|
||||||
type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
|
|
||||||
help=(
|
|
||||||
"Optional file path for JSON lines event logs. Defaults to stdout.\n"
|
|
||||||
"It's highly recommended to save the event logs to a file and preserve it for a period of time."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--concurrency",
|
|
||||||
type=click.IntRange(min=1),
|
|
||||||
default=_DEFAULT_CONCURRENCY,
|
|
||||||
show_default=True,
|
|
||||||
help="Number of tenant-level worker threads to run in parallel.",
|
|
||||||
)
|
|
||||||
def legacy_model_types(
|
|
||||||
apply: bool,
|
|
||||||
tables: tuple[str, ...],
|
|
||||||
model_types: tuple[str, ...],
|
|
||||||
tenant_id_file: str | None,
|
|
||||||
output: Path | None,
|
|
||||||
concurrency: int = _DEFAULT_CONCURRENCY,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Migrate legacy provider-related model_type values and emit JSON lines events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
normalized_tables = _normalize_multi_value_option(
|
|
||||||
tables,
|
|
||||||
valid_values=VALID_TABLE_NAMES,
|
|
||||||
option_name="--tables",
|
|
||||||
)
|
|
||||||
normalized_model_types = _normalize_multi_value_option(
|
|
||||||
model_types,
|
|
||||||
valid_values=_SUPPORTED_MODEL_TYPE_CHOICES,
|
|
||||||
option_name="--model-types",
|
|
||||||
)
|
|
||||||
selected_model_types = (
|
|
||||||
tuple(ModelType.value_of(model_type) for model_type in normalized_model_types)
|
|
||||||
if normalized_model_types
|
|
||||||
else (
|
|
||||||
ModelType.LLM,
|
|
||||||
ModelType.TEXT_EMBEDDING,
|
|
||||||
ModelType.RERANK,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tenant_ids = load_tenant_ids_from_file(tenant_id_file) if tenant_id_file else None
|
|
||||||
|
|
||||||
output_context: AbstractContextManager[io.TextIOBase]
|
|
||||||
if output is None:
|
|
||||||
output_context = nullcontext(cast(io.TextIOBase, sys.stdout))
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
output_context = output.open("w", encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
raise click.ClickException(f"failed to open output file '{output}': {exc.strerror or exc}") from exc
|
|
||||||
|
|
||||||
with output_context as output_stream:
|
|
||||||
LegacyModelTypeMigrationService(
|
|
||||||
engine=db.engine,
|
|
||||||
apply=apply,
|
|
||||||
concurrency=concurrency,
|
|
||||||
output=cast(io.TextIOBase, output_stream),
|
|
||||||
tables=normalized_tables or None,
|
|
||||||
model_types=selected_model_types,
|
|
||||||
tenant_ids=tenant_ids,
|
|
||||||
).migrate()
|
|
||||||
|
|
||||||
|
|
||||||
data_migrate.add_command(legacy_model_types)
|
|
||||||
@ -1,754 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import click
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from extensions.ext_database import db
|
|
||||||
from models import Tenant
|
|
||||||
from models.model import App
|
|
||||||
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
|
|
||||||
from services.app_dsl_service import AppDslService
|
|
||||||
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
|
|
||||||
from services.data_migration.entities import (
|
|
||||||
DependencyKind,
|
|
||||||
ImportOptions,
|
|
||||||
MigrationDataError,
|
|
||||||
ReportContext,
|
|
||||||
ResourceReportItem,
|
|
||||||
)
|
|
||||||
from services.data_migration.export_service import ExportConfigParser, MigrationExportService
|
|
||||||
from services.data_migration.import_service import ImportRequest, MigrationImportService
|
|
||||||
from services.data_migration.package_service import MigrationPackageService
|
|
||||||
from services.data_migration.report_service import MigrationReportService
|
|
||||||
|
|
||||||
ID_STRATEGY_CHOICES = ["preserve-id", "generate-new-id"]
|
|
||||||
CONFLICT_STRATEGY_CHOICES = ["fail", "skip", "update"]
|
|
||||||
SUPPORTED_WIZARD_APP_MODES = ["workflow", "advanced-chat"]
|
|
||||||
WizardToolMap = dict[str, dict[str, str | None]]
|
|
||||||
WizardToolSelection = dict[str, list[str]]
|
|
||||||
|
|
||||||
|
|
||||||
def _scripted_export_template() -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"source_tenant": {
|
|
||||||
"mode": "single",
|
|
||||||
"id": "",
|
|
||||||
"name": "admin's Workspace",
|
|
||||||
},
|
|
||||||
"apps": {
|
|
||||||
"modes": ["workflow", "advanced-chat"],
|
|
||||||
"ids": [],
|
|
||||||
"all": True,
|
|
||||||
},
|
|
||||||
"include_referenced_tools": True,
|
|
||||||
"additional_tools": {
|
|
||||||
"api_tools": [],
|
|
||||||
"workflow_tools": [],
|
|
||||||
"mcp_tools": [],
|
|
||||||
},
|
|
||||||
"include_secrets": False,
|
|
||||||
"import_options": {
|
|
||||||
"create_app_api_token_on_import": False,
|
|
||||||
"id_strategy": "preserve-id",
|
|
||||||
"conflict_strategy": "fail",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("app-migration-template", help="Print or write a scripted export config JSON template.")
|
|
||||||
@click.option(
|
|
||||||
"--output",
|
|
||||||
"output_file",
|
|
||||||
required=False,
|
|
||||||
type=click.Path(dir_okay=False),
|
|
||||||
help="Path to write the export config JSON template. Prints to stdout when omitted.",
|
|
||||||
)
|
|
||||||
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite output if it already exists.")
|
|
||||||
def export_migration_data_template(output_file: str | None, overwrite: bool) -> None:
|
|
||||||
template_json = json.dumps(_scripted_export_template(), indent=2, ensure_ascii=False) + "\n"
|
|
||||||
if output_file is None:
|
|
||||||
click.echo(template_json, nl=False)
|
|
||||||
return
|
|
||||||
path = Path(output_file)
|
|
||||||
if path.exists() and not overwrite:
|
|
||||||
raise click.ClickException(f"Output file already exists: {output_file}")
|
|
||||||
path.write_text(template_json)
|
|
||||||
click.echo(click.style(f"Output written to {output_file}", fg="green"))
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("export-app-migration", help="Export workflow migration data to a versioned JSON package.")
|
|
||||||
@click.option(
|
|
||||||
"--input",
|
|
||||||
"input_file",
|
|
||||||
required=False,
|
|
||||||
type=click.Path(exists=True, dir_okay=False),
|
|
||||||
help="Path to export config JSON.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--output",
|
|
||||||
"output_file",
|
|
||||||
required=False,
|
|
||||||
type=click.Path(dir_okay=False),
|
|
||||||
help="Path to migration package JSON.",
|
|
||||||
)
|
|
||||||
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite output if it already exists.")
|
|
||||||
def export_migration_data(input_file: str | None, output_file: str | None, overwrite: bool) -> None:
|
|
||||||
try:
|
|
||||||
_require_options(("--input", input_file), ("--output", output_file))
|
|
||||||
assert input_file is not None
|
|
||||||
assert output_file is not None
|
|
||||||
raw_config = _load_json_object(input_file, "Export config")
|
|
||||||
selection = ExportConfigParser().parse(raw_config)
|
|
||||||
result = MigrationExportService().export(selection)
|
|
||||||
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
|
|
||||||
click.echo(click.style(f"Output written to {output_file}", fg="green"))
|
|
||||||
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
|
|
||||||
except MigrationDataError as exc:
|
|
||||||
raise click.ClickException(str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("import-app-migration", help="Import a versioned migration data package.")
|
|
||||||
@click.option(
|
|
||||||
"--input",
|
|
||||||
"input_file",
|
|
||||||
required=False,
|
|
||||||
type=click.Path(exists=True, dir_okay=False),
|
|
||||||
help="Path to migration package JSON.",
|
|
||||||
)
|
|
||||||
@click.option("--target-tenant", default=None, help="Target tenant/workspace name. Overrides package metadata.")
|
|
||||||
@click.option("--operator-email", default=None, help="Operator account email in the target tenant.")
|
|
||||||
@click.option(
|
|
||||||
"--id-strategy",
|
|
||||||
default=None,
|
|
||||||
type=click.Choice(ID_STRATEGY_CHOICES),
|
|
||||||
help="Override package ID strategy.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--conflict-strategy",
|
|
||||||
default=None,
|
|
||||||
type=click.Choice(CONFLICT_STRATEGY_CHOICES),
|
|
||||||
help="Override package conflict strategy.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--create-app-api-token-on-import/--no-create-app-api-token-on-import",
|
|
||||||
default=None,
|
|
||||||
help="Override package app API token creation behavior.",
|
|
||||||
)
|
|
||||||
def import_migration_data(
|
|
||||||
input_file: str | None,
|
|
||||||
target_tenant: str | None,
|
|
||||||
operator_email: str | None,
|
|
||||||
id_strategy: str | None,
|
|
||||||
conflict_strategy: str | None,
|
|
||||||
create_app_api_token_on_import: bool | None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
_require_options(("--input", input_file))
|
|
||||||
assert input_file is not None
|
|
||||||
package = MigrationPackageService().load_package(input_file)
|
|
||||||
result = MigrationImportService().import_package(
|
|
||||||
ImportRequest(
|
|
||||||
package=package,
|
|
||||||
cli_target_tenant=target_tenant,
|
|
||||||
operator_email=operator_email,
|
|
||||||
options_override=_build_options_override(
|
|
||||||
package.metadata.import_options,
|
|
||||||
id_strategy=id_strategy,
|
|
||||||
conflict_strategy=conflict_strategy,
|
|
||||||
create_app_api_token_on_import=create_app_api_token_on_import,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_render_report(result.report_items, context=result.report_context)
|
|
||||||
except MigrationDataError as exc:
|
|
||||||
raise click.ClickException(str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def parse_index_selection(raw: str, values: list[str]) -> list[str]:
|
|
||||||
normalized = raw.strip().lower()
|
|
||||||
if normalized == "all":
|
|
||||||
return values
|
|
||||||
|
|
||||||
selected: list[str] = []
|
|
||||||
for part in raw.split(","):
|
|
||||||
stripped = part.strip()
|
|
||||||
if not stripped:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
index = int(stripped)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise click.ClickException(f"Selection must be 'all' or comma-separated numbers: {raw}") from exc
|
|
||||||
if index < 1 or index > len(values):
|
|
||||||
raise click.ClickException(f"Selection index out of range: {index}")
|
|
||||||
selected.append(values[index - 1])
|
|
||||||
return list(dict.fromkeys(selected))
|
|
||||||
|
|
||||||
|
|
||||||
def _print_wizard_step(title: str) -> None:
|
|
||||||
click.echo("")
|
|
||||||
click.echo(f"==== {title} ====")
|
|
||||||
|
|
||||||
|
|
||||||
def _print_wizard_substep(title: str) -> None:
|
|
||||||
click.echo("")
|
|
||||||
click.echo(f"-- {title} --")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("app-migration-wizard", help="Interactively export workflow migration data.")
|
|
||||||
def migration_data_wizard() -> None:
|
|
||||||
try:
|
|
||||||
tenant = _prompt_source_tenant()
|
|
||||||
apps = _eligible_apps_for_tenant(tenant.id)
|
|
||||||
app_ids = _prompt_app_ids(apps)
|
|
||||||
_print_wizard_step("Referenced Tools")
|
|
||||||
include_referenced_tools = click.confirm(
|
|
||||||
"Automatically export tools referenced by selected apps? [y/n, default: y]",
|
|
||||||
default=True,
|
|
||||||
show_default=False,
|
|
||||||
)
|
|
||||||
auto_tools = _discover_auto_tools([app for app in apps if app.id in set(app_ids)], include_referenced_tools)
|
|
||||||
auto_tools = _resolve_auto_tool_names(tenant.id, auto_tools)
|
|
||||||
_print_auto_tools(auto_tools)
|
|
||||||
additional_tools = _prompt_additional_tools(tenant.id, auto_tools)
|
|
||||||
include_secrets, create_tokens, id_strategy, conflict_strategy = _prompt_import_options()
|
|
||||||
_print_wizard_step("Output")
|
|
||||||
output_file, overwrite = _prompt_output_file()
|
|
||||||
|
|
||||||
selection = ExportConfigParser().parse(
|
|
||||||
{
|
|
||||||
"source_tenant": {"mode": "single", "id": tenant.id, "name": tenant.name},
|
|
||||||
"apps": {"ids": app_ids, "all": False},
|
|
||||||
"include_referenced_tools": include_referenced_tools,
|
|
||||||
"additional_tools": additional_tools,
|
|
||||||
"include_secrets": include_secrets,
|
|
||||||
"import_options": {
|
|
||||||
"create_app_api_token_on_import": create_tokens,
|
|
||||||
"id_strategy": id_strategy,
|
|
||||||
"conflict_strategy": conflict_strategy,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_confirm_wizard_summary(
|
|
||||||
tenant_name=tenant.name,
|
|
||||||
app_names=[app.name for app in apps if app.id in set(app_ids)],
|
|
||||||
auto_tools=auto_tools,
|
|
||||||
additional_tools=additional_tools,
|
|
||||||
manual_labels=_selected_tool_labels_for_tenant(tenant.id, additional_tools),
|
|
||||||
include_referenced_tools=include_referenced_tools,
|
|
||||||
include_secrets=include_secrets,
|
|
||||||
create_tokens=create_tokens,
|
|
||||||
id_strategy=id_strategy,
|
|
||||||
conflict_strategy=conflict_strategy,
|
|
||||||
output_file=output_file,
|
|
||||||
)
|
|
||||||
result = MigrationExportService().export(selection)
|
|
||||||
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
|
|
||||||
click.echo(click.style(f"Output written to {output_file}", fg="green"))
|
|
||||||
_print_wizard_step("Report")
|
|
||||||
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
|
|
||||||
except MigrationDataError as exc:
|
|
||||||
raise click.ClickException(str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _load_json_object(path: str, label: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
with Path(path).open(encoding="utf-8") as file:
|
|
||||||
raw = json.load(file)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise MigrationDataError(f"{label} JSON is invalid: {exc.msg}") from exc
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise MigrationDataError(f"{label} JSON must be an object.")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def _require_options(*options: tuple[str, object | None]) -> None:
|
|
||||||
missing_options = [name for name, value in options if value is None]
|
|
||||||
if missing_options:
|
|
||||||
raise click.UsageError(f"Missing option(s): {', '.join(missing_options)}.")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_options_override(
|
|
||||||
package_options: ImportOptions,
|
|
||||||
*,
|
|
||||||
id_strategy: str | None,
|
|
||||||
conflict_strategy: str | None,
|
|
||||||
create_app_api_token_on_import: bool | None,
|
|
||||||
) -> ImportOptions | None:
|
|
||||||
if id_strategy is None and conflict_strategy is None and create_app_api_token_on_import is None:
|
|
||||||
return None
|
|
||||||
return ImportOptions.from_mapping(
|
|
||||||
{
|
|
||||||
"id_strategy": id_strategy or package_options.id_strategy,
|
|
||||||
"conflict_strategy": conflict_strategy or package_options.conflict_strategy,
|
|
||||||
"create_app_api_token_on_import": (
|
|
||||||
create_app_api_token_on_import
|
|
||||||
if create_app_api_token_on_import is not None
|
|
||||||
else package_options.create_app_api_token_on_import
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_source_tenant() -> Tenant:
|
|
||||||
tenants = list(db.session.scalars(sa.select(Tenant).order_by(Tenant.name.asc())).all())
|
|
||||||
if not tenants:
|
|
||||||
raise MigrationDataError("No tenants found.")
|
|
||||||
|
|
||||||
_print_wizard_step("Source Tenant")
|
|
||||||
click.echo("Source tenants:")
|
|
||||||
for index, tenant in enumerate(tenants, 1):
|
|
||||||
click.echo(f"{index}. {tenant.name} ({tenant.id})")
|
|
||||||
|
|
||||||
tenant_index = click.prompt("Select one source tenant by number", type=int, default=1, show_default=True)
|
|
||||||
if tenant_index < 1 or tenant_index > len(tenants):
|
|
||||||
raise click.ClickException(f"Selection index out of range: {tenant_index}")
|
|
||||||
return tenants[tenant_index - 1]
|
|
||||||
|
|
||||||
|
|
||||||
def _eligible_apps_for_tenant(tenant_id: str) -> list[App]:
|
|
||||||
return list(
|
|
||||||
db.session.scalars(
|
|
||||||
sa.select(App)
|
|
||||||
.where(App.tenant_id == tenant_id, App.mode.in_(SUPPORTED_WIZARD_APP_MODES))
|
|
||||||
.order_by(App.name.asc())
|
|
||||||
).all()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_app_ids(apps: list[App]) -> list[str]:
|
|
||||||
if not apps:
|
|
||||||
raise MigrationDataError("No workflow or advanced-chat apps found for the selected tenant.")
|
|
||||||
|
|
||||||
_print_wizard_step("App Selection")
|
|
||||||
click.echo("Currently supported app types: workflow and chatflow.")
|
|
||||||
click.echo("Workflow/chatflow apps:")
|
|
||||||
for index, app in enumerate(apps, 1):
|
|
||||||
mode = app.mode.value if hasattr(app.mode, "value") else app.mode
|
|
||||||
click.echo(f"{index}. {app.name} [{mode}] ({app.id})")
|
|
||||||
app_ids = parse_index_selection(
|
|
||||||
click.prompt("Select apps by number, comma-separated numbers, or all", default="all"),
|
|
||||||
[app.id for app in apps],
|
|
||||||
)
|
|
||||||
selected_apps = [app for app in apps if app.id in set(app_ids)]
|
|
||||||
click.echo("Selected apps:")
|
|
||||||
for app in selected_apps:
|
|
||||||
click.echo(f"- {app.name} ({app.id})")
|
|
||||||
return app_ids
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_import_options() -> tuple[bool, bool, str, str]:
|
|
||||||
_print_wizard_step("Import Options")
|
|
||||||
_print_wizard_substep("Secrets")
|
|
||||||
click.echo("Secrets include workflow/app DSL secret values, custom API tool credentials,")
|
|
||||||
click.echo("and full MCP provider connection data such as server URL, headers, authentication, and tool list.")
|
|
||||||
click.echo("If you choose no, credentials are omitted or masked,")
|
|
||||||
click.echo("and MCP providers are exported as dependency metadata only.")
|
|
||||||
click.echo("Treat the output JSON as sensitive if you choose yes.")
|
|
||||||
include_secrets = click.confirm(
|
|
||||||
"Include secrets in output JSON? [y/n, default: n]",
|
|
||||||
default=False,
|
|
||||||
show_default=False,
|
|
||||||
)
|
|
||||||
_print_wizard_substep("App API Tokens")
|
|
||||||
click.echo("When enabled, import will create an app API token if the imported app has none,")
|
|
||||||
click.echo("or reuse an existing app API token if one already exists.")
|
|
||||||
create_tokens = click.confirm(
|
|
||||||
"Create or reuse app API tokens during import? [y/n, default: n]",
|
|
||||||
default=False,
|
|
||||||
show_default=False,
|
|
||||||
)
|
|
||||||
_print_wizard_substep("ID Strategy")
|
|
||||||
click.echo("ID strategy controls whether imported app and tool IDs preserve source IDs")
|
|
||||||
click.echo("or use target-generated IDs.")
|
|
||||||
click.echo("preserve-id: keep source IDs where the target service supports it.")
|
|
||||||
click.echo("generate-new-id: let the target environment generate new IDs and rewrite references via mapping.")
|
|
||||||
id_strategy = click.prompt(
|
|
||||||
"Import ID strategy. Enter one of: preserve-id, generate-new-id",
|
|
||||||
type=click.Choice(ID_STRATEGY_CHOICES),
|
|
||||||
default="preserve-id",
|
|
||||||
show_default=True,
|
|
||||||
)
|
|
||||||
_print_wizard_substep("Conflict Strategy")
|
|
||||||
click.echo("Conflict strategy controls what import does when a target resource already exists.")
|
|
||||||
click.echo("fail: stop at the first conflict; previously committed resources are not rolled back.")
|
|
||||||
click.echo("skip: keep the existing target resource and skip importing that resource.")
|
|
||||||
click.echo("update: update the existing target resource in place.")
|
|
||||||
conflict_strategy = click.prompt(
|
|
||||||
"Import conflict strategy. Enter one of: fail, skip, update",
|
|
||||||
type=click.Choice(CONFLICT_STRATEGY_CHOICES),
|
|
||||||
default="update",
|
|
||||||
show_default=True,
|
|
||||||
)
|
|
||||||
return include_secrets, create_tokens, id_strategy, conflict_strategy
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_auto_tools(apps: list[App], include_referenced_tools: bool) -> WizardToolMap:
|
|
||||||
auto_tools: WizardToolMap = {"api_tools": {}, "workflow_tools": {}, "mcp_tools": {}}
|
|
||||||
if not include_referenced_tools:
|
|
||||||
return auto_tools
|
|
||||||
discovery_service = DependencyDiscoveryService()
|
|
||||||
for app in apps:
|
|
||||||
dsl_content = AppDslService.export_dsl(app_model=app, include_secret=False)
|
|
||||||
raw_dsl = yaml.safe_load(dsl_content) if dsl_content else {}
|
|
||||||
dsl = raw_dsl if isinstance(raw_dsl, dict) else {}
|
|
||||||
for dependency in discovery_service.discover_from_dsl(dsl):
|
|
||||||
if dependency.kind == DependencyKind.API_TOOL:
|
|
||||||
auto_tools["api_tools"][dependency.provider_name or dependency.provider_id] = dependency.provider_id
|
|
||||||
elif dependency.kind == DependencyKind.WORKFLOW_TOOL:
|
|
||||||
auto_tools["workflow_tools"][dependency.provider_name or dependency.provider_id] = (
|
|
||||||
dependency.provider_id
|
|
||||||
)
|
|
||||||
elif dependency.kind == DependencyKind.MCP_TOOL:
|
|
||||||
auto_tools["mcp_tools"][dependency.provider_name or dependency.provider_id] = dependency.provider_id
|
|
||||||
return auto_tools
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_auto_tool_names(tenant_id: str, auto_tools: WizardToolMap) -> WizardToolMap:
|
|
||||||
return {
|
|
||||||
"api_tools": _resolve_api_tool_names(tenant_id, auto_tools["api_tools"]),
|
|
||||||
"workflow_tools": _resolve_workflow_tool_names(tenant_id, auto_tools["workflow_tools"]),
|
|
||||||
"mcp_tools": _resolve_mcp_tool_names(tenant_id, auto_tools["mcp_tools"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_api_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
|
|
||||||
resolved: dict[str, str | None] = {}
|
|
||||||
for name, identifier in tools.items():
|
|
||||||
predicates = [ApiToolProvider.name == name]
|
|
||||||
if _is_uuid_string(identifier):
|
|
||||||
predicates.append(ApiToolProvider.id == identifier)
|
|
||||||
provider = db.session.scalar(
|
|
||||||
sa.select(ApiToolProvider).where(
|
|
||||||
ApiToolProvider.tenant_id == tenant_id,
|
|
||||||
sa.or_(*predicates),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
resolved[provider.name if provider else name] = provider.id if provider else identifier
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_workflow_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
|
|
||||||
resolved: dict[str, str | None] = {}
|
|
||||||
for name, identifier in tools.items():
|
|
||||||
predicates = [WorkflowToolProvider.name == name]
|
|
||||||
if _is_uuid_string(identifier):
|
|
||||||
predicates.append(WorkflowToolProvider.id == identifier)
|
|
||||||
provider = db.session.scalar(
|
|
||||||
sa.select(WorkflowToolProvider).where(
|
|
||||||
WorkflowToolProvider.tenant_id == tenant_id,
|
|
||||||
sa.or_(*predicates),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
resolved[provider.name if provider else name] = provider.id if provider else identifier
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mcp_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
|
|
||||||
resolved: dict[str, str | None] = {}
|
|
||||||
for name, identifier in tools.items():
|
|
||||||
predicates = [MCPToolProvider.name == name]
|
|
||||||
if identifier:
|
|
||||||
predicates.append(MCPToolProvider.server_identifier == identifier)
|
|
||||||
if _is_uuid_string(identifier):
|
|
||||||
predicates.append(MCPToolProvider.id == identifier)
|
|
||||||
provider = db.session.scalar(
|
|
||||||
sa.select(MCPToolProvider).where(
|
|
||||||
MCPToolProvider.tenant_id == tenant_id,
|
|
||||||
sa.or_(*predicates),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
resolved[provider.name if provider else name] = provider.id if provider else identifier
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _is_uuid_string(value: str | None) -> bool:
|
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
UUID(value)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _print_auto_tools(auto_tools: WizardToolMap) -> None:
|
|
||||||
_print_wizard_step("Automatically Discovered Tools")
|
|
||||||
click.echo("Automatically discovered tools:")
|
|
||||||
_print_auto_tool_category("Custom API tools", auto_tools["api_tools"])
|
|
||||||
_print_auto_tool_category("Workflow tools", auto_tools["workflow_tools"])
|
|
||||||
_print_auto_tool_category("MCP tools", auto_tools["mcp_tools"])
|
|
||||||
|
|
||||||
|
|
||||||
def _print_auto_tool_category(label: str, values: dict[str, str | None]) -> None:
|
|
||||||
click.echo(label)
|
|
||||||
if not values:
|
|
||||||
click.echo("- none")
|
|
||||||
return
|
|
||||||
for name, identifier in sorted(values.items()):
|
|
||||||
click.echo(f"- {_format_tool_name_id(name, identifier)}")
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_additional_tools(tenant_id: str, auto_tools: WizardToolMap) -> WizardToolSelection:
|
|
||||||
selections: WizardToolSelection = {"api_tools": [], "workflow_tools": [], "mcp_tools": []}
|
|
||||||
_print_wizard_step("Additional Tools")
|
|
||||||
if not click.confirm(
|
|
||||||
"Export additional tools manually? [y/n, default: n]",
|
|
||||||
default=False,
|
|
||||||
show_default=False,
|
|
||||||
):
|
|
||||||
_print_final_tool_selection(auto_tools, selections, {})
|
|
||||||
return selections
|
|
||||||
manual_labels: dict[str, str] = {}
|
|
||||||
api_tool_options = [
|
|
||||||
(tool.name, tool.name, tool.id)
|
|
||||||
for tool in db.session.scalars(
|
|
||||||
sa.select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant_id).order_by(ApiToolProvider.name)
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
selections["api_tools"] = _prompt_tool_category(
|
|
||||||
"Custom API tools",
|
|
||||||
api_tool_options,
|
|
||||||
auto_tools=auto_tools["api_tools"],
|
|
||||||
)
|
|
||||||
manual_labels.update(_selected_tool_labels(api_tool_options, selections["api_tools"]))
|
|
||||||
workflow_tool_options = [
|
|
||||||
(tool.id, tool.name, tool.id)
|
|
||||||
for tool in db.session.scalars(
|
|
||||||
sa.select(WorkflowToolProvider)
|
|
||||||
.where(WorkflowToolProvider.tenant_id == tenant_id)
|
|
||||||
.order_by(WorkflowToolProvider.name)
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
selections["workflow_tools"] = _prompt_tool_category(
|
|
||||||
"Workflow tools",
|
|
||||||
workflow_tool_options,
|
|
||||||
auto_tools=auto_tools["workflow_tools"],
|
|
||||||
)
|
|
||||||
manual_labels.update(_selected_tool_labels(workflow_tool_options, selections["workflow_tools"]))
|
|
||||||
mcp_tool_options = [
|
|
||||||
(tool.id, tool.name, tool.server_identifier)
|
|
||||||
for tool in db.session.scalars(
|
|
||||||
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id).order_by(MCPToolProvider.name)
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
selections["mcp_tools"] = _prompt_tool_category(
|
|
||||||
"MCP tools",
|
|
||||||
mcp_tool_options,
|
|
||||||
auto_tools=auto_tools["mcp_tools"],
|
|
||||||
)
|
|
||||||
manual_labels.update(_selected_tool_labels(mcp_tool_options, selections["mcp_tools"]))
|
|
||||||
_print_final_tool_selection(auto_tools, selections, manual_labels)
|
|
||||||
return selections
|
|
||||||
|
|
||||||
|
|
||||||
def _selected_tool_labels_for_tenant(tenant_id: str, selected_tools: WizardToolSelection) -> dict[str, str]:
|
|
||||||
labels: dict[str, str] = {}
|
|
||||||
if selected_tools["api_tools"]:
|
|
||||||
labels.update(
|
|
||||||
_selected_tool_labels(
|
|
||||||
[
|
|
||||||
(tool.name, tool.name, tool.id)
|
|
||||||
for tool in db.session.scalars(
|
|
||||||
sa.select(ApiToolProvider)
|
|
||||||
.where(ApiToolProvider.tenant_id == tenant_id)
|
|
||||||
.order_by(ApiToolProvider.name)
|
|
||||||
).all()
|
|
||||||
],
|
|
||||||
selected_tools["api_tools"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if selected_tools["workflow_tools"]:
|
|
||||||
labels.update(
|
|
||||||
_selected_tool_labels(
|
|
||||||
[
|
|
||||||
(tool.id, tool.name, tool.id)
|
|
||||||
for tool in db.session.scalars(
|
|
||||||
sa.select(WorkflowToolProvider)
|
|
||||||
.where(WorkflowToolProvider.tenant_id == tenant_id)
|
|
||||||
.order_by(WorkflowToolProvider.name)
|
|
||||||
).all()
|
|
||||||
],
|
|
||||||
selected_tools["workflow_tools"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if selected_tools["mcp_tools"]:
|
|
||||||
labels.update(
|
|
||||||
_selected_tool_labels(
|
|
||||||
[
|
|
||||||
(tool.id, tool.name, tool.server_identifier)
|
|
||||||
for tool in db.session.scalars(
|
|
||||||
sa.select(MCPToolProvider)
|
|
||||||
.where(MCPToolProvider.tenant_id == tenant_id)
|
|
||||||
.order_by(MCPToolProvider.name)
|
|
||||||
).all()
|
|
||||||
],
|
|
||||||
selected_tools["mcp_tools"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return labels
|
|
||||||
|
|
||||||
|
|
||||||
def _selected_tool_labels(options: list[tuple[str, str, str]], selected_values: list[str]) -> dict[str, str]:
|
|
||||||
selected = set(selected_values)
|
|
||||||
return {value: _format_tool_name_id(name, detail) for value, name, detail in options if value in selected}
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_tool_category(
|
|
||||||
label: str,
|
|
||||||
options: list[tuple[str, str, str]],
|
|
||||||
*,
|
|
||||||
auto_tools: dict[str, str | None],
|
|
||||||
) -> list[str]:
|
|
||||||
if not options:
|
|
||||||
click.echo(f"{label}: none")
|
|
||||||
return []
|
|
||||||
_print_wizard_step(label)
|
|
||||||
for index, (value, name, detail) in enumerate(options, 1):
|
|
||||||
marker = "[auto]" if _is_auto_tool(value, name, detail, auto_tools) else "[ ]"
|
|
||||||
click.echo(f"{index}. {marker} {name} ({detail})")
|
|
||||||
raw = click.prompt(
|
|
||||||
f"Select {label.lower()} by number, comma-separated numbers, all, or empty",
|
|
||||||
default="",
|
|
||||||
show_default=cast(Any, "empty"),
|
|
||||||
)
|
|
||||||
if not raw.strip():
|
|
||||||
return []
|
|
||||||
return parse_index_selection(raw, [value for value, _, _ in options])
|
|
||||||
|
|
||||||
|
|
||||||
def _is_auto_tool(value: str, name: str, detail: str, auto_tools: dict[str, str | None]) -> bool:
|
|
||||||
return name in auto_tools or value in auto_tools or value in auto_tools.values() or detail in auto_tools.values()
|
|
||||||
|
|
||||||
|
|
||||||
def _print_final_tool_selection(
|
|
||||||
auto_tools: WizardToolMap,
|
|
||||||
additional_tools: WizardToolSelection,
|
|
||||||
manual_labels: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
_print_wizard_step("Final Tool Selection")
|
|
||||||
_print_tool_selection_body(auto_tools, additional_tools, manual_labels)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_tool_selection_body(
|
|
||||||
auto_tools: WizardToolMap,
|
|
||||||
additional_tools: WizardToolSelection,
|
|
||||||
manual_labels: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
click.echo("Final tools to export:")
|
|
||||||
_print_final_tool_category(
|
|
||||||
"Custom API tools",
|
|
||||||
auto_tools["api_tools"],
|
|
||||||
additional_tools["api_tools"],
|
|
||||||
manual_labels,
|
|
||||||
)
|
|
||||||
_print_final_tool_category(
|
|
||||||
"Workflow tools",
|
|
||||||
auto_tools["workflow_tools"],
|
|
||||||
additional_tools["workflow_tools"],
|
|
||||||
manual_labels,
|
|
||||||
)
|
|
||||||
_print_final_tool_category("MCP tools", auto_tools["mcp_tools"], additional_tools["mcp_tools"], manual_labels)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_final_tool_category(
|
|
||||||
label: str,
|
|
||||||
auto_tools: dict[str, str | None],
|
|
||||||
manual_values: list[str],
|
|
||||||
manual_labels: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
click.echo(label)
|
|
||||||
lines = [f"- [auto] {_format_tool_name_id(name, identifier)}" for name, identifier in sorted(auto_tools.items())]
|
|
||||||
auto_identifiers = {identifier for identifier in auto_tools.values() if identifier}
|
|
||||||
lines.extend(
|
|
||||||
f"- [manual] {manual_labels.get(value, value)}"
|
|
||||||
for value in manual_values
|
|
||||||
if value not in auto_tools and value not in auto_identifiers
|
|
||||||
)
|
|
||||||
if not lines:
|
|
||||||
click.echo("- none")
|
|
||||||
return
|
|
||||||
for line in lines:
|
|
||||||
click.echo(line)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_name_id(name: str, identifier: str | None) -> str:
|
|
||||||
if identifier and identifier != name:
|
|
||||||
return f"{name}: {identifier}"
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def _confirm_wizard_summary(
|
|
||||||
*,
|
|
||||||
tenant_name: str,
|
|
||||||
app_names: list[str],
|
|
||||||
auto_tools: WizardToolMap,
|
|
||||||
additional_tools: WizardToolSelection,
|
|
||||||
manual_labels: dict[str, str],
|
|
||||||
include_referenced_tools: bool,
|
|
||||||
include_secrets: bool,
|
|
||||||
create_tokens: bool,
|
|
||||||
id_strategy: str,
|
|
||||||
conflict_strategy: str,
|
|
||||||
output_file: str,
|
|
||||||
) -> None:
|
|
||||||
_print_wizard_step("Summary")
|
|
||||||
click.echo("Migration export summary:")
|
|
||||||
click.echo(f"source tenant: {tenant_name}")
|
|
||||||
click.echo(f"selected apps: {len(app_names)}")
|
|
||||||
for app_name in app_names:
|
|
||||||
click.echo(f"- {app_name}")
|
|
||||||
click.echo(f"auto referenced tools: {str(include_referenced_tools).lower()}")
|
|
||||||
_print_tool_selection_body(auto_tools, additional_tools, manual_labels)
|
|
||||||
click.echo(f"include secrets: {str(include_secrets).lower()}")
|
|
||||||
click.echo(f"create app api token on import: {str(create_tokens).lower()}")
|
|
||||||
click.echo(f"id strategy: {id_strategy}")
|
|
||||||
click.echo(f"conflict strategy: {conflict_strategy}")
|
|
||||||
click.echo(f"output path: {output_file}")
|
|
||||||
if not click.confirm("Write migration package? [y/n, default: y]", default=True, show_default=False):
|
|
||||||
raise click.Abort()
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_output_file() -> tuple[str, bool]:
|
|
||||||
default_output = f"migration-data-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
|
||||||
output_file = click.prompt("Output path", default=default_output, show_default=True)
|
|
||||||
if output_file.lower() in {"y", "yes", "n", "no"}:
|
|
||||||
raise click.ClickException("Output path must be a file path. Press Enter to use the default path.")
|
|
||||||
overwrite = False
|
|
||||||
if Path(output_file).exists():
|
|
||||||
overwrite = click.confirm(
|
|
||||||
"Output file exists. Overwrite? [y/n, default: n]",
|
|
||||||
default=False,
|
|
||||||
show_default=False,
|
|
||||||
)
|
|
||||||
if not overwrite:
|
|
||||||
raise click.ClickException(f"Output file already exists: {output_file}")
|
|
||||||
return output_file, overwrite
|
|
||||||
|
|
||||||
|
|
||||||
def _with_output_path(context: ReportContext | None, output_path: str) -> ReportContext:
|
|
||||||
if context is None:
|
|
||||||
return ReportContext(output_path=output_path)
|
|
||||||
return ReportContext(
|
|
||||||
output_path=output_path,
|
|
||||||
source_scope=context.source_scope,
|
|
||||||
selected_app_count=context.selected_app_count,
|
|
||||||
include_secrets=context.include_secrets,
|
|
||||||
target_tenant=context.target_tenant,
|
|
||||||
operator_email=context.operator_email,
|
|
||||||
app_api_tokens_created=context.app_api_tokens_created,
|
|
||||||
app_api_tokens_reused=context.app_api_tokens_reused,
|
|
||||||
id_mapping_count=context.id_mapping_count,
|
|
||||||
id_mappings=context.id_mappings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_report(report_items: list[ResourceReportItem], *, context: ReportContext | None = None) -> None:
|
|
||||||
for line in MigrationReportService().render(report_items, context=context):
|
|
||||||
click.echo(line)
|
|
||||||
@ -11,8 +11,7 @@ from configs import dify_config
|
|||||||
from core.helper import encrypter
|
from core.helper import encrypter
|
||||||
from core.plugin.entities.plugin_daemon import CredentialType
|
from core.plugin.entities.plugin_daemon import CredentialType
|
||||||
from core.plugin.impl.plugin import PluginInstaller
|
from core.plugin.impl.plugin import PluginInstaller
|
||||||
from core.plugin.plugin_service import PluginService
|
from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params
|
||||||
from core.tools.utils.system_encryption import encrypt_system_params
|
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from models import Tenant
|
from models import Tenant
|
||||||
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
||||||
@ -21,6 +20,7 @@ from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
|||||||
from models.tools import ToolOAuthSystemClient
|
from models.tools import ToolOAuthSystemClient
|
||||||
from services.plugin.data_migration import PluginDataMigration
|
from services.plugin.data_migration import PluginDataMigration
|
||||||
from services.plugin.plugin_migration import PluginMigration
|
from services.plugin.plugin_migration import PluginMigration
|
||||||
|
from services.plugin.plugin_service import PluginService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ def setup_system_tool_oauth_client(provider, client_params):
|
|||||||
|
|
||||||
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
||||||
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
||||||
oauth_client_params = encrypt_system_params(client_params_dict)
|
oauth_client_params = encrypt_system_oauth_params(client_params_dict)
|
||||||
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
||||||
@ -94,7 +94,7 @@ def setup_system_trigger_oauth_client(provider, client_params):
|
|||||||
|
|
||||||
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
|
||||||
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
|
||||||
oauth_client_params = encrypt_system_params(client_params_dict)
|
oauth_client_params = encrypt_system_oauth_params(client_params_dict)
|
||||||
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
click.echo(click.style("Client params encrypted successfully.", fg="green"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
|
||||||
@ -185,9 +185,9 @@ def transform_datasource_credentials(environment: str):
|
|||||||
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
|
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
|
||||||
jina_plugin_id = "langgenius/jina_datasource"
|
jina_plugin_id = "langgenius/jina_datasource"
|
||||||
if environment == "online":
|
if environment == "online":
|
||||||
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
|
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id) # pyright: ignore[reportPrivateUsage]
|
||||||
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
|
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id) # pyright: ignore[reportPrivateUsage]
|
||||||
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
|
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id) # pyright: ignore[reportPrivateUsage]
|
||||||
else:
|
else:
|
||||||
notion_plugin_unique_identifier = None
|
notion_plugin_unique_identifier = None
|
||||||
firecrawl_plugin_unique_identifier = None
|
firecrawl_plugin_unique_identifier = None
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from libs.rsa import generate_key_pair
|
|||||||
from models import Tenant
|
from models import Tenant
|
||||||
from models.model import App, AppMode, Conversation
|
from models.model import App, AppMode, Conversation
|
||||||
from models.provider import Provider, ProviderModel
|
from models.provider import Provider, ProviderModel
|
||||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -24,16 +23,13 @@ DB_UPGRADE_LOCK_TTL_SECONDS = 60
|
|||||||
@click.command(
|
@click.command(
|
||||||
"reset-encrypt-key-pair",
|
"reset-encrypt-key-pair",
|
||||||
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
|
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
|
||||||
"After the reset, all LLM credentials and tool provider credentials "
|
"After the reset, all LLM credentials will become invalid, "
|
||||||
"(builtin / API / MCP) will be purged, requiring re-entry. "
|
"requiring re-entry."
|
||||||
"Only support SELF_HOSTED mode.",
|
"Only support SELF_HOSTED mode.",
|
||||||
)
|
)
|
||||||
@click.confirmation_option(
|
@click.confirmation_option(
|
||||||
prompt=click.style(
|
prompt=click.style(
|
||||||
"Are you sure you want to reset encrypt key pair? "
|
"Are you sure you want to reset encrypt key pair? This operation cannot be rolled back!", fg="red"
|
||||||
"This will also purge builtin / API / MCP tool provider records for every tenant. "
|
|
||||||
"This operation cannot be rolled back!",
|
|
||||||
fg="red",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
def reset_encrypt_key_pair():
|
def reset_encrypt_key_pair():
|
||||||
@ -57,13 +53,6 @@ def reset_encrypt_key_pair():
|
|||||||
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
|
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
|
||||||
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
|
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
|
||||||
|
|
||||||
# Purge tool provider records that hold credentials encrypted under the
|
|
||||||
# tenant key. Leaving them in place causes /console/api/workspaces/current/
|
|
||||||
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
|
|
||||||
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
|
|
||||||
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
|
|
||||||
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
|
|
||||||
|
|
||||||
click.echo(
|
click.echo(
|
||||||
click.style(
|
click.style(
|
||||||
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
|
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
|
||||||
|
|||||||
@ -30,7 +30,7 @@ def vdb_migrate(scope: str):
|
|||||||
|
|
||||||
def migrate_annotation_vector_database():
|
def migrate_annotation_vector_database():
|
||||||
"""
|
"""
|
||||||
Migrate annotation data to target vector database.
|
Migrate annotation datas to target vector database .
|
||||||
"""
|
"""
|
||||||
click.echo(click.style("Starting annotation data migration.", fg="green"))
|
click.echo(click.style("Starting annotation data migration.", fg="green"))
|
||||||
create_count = 0
|
create_count = 0
|
||||||
@ -140,7 +140,7 @@ def migrate_annotation_vector_database():
|
|||||||
|
|
||||||
def migrate_knowledge_vector_database():
|
def migrate_knowledge_vector_database():
|
||||||
"""
|
"""
|
||||||
Migrate vector database data to target vector database.
|
Migrate vector database datas to target vector database .
|
||||||
"""
|
"""
|
||||||
click.echo(click.style("Starting vector database migration.", fg="green"))
|
click.echo(click.style("Starting vector database migration.", fg="green"))
|
||||||
create_count = 0
|
create_count = 0
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, override
|
from typing import Any
|
||||||
|
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import FieldInfo
|
||||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
||||||
@ -25,7 +25,6 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
|
|||||||
def __init__(self, settings_cls: type[BaseSettings]):
|
def __init__(self, settings_cls: type[BaseSettings]):
|
||||||
super().__init__(settings_cls)
|
super().__init__(settings_cls)
|
||||||
|
|
||||||
@override
|
|
||||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@ -91,7 +90,6 @@ class DifyConfig(
|
|||||||
# Thanks for your concentration and consideration.
|
# Thanks for your concentration and consideration.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@override
|
|
||||||
def settings_customise_sources(
|
def settings_customise_sources(
|
||||||
cls,
|
cls,
|
||||||
settings_cls: type[BaseSettings],
|
settings_cls: type[BaseSettings],
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@ -25,7 +23,7 @@ class DeploymentConfig(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field(
|
EDITION: str = Field(
|
||||||
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
|
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
|
||||||
default="SELF_HOSTED",
|
default="SELF_HOSTED",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -23,12 +23,6 @@ class EnterpriseFeatureConfig(BaseSettings):
|
|||||||
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
||||||
)
|
)
|
||||||
|
|
||||||
ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="If disabled, credential policy check is only performed when saving workflows."
|
|
||||||
"This helps gain runtime performance by trading off consistency.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnterpriseTelemetryConfig(BaseSettings):
|
class EnterpriseTelemetryConfig(BaseSettings):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
from configs.extra.agent_backend_config import AgentBackendConfig
|
|
||||||
from configs.extra.archive_config import ArchiveStorageConfig
|
from configs.extra.archive_config import ArchiveStorageConfig
|
||||||
from configs.extra.notion_config import NotionConfig
|
from configs.extra.notion_config import NotionConfig
|
||||||
from configs.extra.sentry_config import SentryConfig
|
from configs.extra.sentry_config import SentryConfig
|
||||||
@ -6,7 +5,6 @@ from configs.extra.sentry_config import SentryConfig
|
|||||||
|
|
||||||
class ExtraServiceConfig(
|
class ExtraServiceConfig(
|
||||||
# place the configs in alphabet order
|
# place the configs in alphabet order
|
||||||
AgentBackendConfig,
|
|
||||||
ArchiveStorageConfig,
|
ArchiveStorageConfig,
|
||||||
NotionConfig,
|
NotionConfig,
|
||||||
SentryConfig,
|
SentryConfig,
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
from pydantic import Field
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBackendConfig(BaseSettings):
|
|
||||||
"""
|
|
||||||
Configuration settings for the Agent backend runtime integration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AGENT_BACKEND_BASE_URL: str | None = Field(
|
|
||||||
description="Base URL for the Dify Agent backend service.",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
AGENT_BACKEND_USE_FAKE: bool = Field(
|
|
||||||
description="Use the deterministic in-process fake Agent backend client.",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
AGENT_BACKEND_FAKE_SCENARIO: str = Field(
|
|
||||||
description="Scenario used by the fake Agent backend client.",
|
|
||||||
default="success",
|
|
||||||
)
|
|
||||||
@ -24,8 +24,8 @@ class SecurityConfig(BaseSettings):
|
|||||||
|
|
||||||
SECRET_KEY: str = Field(
|
SECRET_KEY: str = Field(
|
||||||
description="Secret key for secure session cookie signing."
|
description="Secret key for secure session cookie signing."
|
||||||
"Leave empty to let Dify generate a persistent key in the storage directory, "
|
"Make sure you are changing this key for your deployment with a strong key."
|
||||||
"or set a strong value via the `SECRET_KEY` environment variable.",
|
"Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.",
|
||||||
default="",
|
default="",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -265,11 +265,6 @@ class PluginConfig(BaseSettings):
|
|||||||
default=60 * 60,
|
default=60 * 60,
|
||||||
)
|
)
|
||||||
|
|
||||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
|
||||||
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
|
||||||
default=60 * 60 * 24,
|
|
||||||
)
|
|
||||||
|
|
||||||
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
||||||
description="Maximum allowed size (bytes) for plugin-generated files",
|
description="Maximum allowed size (bytes) for plugin-generated files",
|
||||||
default=50 * 1024 * 1024,
|
default=50 * 1024 * 1024,
|
||||||
@ -292,27 +287,6 @@ class MarketplaceConfig(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CreatorsPlatformConfig(BaseSettings):
|
|
||||||
"""
|
|
||||||
Configuration for Creators Platform integration
|
|
||||||
"""
|
|
||||||
|
|
||||||
CREATORS_PLATFORM_FEATURES_ENABLED: bool = Field(
|
|
||||||
description="Enable or disable Creators Platform features",
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
CREATORS_PLATFORM_API_URL: HttpUrl = Field(
|
|
||||||
description="Creators Platform API URL",
|
|
||||||
default=HttpUrl("https://creators.dify.ai"),
|
|
||||||
)
|
|
||||||
|
|
||||||
CREATORS_PLATFORM_OAUTH_CLIENT_ID: str = Field(
|
|
||||||
description="OAuth client ID for Creators Platform integration",
|
|
||||||
default="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointConfig(BaseSettings):
|
class EndpointConfig(BaseSettings):
|
||||||
"""
|
"""
|
||||||
Configuration for various application endpoints and URLs
|
Configuration for various application endpoints and URLs
|
||||||
@ -525,44 +499,6 @@ class HttpConfig(BaseSettings):
|
|||||||
def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
||||||
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",")
|
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",")
|
||||||
|
|
||||||
OPENAPI_ENABLED: bool = Field(
|
|
||||||
description=(
|
|
||||||
"Enable the /openapi/v1/* endpoint group used by difyctl and other "
|
|
||||||
"programmatic clients. Set to true to activate; disabled by default."
|
|
||||||
),
|
|
||||||
validation_alias=AliasChoices("OPENAPI_ENABLED"),
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
inner_OPENAPI_CORS_ALLOW_ORIGINS: str = Field(
|
|
||||||
description=(
|
|
||||||
"Comma-separated allowlist for /openapi/v1/* CORS. "
|
|
||||||
"Default empty = same-origin only. Browser-cookie routes within "
|
|
||||||
"the group reject cross-origin OPTIONS regardless of this list."
|
|
||||||
),
|
|
||||||
validation_alias=AliasChoices("OPENAPI_CORS_ALLOW_ORIGINS"),
|
|
||||||
default="",
|
|
||||||
)
|
|
||||||
|
|
||||||
@computed_field
|
|
||||||
def OPENAPI_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
|
||||||
return [o for o in self.inner_OPENAPI_CORS_ALLOW_ORIGINS.split(",") if o]
|
|
||||||
|
|
||||||
inner_OPENAPI_KNOWN_CLIENT_IDS: str = Field(
|
|
||||||
description=(
|
|
||||||
"Comma-separated client_id values accepted at "
|
|
||||||
"POST /openapi/v1/oauth/device/code. New CLIs / SDKs added here "
|
|
||||||
"without code changes. Unknown client_id returns 400 unsupported_client."
|
|
||||||
),
|
|
||||||
validation_alias=AliasChoices("OPENAPI_KNOWN_CLIENT_IDS"),
|
|
||||||
default="difyctl",
|
|
||||||
)
|
|
||||||
|
|
||||||
@computed_field # type: ignore[misc]
|
|
||||||
@property
|
|
||||||
def OPENAPI_KNOWN_CLIENT_IDS(self) -> frozenset[str]:
|
|
||||||
return frozenset(c for c in self.inner_OPENAPI_KNOWN_CLIENT_IDS.split(",") if c)
|
|
||||||
|
|
||||||
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field(
|
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field(
|
||||||
ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10
|
ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10
|
||||||
)
|
)
|
||||||
@ -804,7 +740,7 @@ class WorkflowConfig(BaseSettings):
|
|||||||
# GraphEngine Worker Pool Configuration
|
# GraphEngine Worker Pool Configuration
|
||||||
GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field(
|
GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field(
|
||||||
description="Minimum number of workers per GraphEngine instance",
|
description="Minimum number of workers per GraphEngine instance",
|
||||||
default=3,
|
default=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field(
|
GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field(
|
||||||
@ -938,17 +874,6 @@ class AuthConfig(BaseSettings):
|
|||||||
default=86400,
|
default=86400,
|
||||||
)
|
)
|
||||||
|
|
||||||
ENABLE_OAUTH_BEARER: bool = Field(
|
|
||||||
description="Enable OAuth bearer authentication (device-flow + Service API /v1/* bearer middleware).",
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field(
|
|
||||||
description="Per-token rate limit on /openapi/v1/* (requests per minute). "
|
|
||||||
"Bucket keyed on sha256(token), shared across api replicas via Redis.",
|
|
||||||
default=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ModerationConfig(BaseSettings):
|
class ModerationConfig(BaseSettings):
|
||||||
"""
|
"""
|
||||||
@ -1191,18 +1116,6 @@ class MultiModalTransferConfig(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OpsTraceConfig(BaseSettings):
|
|
||||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
|
|
||||||
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
|
|
||||||
default=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field(
|
|
||||||
description="Delay in seconds between transient ops trace provider dispatch retry attempts.",
|
|
||||||
default=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CeleryBeatConfig(BaseSettings):
|
class CeleryBeatConfig(BaseSettings):
|
||||||
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
||||||
description="Interval in days for Celery Beat scheduler execution, default to 1 day",
|
description="Interval in days for Celery Beat scheduler execution, default to 1 day",
|
||||||
@ -1235,14 +1148,6 @@ class CeleryScheduleTasksConfig(BaseSettings):
|
|||||||
description="Enable scheduled workflow run cleanup task",
|
description="Enable scheduled workflow run cleanup task",
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK: bool = Field(
|
|
||||||
description="Enable scheduled cleanup of revoked/expired OAuth access-token rows past retention.",
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
OAUTH_ACCESS_TOKEN_RETENTION_DAYS: PositiveInt = Field(
|
|
||||||
description="Days to retain revoked OAuth access-token rows before deletion.",
|
|
||||||
default=30,
|
|
||||||
)
|
|
||||||
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field(
|
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field(
|
||||||
description="Enable mail clean document notify task",
|
description="Enable mail clean document notify task",
|
||||||
default=False,
|
default=False,
|
||||||
@ -1369,13 +1274,6 @@ class PositionConfig(BaseSettings):
|
|||||||
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
|
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
|
||||||
|
|
||||||
|
|
||||||
class CollaborationConfig(BaseSettings):
|
|
||||||
ENABLE_COLLABORATION_MODE: bool = Field(
|
|
||||||
description="Whether to enable collaboration mode features across the workspace",
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginConfig(BaseSettings):
|
class LoginConfig(BaseSettings):
|
||||||
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
|
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
|
||||||
description="whether to enable email code login",
|
description="whether to enable email code login",
|
||||||
@ -1474,7 +1372,6 @@ class FeatureConfig(
|
|||||||
AuthConfig, # Changed from OAuthConfig to AuthConfig
|
AuthConfig, # Changed from OAuthConfig to AuthConfig
|
||||||
BillingConfig,
|
BillingConfig,
|
||||||
CodeExecutionSandboxConfig,
|
CodeExecutionSandboxConfig,
|
||||||
CreatorsPlatformConfig,
|
|
||||||
TriggerConfig,
|
TriggerConfig,
|
||||||
AsyncWorkflowConfig,
|
AsyncWorkflowConfig,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
@ -1491,7 +1388,6 @@ class FeatureConfig(
|
|||||||
ModelLoadBalanceConfig,
|
ModelLoadBalanceConfig,
|
||||||
ModerationConfig,
|
ModerationConfig,
|
||||||
MultiModalTransferConfig,
|
MultiModalTransferConfig,
|
||||||
OpsTraceConfig,
|
|
||||||
PositionConfig,
|
PositionConfig,
|
||||||
RagEtlConfig,
|
RagEtlConfig,
|
||||||
RepositoryConfig,
|
RepositoryConfig,
|
||||||
@ -1503,7 +1399,6 @@ class FeatureConfig(
|
|||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowNodeExecutionConfig,
|
WorkflowNodeExecutionConfig,
|
||||||
WorkspaceConfig,
|
WorkspaceConfig,
|
||||||
CollaborationConfig,
|
|
||||||
LoginConfig,
|
LoginConfig,
|
||||||
AccountConfig,
|
AccountConfig,
|
||||||
SwaggerUIConfig,
|
SwaggerUIConfig,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Literal, TypedDict, cast
|
from typing import Any, Literal, TypedDict
|
||||||
from urllib.parse import parse_qsl, quote_plus
|
from urllib.parse import parse_qsl, quote_plus
|
||||||
|
|
||||||
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
||||||
@ -50,7 +50,9 @@ from .vdb.vastbase_vector_config import VastbaseVectorConfig
|
|||||||
from .vdb.vikingdb_config import VikingDBConfig
|
from .vdb.vikingdb_config import VikingDBConfig
|
||||||
from .vdb.weaviate_config import WeaviateConfig
|
from .vdb.weaviate_config import WeaviateConfig
|
||||||
|
|
||||||
_VALID_STORAGE_TYPE = Literal[
|
|
||||||
|
class StorageConfig(BaseSettings):
|
||||||
|
STORAGE_TYPE: Literal[
|
||||||
"opendal",
|
"opendal",
|
||||||
"s3",
|
"s3",
|
||||||
"aliyun-oss",
|
"aliyun-oss",
|
||||||
@ -64,16 +66,12 @@ _VALID_STORAGE_TYPE = Literal[
|
|||||||
"volcengine-tos",
|
"volcengine-tos",
|
||||||
"supabase",
|
"supabase",
|
||||||
"local",
|
"local",
|
||||||
]
|
] = Field(
|
||||||
|
|
||||||
|
|
||||||
class StorageConfig(BaseSettings):
|
|
||||||
STORAGE_TYPE: _VALID_STORAGE_TYPE = Field(
|
|
||||||
description="Type of storage to use."
|
description="Type of storage to use."
|
||||||
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
|
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
|
||||||
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
|
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
|
||||||
"'volcengine-tos', 'supabase'. Default is 'opendal'.",
|
"'volcengine-tos', 'supabase'. Default is 'opendal'.",
|
||||||
default=cast(_VALID_STORAGE_TYPE, "opendal"),
|
default="opendal",
|
||||||
)
|
)
|
||||||
|
|
||||||
STORAGE_LOCAL_PATH: str = Field(
|
STORAGE_LOCAL_PATH: str = Field(
|
||||||
@ -116,7 +114,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict):
|
|||||||
pool_pre_ping: bool
|
pool_pre_ping: bool
|
||||||
connect_args: dict[str, str]
|
connect_args: dict[str, str]
|
||||||
pool_use_lifo: bool
|
pool_use_lifo: bool
|
||||||
pool_reset_on_return: Literal["commit", "rollback", None]
|
pool_reset_on_return: None
|
||||||
pool_timeout: int
|
pool_timeout: int
|
||||||
|
|
||||||
|
|
||||||
@ -225,11 +223,6 @@ class DatabaseConfig(BaseSettings):
|
|||||||
default=30,
|
default=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
SQLALCHEMY_POOL_RESET_ON_RETURN: Literal["commit", "rollback", None] = Field(
|
|
||||||
description="Connection pool reset behavior on return. Options: 'commit', 'rollback', or None",
|
|
||||||
default="rollback",
|
|
||||||
)
|
|
||||||
|
|
||||||
RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field(
|
RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field(
|
||||||
description="Number of processes for the retrieval service, default to CPU cores.",
|
description="Number of processes for the retrieval service, default to CPU cores.",
|
||||||
default=os.cpu_count() or 1,
|
default=os.cpu_count() or 1,
|
||||||
@ -259,7 +252,7 @@ class DatabaseConfig(BaseSettings):
|
|||||||
"pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING,
|
"pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING,
|
||||||
"connect_args": connect_args,
|
"connect_args": connect_args,
|
||||||
"pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO,
|
"pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO,
|
||||||
"pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN,
|
"pool_reset_on_return": None,
|
||||||
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
|
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -2,7 +2,6 @@ from typing import Literal, Protocol, cast
|
|||||||
from urllib.parse import quote_plus, urlunparse
|
from urllib.parse import quote_plus, urlunparse
|
||||||
|
|
||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic.types import NonNegativeInt
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
@ -71,24 +70,6 @@ class RedisPubSubConfig(BaseSettings):
|
|||||||
default=600,
|
default=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
|
|
||||||
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
|
|
||||||
description=(
|
|
||||||
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
|
|
||||||
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
|
|
||||||
"an SSE client and the response stream actually closing.\n\n"
|
|
||||||
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
|
|
||||||
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
|
|
||||||
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
|
|
||||||
"return promptly while the daemon listener thread cleans itself up on the next poll "
|
|
||||||
"boundary - safe because the listener holds no critical state and exits within one poll "
|
|
||||||
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
|
|
||||||
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
|
|
||||||
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
|
|
||||||
),
|
|
||||||
default=2000,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_default_pubsub_url(self) -> str:
|
def _build_default_pubsub_url(self) -> str:
|
||||||
defaults = _redis_defaults(self)
|
defaults = _redis_defaults(self)
|
||||||
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
||||||
|
|||||||
@ -41,21 +41,3 @@ class MilvusConfig(BaseSettings):
|
|||||||
description='Milvus text analyzer parameters, e.g., {"type": "chinese"} for Chinese segmentation support.',
|
description='Milvus text analyzer parameters, e.g., {"type": "chinese"} for Chinese segmentation support.',
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
MILVUS_SECURE: bool = Field(
|
|
||||||
description="Enable TLS for the Milvus connection (one-way TLS). When True, the client uses gRPC over TLS "
|
|
||||||
"and verifies the server certificate. Equivalent to passing secure=True to pymilvus.",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
MILVUS_SERVER_PEM_PATH: str | None = Field(
|
|
||||||
description="Filesystem path inside the container to the Milvus server certificate (PEM). Mount this via "
|
|
||||||
"a Kubernetes secret. Used as pymilvus's server_pem_path when MILVUS_SECURE is True.",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
MILVUS_SERVER_NAME: str | None = Field(
|
|
||||||
description="Server name (TLS SNI / certificate CN or SAN) to verify against the Milvus server certificate. "
|
|
||||||
"Required when MILVUS_SERVER_PEM_PATH is set.",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any, override
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import FieldInfo
|
||||||
@ -48,7 +48,6 @@ class ApolloSettingsSource(RemoteSettingsSource):
|
|||||||
self.namespace = configs["APOLLO_NAMESPACE"]
|
self.namespace = configs["APOLLO_NAMESPACE"]
|
||||||
self.remote_configs = self.client.get_all_dicts(self.namespace)
|
self.remote_configs = self.client.get_all_dicts(self.namespace)
|
||||||
|
|
||||||
@override
|
|
||||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||||
if not isinstance(self.remote_configs, dict):
|
if not isinstance(self.remote_configs, dict):
|
||||||
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any, override
|
from typing import Any
|
||||||
|
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import FieldInfo
|
||||||
|
|
||||||
@ -41,7 +41,6 @@ class NacosSettingsSource(RemoteSettingsSource):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Failed to parse config: {e}")
|
raise RuntimeError(f"Failed to parse config: {e}")
|
||||||
|
|
||||||
@override
|
|
||||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||||
field_value = self.remote_configs.get(field_name)
|
field_value = self.remote_configs.get(field_name)
|
||||||
if field_value is None:
|
if field_value is None:
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
"""SECRET_KEY persistence helpers for runtime setup."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from extensions.ext_storage import storage
|
|
||||||
|
|
||||||
GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_secret_key(secret_key: str) -> str:
|
|
||||||
"""Return an explicit SECRET_KEY or a generated key persisted in storage."""
|
|
||||||
if secret_key:
|
|
||||||
return secret_key
|
|
||||||
|
|
||||||
return _load_or_create_secret_key()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_or_create_secret_key() -> str:
|
|
||||||
try:
|
|
||||||
persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip()
|
|
||||||
if persisted_key:
|
|
||||||
return persisted_key
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
generated_key = secrets.token_urlsafe(48)
|
|
||||||
|
|
||||||
try:
|
|
||||||
storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode())
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError(
|
|
||||||
f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. "
|
|
||||||
"Set SECRET_KEY explicitly or make storage writable."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return generated_key
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
"""Global pytest hooks for Dify backend tests.
|
|
||||||
|
|
||||||
This root conftest is loaded before package-specific conftests, which lets tests opt
|
|
||||||
into Docker-backed middleware before application modules read environment config.
|
|
||||||
It intentionally lives at the API root because pytest applies conftest.py files to
|
|
||||||
tests below their directory, and this setup is shared by api/tests and api/providers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.pytest_dify import (
|
|
||||||
DEFAULT_MIDDLEWARE_SERVICES,
|
|
||||||
DEFAULT_VDB_SERVICES,
|
|
||||||
DockerComposeStack,
|
|
||||||
build_middleware_stack,
|
|
||||||
build_vdb_stack,
|
|
||||||
ensure_backend_test_environment,
|
|
||||||
ensure_compose_env_files,
|
|
||||||
parse_services,
|
|
||||||
)
|
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
_DIFY_COMPOSE_STACKS_KEY = pytest.StashKey[list[DockerComposeStack]]()
|
|
||||||
|
|
||||||
# This must run at import time because package-specific conftests can import the
|
|
||||||
# Flask app before pytest_configure hooks from this file are called.
|
|
||||||
ensure_backend_test_environment(_REPO_ROOT)
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
||||||
group = parser.getgroup("dify")
|
|
||||||
group.addoption(
|
|
||||||
"--start-middleware",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Start the Docker middleware services needed by API integration tests.",
|
|
||||||
)
|
|
||||||
group.addoption(
|
|
||||||
"--middleware-services",
|
|
||||||
default=",".join(DEFAULT_MIDDLEWARE_SERVICES),
|
|
||||||
help="Comma-separated services from docker/docker-compose.middleware.yaml to start.",
|
|
||||||
)
|
|
||||||
group.addoption(
|
|
||||||
"--start-vdb",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Start vector-store Docker services for VDB integration tests.",
|
|
||||||
)
|
|
||||||
group.addoption(
|
|
||||||
"--vdb-services",
|
|
||||||
default=",".join(DEFAULT_VDB_SERVICES),
|
|
||||||
help="Comma-separated services from docker/docker-compose.yaml to start for VDB tests.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config: pytest.Config) -> None:
|
|
||||||
config.stash[_DIFY_COMPOSE_STACKS_KEY] = []
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_sessionstart(session: pytest.Session) -> None:
|
|
||||||
config = session.config
|
|
||||||
if hasattr(config, "workerinput"):
|
|
||||||
return
|
|
||||||
|
|
||||||
stacks: list[DockerComposeStack] = []
|
|
||||||
if config.getoption("start_middleware"):
|
|
||||||
ensure_compose_env_files(_REPO_ROOT)
|
|
||||||
stack = build_middleware_stack(_REPO_ROOT, parse_services(config.getoption("middleware_services")))
|
|
||||||
stack.up()
|
|
||||||
stacks.append(stack)
|
|
||||||
|
|
||||||
if config.getoption("start_vdb"):
|
|
||||||
ensure_compose_env_files(_REPO_ROOT)
|
|
||||||
stack = build_vdb_stack(_REPO_ROOT, parse_services(config.getoption("vdb_services")))
|
|
||||||
stack.up()
|
|
||||||
stacks.append(stack)
|
|
||||||
|
|
||||||
config.stash[_DIFY_COMPOSE_STACKS_KEY] = stacks
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
||||||
if hasattr(config, "workerinput"):
|
|
||||||
return
|
|
||||||
|
|
||||||
stacks = config.stash.get(_DIFY_COMPOSE_STACKS_KEY, [])
|
|
||||||
for stack in reversed(stacks):
|
|
||||||
stack.down()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user